diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 999fee9..b779af7 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -17,7 +17,7 @@ concurrency: jobs: build: name: Build - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 permissions: contents: read # clone repo steps: @@ -32,17 +32,7 @@ jobs: with: deno-version: v2.x - - name: Cache WASM - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - id: wasm-cache - with: - path: | - clayterm.wasm - wasm.ts - key: wasm-${{ hashFiles('Makefile', 'src/**', 'tasks/bundle-wasm.ts') }} - - name: Build WASM - if: steps.wasm-cache.outputs.cache-hit != 'true' run: make - name: Cache dependencies @@ -63,52 +53,7 @@ jobs: retention-days: 1 path: wasm.ts - simulation: - name: Run benchmarks (simulation) - needs: build - runs-on: codspeed-macro - permissions: - contents: read # clone repo - id-token: write # upload benchmark results to codspeed - - steps: - - name: Checkout - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - - - name: Setup Deno - uses: denoland/setup-deno@667a34cdef165d8d2b2e98dde39547c9daac7282 # v2.0.4 - with: - deno-version: v2.x - - - name: Setup Node - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 - with: - node-version: 24 - - - name: Download build artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 - with: - name: bench-build - - - name: Restore dependencies - uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 - id: deno-cache - with: - path: node_modules - key: deno-${{ hashFiles('deno.lock') }} - - - name: Install dependencies - if: steps.deno-cache.outputs.cache-hit != 'true' - run: deno install - - - name: Run benchmarks - uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0 - with: - mode: simulation - # IMPORTANT! deno task bench fails in CI due to incompatible V8 bindings - run: node bench/mod.ts - - walltime: + benchmarks: name: Run benchmarks (walltime) needs: build runs-on: codspeed-macro @@ -146,8 +91,12 @@ jobs: if: steps.deno-cache.outputs.cache-hit != 'true' run: deno install - - name: Run process startup benchmarks + - name: Run walltime benchmarks uses: CodSpeedHQ/action@9d332c4d90b43981c3e55ae8e38e68709996240f # v4.17.0 with: mode: walltime - run: node bench/startup.bench.ts + run: | + node bench/startup.bench.ts + node bench/throughput.bench.ts + node bench/render.bench.ts + node bench/ops.bench.ts diff --git a/bench/input.bench.ts b/bench/input.bench.ts deleted file mode 100644 index 8e2c96b..0000000 --- a/bench/input.bench.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { Bench } from "tinybench"; -import { withCodSpeed } from "@codspeed/tinybench-plugin"; -import { createInput } from "../input.ts"; - -function bytes(...values: number[]): Uint8Array { - return new Uint8Array(values); -} - -function str(s: string): Uint8Array { - return new TextEncoder().encode(s); -} - -let input = await createInput({ escLatency: 25 }); - -let longBurst = new Uint8Array(200); -for (let i = 0; i < 200; i++) { - longBurst[i] = 0x61 + (i % 26); -} - -let bench = withCodSpeed(new Bench()); - -bench - .add("printable ASCII (single char)", () => { - input.scan(bytes(0x61)); - }) - .add("printable ASCII (short string)", () => { - input.scan(str("hello world")); - }) - .add("arrow key (CSI sequence)", () => { - input.scan(bytes(0x1b, 0x5b, 0x41)); - }) - .add("modifier combo (Ctrl+Shift+Arrow)", () => { - input.scan(bytes(0x1b, 0x5b, 0x31, 0x3b, 0x38, 0x41)); - }) - .add("SGR mouse press", () => { - input.scan(str("\x1b[<0;35;12M")); - }) - .add("multi-event burst (arrows + text)", () => { - input.scan(bytes(0x1b, 0x5b, 0x41, 0x1b, 0x5b, 0x42, 0x68, 0x69)); - }) - .add("UTF-8 3-byte character", () => { - input.scan(bytes(0xe4, 0xb8, 0xad)); - }) - .add("UTF-8 4-byte emoji", () => { - input.scan(bytes(0xf0, 0x9f, 0x8e, 0x89)); - }) - .add("Kitty protocol (CSI u with modifiers)", () => { - input.scan(str("\x1b[97;3u")); - }) - .add("long input burst (200 bytes)", () => { - input.scan(longBurst); - }); - -await bench.run(); -console.table(bench.table()); diff --git a/bench/mod.ts b/bench/mod.ts index ca9de34..8111868 100644 --- a/bench/mod.ts +++ b/bench/mod.ts @@ -1,3 +1,3 @@ -import "./input.bench.ts"; +import "./throughput.bench.ts"; import "./render.bench.ts"; import "./ops.bench.ts"; diff --git a/bench/ops.bench.ts b/bench/ops.bench.ts index 3019792..91b2dc8 100644 --- a/bench/ops.bench.ts +++ b/bench/ops.bench.ts @@ -3,18 +3,6 @@ import { withCodSpeed } from "@codspeed/tinybench-plugin"; import { close, fixed, grow, open, pack, rgba, text } from "../ops.ts"; import type { Op } from "../ops.ts"; -function makeBuf(size: number): ArrayBuffer { - return new ArrayBuffer(size); -} - -let simpleOps: Op[] = [ - open("root", { - layout: { width: grow(), height: grow(), direction: "ttb" }, - }), - text("Hello, World!"), - close(), -]; - let complexOps: Op[] = [ open("root", { layout: { width: grow(), height: grow(), direction: "ttb" }, @@ -104,20 +92,18 @@ let listOps: Op[] = [ close(), ]; -let bench = withCodSpeed(new Bench()); +let buf = new ArrayBuffer(32768); + +let bench = withCodSpeed(new Bench({ name: "ops" })); bench - .add("simple tree (root + text)", () => { - let buf = makeBuf(4096); - pack(simpleOps, buf, 0); - }) - .add("complex layout (header + sidebar + main + footer)", () => { - let buf = makeBuf(8192); - pack(complexOps, buf, 0); + .add("pack complex layout", () => { + for (let i = 0; i < 1500; i++) pack(complexOps, buf, 0); + return Promise.resolve(); }) - .add("large list (50 items)", () => { - let buf = makeBuf(32768); - pack(listOps, buf, 0); + .add("pack large list", () => { + for (let i = 0; i < 250; i++) pack(listOps, buf, 0); + return Promise.resolve(); }); await bench.run(); diff --git a/bench/render.bench.ts b/bench/render.bench.ts index 1c3b8c3..44e9c56 100644 --- a/bench/render.bench.ts +++ b/bench/render.bench.ts @@ -5,40 +5,6 @@ import { close, fixed, grow, open, rgba, text } from "../ops.ts"; import type { Op } from "../ops.ts"; let term = await createTerm({ width: 80, height: 24 }); -let termPtr = await createTerm({ width: 80, height: 24 }); - -let helloOps: Op[] = [ - open("root", { - layout: { width: grow(), height: grow(), direction: "ttb" }, - }), - text("Hello, World!"), - close(), -]; - -let borderedOps: Op[] = [ - open("root", { - layout: { width: grow(), height: grow(), direction: "ttb" }, - }), - open("box", { - layout: { - width: grow(), - height: grow(), - padding: { left: 1, right: 1, top: 1, bottom: 1 }, - direction: "ttb", - }, - border: { - color: rgba(0, 255, 0), - left: 1, - right: 1, - top: 1, - bottom: 1, - }, - cornerRadius: { tl: 1, tr: 1, bl: 1, br: 1 }, - }), - text("Bordered content"), - close(), - close(), -]; let dashboardOps: Op[] = [ open("root", { @@ -134,24 +100,20 @@ let uiOps: Op[] = [ close(), ]; -let bench = withCodSpeed(new Bench()); +let bench = withCodSpeed(new Bench({ name: "render" })); bench - .add("simple text", () => { - term.render(helloOps); - }) - .add("bordered box with corner radius", () => { - term.render(borderedOps); - }) - .add("dashboard layout", () => { - term.render(dashboardOps); - }) - .add("diff render (second frame)", () => { - term.render(dashboardOps); - term.render(dashboardOps); + .add("render mixed frames", () => { + for (let i = 0; i < 250; i++) { + term.render(i % 2 === 0 ? dashboardOps : uiOps); + } + return Promise.resolve(); }) - .add("render with pointer hit testing", () => { - termPtr.render(uiOps, { pointer: { x: 10, y: 1, down: false } }); + .add("render steady diff", () => { + for (let i = 0; i < 250; i++) { + term.render(dashboardOps); + } + return Promise.resolve(); }); await bench.run(); diff --git a/bench/throughput.bench.ts b/bench/throughput.bench.ts new file mode 100644 index 0000000..b3ccdd8 --- /dev/null +++ b/bench/throughput.bench.ts @@ -0,0 +1,47 @@ +import { Bench } from "tinybench"; +import { withCodSpeed } from "@codspeed/tinybench-plugin"; +import { createInput } from "../input.ts"; + +function str(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +function concat(parts: Uint8Array[]): Uint8Array { + let len = parts.reduce((n, p) => n + p.length, 0); + let out = new Uint8Array(len); + let off = 0; + for (let p of parts) { + out.set(p, off); + off += p.length; + } + return out; +} + +let unit = concat([ + str("the quick brown fox "), + new Uint8Array([0x1b, 0x5b, 0x41]), // ArrowUp + str("\x1b[<0;40;12M"), // SGR mouse press + new Uint8Array([0xe4, 0xb8, 0xad]), // 中 + str("\x1b[97;3u"), // Kitty a+Alt + new Uint8Array([0xf0, 0x9f, 0x8e, 0x89]), // 🎉 +]); +let corpus = concat(new Array(1000).fill(unit)); + +let READ = 64; + +let input = await createInput({ escLatency: 25 }); + +let bench = withCodSpeed(new Bench({ name: "throughput" })); + +bench.add("input throughput (mixed corpus, chunked read loop)", () => { + let dispatched = 0; + for (let off = 0; off < corpus.length; off += READ) { + let { events } = input.scan(corpus.subarray(off, off + READ)); + dispatched += events.length; + } + if (dispatched === 0) throw new Error("expected events"); + return Promise.resolve(); +}); + +await bench.run(); +console.table(bench.table()); diff --git a/test/input-loop.test.ts b/test/input-loop.test.ts new file mode 100644 index 0000000..f8b6335 --- /dev/null +++ b/test/input-loop.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "./suite.ts"; +import { createInput, type InputEvent, type InputOptions } from "../input.ts"; + +function str(s: string): Uint8Array { + return new TextEncoder().encode(s); +} + +function concat(...parts: Uint8Array[]): Uint8Array { + let len = parts.reduce((n, p) => n + p.length, 0); + let out = new Uint8Array(len); + let off = 0; + for (let p of parts) { + out.set(p, off); + off += p.length; + } + return out; +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function sig(e: InputEvent): string { + switch (e.type) { + case "keydown": + case "keyrepeat": + case "keyup": { + let m = `${e.alt ? "a" : ""}${e.ctrl ? "c" : ""}${e.shift ? "s" : ""}`; + return `${e.type}:${e.key}${m ? `:${m}` : ""}`; + } + case "mousedown": + case "mouseup": + case "mousemove": + return `${e.type}:${e.button}:${e.x},${e.y}`; + case "wheel": + return `${e.type}:${e.direction}:${e.x},${e.y}`; + case "cursor": + return `cursor:${e.row},${e.column}`; + case "resize": + return `resize:${e.width}x${e.height}`; + default: + return e.type; + } +} + +async function drive( + chunks: Uint8Array[], + opts: InputOptions = { escLatency: 25 }, +): Promise { + let input = await createInput(opts); + let all: InputEvent[] = []; + for (let i = 0; i < chunks.length; i++) { + let { events, pending } = input.scan(chunks[i]); + all.push(...events); + if (pending && i === chunks.length - 1) { + await sleep(pending.delay + 10); + all.push(...input.scan().events); + } + } + return all.map(sig); +} + +function perByte(buf: Uint8Array): Uint8Array[] { + let out: Uint8Array[] = []; + for (let i = 0; i < buf.length; i++) out.push(buf.subarray(i, i + 1)); + return out; +} + +function splitAt(buf: Uint8Array, offsets: number[]): Uint8Array[] { + let out: Uint8Array[] = []; + let prev = 0; + for (let o of [...offsets, buf.length]) { + out.push(buf.subarray(prev, o)); + prev = o; + } + return out; +} + +describe("input event loop", () => { + let stream = concat( + str("hi"), + new Uint8Array([0x1b, 0x5b, 0x41]), // ArrowUp + str("\x1b[<0;35;12M"), // SGR mouse press + new Uint8Array([0xe4, 0xb8, 0xad]), // 中 + str("\x1b[97;3u"), // Kitty a+Alt + new Uint8Array([0xf0, 0x9f, 0x8e, 0x89]), // 🎉 + ); + + let expected = [ + "keydown:h", + "keydown:i", + "keydown:ArrowUp", + "mousedown:left:34,11", + "keydown:中", + "keydown:a:a", + "keydown:🎉", + ]; + + it("produces the expected sequence when fed whole", async () => { + expect(await drive([stream])).toEqual(expected); + }); + + it("is invariant to chunk boundaries (byte-by-byte)", async () => { + expect(await drive(perByte(stream))).toEqual(expected); + }); + + it("is invariant to chunk boundaries (mid-sequence splits)", async () => { + expect(await drive(splitAt(stream, [3, 5, 9, 16, 21]))).toEqual(expected); + }); + + describe("pending ESC flush", () => { + it("flushes a lone trailing ESC as Escape after the latency", async () => { + expect(await drive([str("hi"), new Uint8Array([0x1b])])).toEqual([ + "keydown:h", + "keydown:i", + "keydown:Escape", + ]); + }); + + it("resolves ESC as a sequence when the rest arrives next chunk", async () => { + expect( + await drive([new Uint8Array([0x1b]), new Uint8Array([0x5b, 0x41])]), + ).toEqual(["keydown:ArrowUp"]); + }); + }); + + it("handles a large mixed burst across many small chunks", async () => { + let unit = concat( + new Uint8Array([0x1b, 0x5b, 0x41]), // ArrowUp + str("ab"), + str("\x1b[<0;1;1M"), // mouse at 0,0 + new Uint8Array([0xe4, 0xb8, 0xad]), // 中 + ); + let n = 50; + let big = concat(...new Array(n).fill(unit)); + let chunks: Uint8Array[] = []; + for (let i = 0; i < big.length; i += 7) chunks.push(big.subarray(i, i + 7)); + + let sigs = await drive(chunks); + expect(sigs.length).toBe(n * 5); + expect(sigs.slice(0, 5)).toEqual([ + "keydown:ArrowUp", + "keydown:a", + "keydown:b", + "mousedown:left:0,0", + "keydown:中", + ]); + }); +});