From a53c75ca6907962d89608462f9fb1279bd9ebfe1 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 9 Nov 2026 19:52:48 +0000 Subject: [PATCH 1/4] fix: skip empty output chunks in the ghostty-web renderer An output event with data "" passes the event schema, but ghostty-web's terminal.write('') throws RangeError: offset is out of bounds. A replay that met one failed with it. The harness page now drops empty chunks in write() and writeBatch(): the write callback goes to the last chunk that is written, and a flush of empty chunks writes nothing. --- src/renderer/ghosttyWeb/README.md | 2 +- .../ghosttyWeb/embeddedHarnessHtml.ts | 28 +++- test/integration/renderer-backend.test.ts | 157 ++++++++++++++++++ 3 files changed, 178 insertions(+), 9 deletions(-) diff --git a/src/renderer/ghosttyWeb/README.md b/src/renderer/ghosttyWeb/README.md index e031d293..77435fbf 100644 --- a/src/renderer/ghosttyWeb/README.md +++ b/src/renderer/ghosttyWeb/README.md @@ -32,7 +32,7 @@ This directory holds the `ghostty-web` reference renderer. `GhosttyWebBackend` s - `replayWithTiming` asserts strictly increasing seqs and non-decreasing timestamps [backend.ts:GhosttyWebBackend.replayWithTiming]. - `replayWithTiming` calls the optional `onProgress` with a `ReplayProgress` in four places. Before each wait on the schedule, `scheduledWaitMs` is the length of that wait. A wait longer than a timer runs (2^31-1 ms) is a chain of timers, so a recorded gap of 30 days takes 30 days, and a report that carries the rest of the wait follows each timer except the last. After each bridge call of a flush, and after the resize call of a resize event, `scheduledWaitMs` is 0. After the last event and its flush, and before the final calls to the renderer, `scheduledWaitMs` is 0 too, so a stall in those calls names the last event, also when that event changed no pixel. `seq` is the last event that the replay had reached. `generateWebmExport` uses these reports to tell a stall from a wait [backend.ts:GhosttyWebBackend.replayWithTiming]. - `replayWithTiming` waits until each event's place on a schedule built from the recorded gaps: `recorded` keeps them, `accelerated` caps each at `maxGapMs`, and `max-speed` sets them to 0. It measures that schedule with `performance.now()`, so frame holds and bridge calls use up idle time instead of adding to it. In `recorded` and `accelerated` mode it flushes pending output once the next event is at least one frame hold after the batch's first output, unless the replay runs behind the schedule, in which case the batch grows until it catches up. `max-speed` has no schedule to fall behind, so recorded time ends most of its flushes. In `max-speed` mode, a flush ends at the first event that is at least one frame hold of recorded time after the batch's first output. Also in `max-speed` mode, an event that changes no pixel (input, marker, run-complete, signal or exit) ends a flush only through that rule, because a flush at each of them would cost a frame per event. In `recorded` and `accelerated` mode such an event ends a flush directly (see below). A resize that finds output waiting and the end of the replay end a flush in every mode. `max-speed` also spreads at most `MAX_SPEED_FRAME_BUDGET` (2,400) flushes over the recorded span of the events that it applies: the distance between the first output of a flush and the event that ends it is at least the larger of the frame hold and the span divided by 2,400. So in `max-speed` mode, recorded time makes about one flush per frame hold for a recording shorter than 38 s and at most about 2,400 flushes for a longer one, whatever its number of output events. A resize that finds output waiting ends a flush too. These flushes do not count against the 2,400, so each resize adds at most one flush, and none when no output is waiting: 100,000 outputs that each have a resize 1 ms after them make 100,000 flushes. In `max-speed` mode, output that arrives less than that distance after the first output of a flush shares the flush, also when an input or a marker sits between, so a state that the next output of the flush replaces is never painted. A flush waits for one animation frame. `max-speed` holds only after the last flush of the replay and around a resize. After the last flush and after a resize it holds the frame hold. Before a resize it holds one video frame (40 ms, from `VIDEO_FPS` = 25), or the frame hold when that is longer, because a screen that stays on the page for less than one video frame can be missing from the video. Two flushes that follow each other can paint within one video frame. In `recorded` and `accelerated` mode, every event that changes no pixel ends a flush, and a hold follows every flush. Output events with the same timestamp share a flush at any hold, 0 included, because the time check needs at least 1 ms of recorded time. An event between them that ends a flush splits them anyway: a resize in every mode, and in `recorded` and `accelerated` mode also an input, a marker, a run-complete, a signal or an exit [backend.ts:GhosttyWebBackend.replayWithTiming]. -- `writeBatch(chunks, endsFlush, paintedCap)` takes the chunks of one bridge call. A flush that has more than 1000 chunks needs several calls, and ghostty-web paints on every animation frame, so the page holds the chunks of a call that does not end the flush. The call that ends it (`endsFlush`, set by the backend on the last call only) writes every held chunk in one task, so no frame paints a state between two calls, and then resolves after one frame. ghostty-web calls a write callback one animation frame (about 17 ms) after the write, and waiting on every chunk (`write`) cost one frame per output event. `write()` and `resize()` throw while chunks are held. `discardHeldChunks()` drops them: the backend calls it when a flush fails, because `HostRendererManager` keeps the backend and a retry would otherwise meet them. A `paintedCap` that is not null makes the call that ends the flush add the graphemes on the screen to the set that the missing-glyph check of `replayWithTiming` probes, so a flush is one page call. The call returns the message of a failed note and not an error, because a failed note must not fail the flush. In `replayWithTiming`, the chunks of one flush share one video frame [embeddedHarnessHtml.ts:EMBEDDED_HARNESS_HTML]. +- `writeBatch(chunks, endsFlush, paintedCap)` takes the chunks of one bridge call. A flush that has more than 1000 chunks needs several calls, and ghostty-web paints on every animation frame, so the page holds the chunks of a call that does not end the flush. The call that ends it (`endsFlush`, set by the backend on the last call only) writes every held chunk in one task, so no frame paints a state between two calls, and then resolves after one frame. ghostty-web calls a write callback one animation frame (about 17 ms) after the write, and waiting on every chunk (`write`) cost one frame per output event. `write()` and `resize()` throw while chunks are held. `write` and `writeBatch` skip an empty chunk, because ghostty-web throws a `RangeError` on `terminal.write('')` and an output event with empty data is valid in an Event Log. The write callback goes to the last chunk that is written, and a flush of empty chunks writes nothing and waits for no frame. `discardHeldChunks()` drops them: the backend calls it when a flush fails, because `HostRendererManager` keeps the backend and a retry would otherwise meet them. A `paintedCap` that is not null makes the call that ends the flush add the graphemes on the screen to the set that the missing-glyph check of `replayWithTiming` probes, so a flush is one page call. The call returns the message of a failed note and not an error, because a failed note must not fail the flush. In `replayWithTiming`, the chunks of one flush share one video frame [embeddedHarnessHtml.ts:EMBEDDED_HARNESS_HTML]. - `snapshot()` and `screenshot()` throw until `replayTo()` or `replayWithTiming()` has run once and set the initial size. Both replays accept `targetSeq` -1 for an empty Event Log. A replay over zero events sets `lastAppliedSeq` to `targetSeq` [backend.ts:GhosttyWebBackend.snapshot]. - `missingGlyphs` lists a code point only when a bundled face that lacks it drew it. Chromium draws a cluster with one font: the first stack font that covers all of it, or else the first font of the stack, which is bundled in both built-in profiles and then draws `.notdef` boxes. CDP names the face that drew a probe by its PostScript name, so a family alias or several faces under one family resolve to the right cmap. When one bundled face drew the whole grapheme, every code point that face lacks is listed. A probe with no reported font is judged against every face of the first family. When the grapheme is split over several fonts, each uncovered code point is judged by its own probe, and a mark's probe on its base is compared with a probe of the base alone. Glyphs that a host font draws are not listed. The check runs after the PNG is written, removes its probe spans, and caches each probe text's result for the page, clearing the cache when it would pass `MAX_CACHED_GLYPH_PROBES` (16384) entries. It probes at most `MAX_GLYPH_PROBES` texts and lists at most `MAX_MISSING_GLYPH_CELLS` cells (4096 each). `probeUsedFonts` sends its CDP queries in batches of 256 and stops starting batches after `GLYPH_PROBE_BUDGET_MS` (1000 ms). The result sets `missingGlyphsTruncated` when any of these stops the check early. Family names match without regard to case. A web font whose PostScript name the profile does not bundle fails the check. A failed check is logged at debug level and leaves the field out, so the screenshot still succeeds. `replayWithTiming()` records the viewport graphemes in the page after each output batch and each resize (at most `MAX_CACHED_GLYPH_PROBES`), and the final frame hold judges them once, with the hold as the probe budget, so the check does not lengthen the video. `recordedMissingGlyphs()` returns that result. A failed note or check leaves the record export fields out. A profile whose font stack does not start with a bundled font gets no `missingGlyphs` field [missingGlyphs.ts:probeUsedFonts] [backend.ts:GhosttyWebBackend.screenshot]. - `screenshot()` requires an absolute `outputPath` whose parent directory exists. It checks that the output is a non-empty PNG with a valid signature and an `IHDR` first chunk [backend.ts:GhosttyWebBackend.screenshot]. diff --git a/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts b/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts index 666368d2..e4dfad21 100644 --- a/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts +++ b/src/renderer/ghosttyWeb/embeddedHarnessHtml.ts @@ -674,6 +674,10 @@ export const EMBEDDED_HARNESS_HTML = ` state.heldChunks.length === 0, 'write() cannot run while writeBatch() holds the chunks of a flush', ); + if (data.length === 0) { + // An empty write changes nothing, and ghostty-web throws on it. + return; + } await new Promise((resolve) => { terminal.write(data, resolve); @@ -706,8 +710,14 @@ export const EMBEDDED_HARNESS_HTML = ` // but ghostty-web calls a write callback one animation frame later, // so waiting on every chunk made a replay cost one frame per event. // The callback on the last chunk makes the call resolve after a frame. + // An empty chunk changes nothing, and ghostty-web throws a RangeError + // on terminal.write(''), so the page drops it here. The callback then + // goes to the last chunk that is written, and a flush of empty chunks + // writes nothing and waits for no frame. for (const chunk of chunks) { - state.heldChunks.push(chunk); + if (chunk.length > 0) { + state.heldChunks.push(chunk); + } } if (!endsFlush) { return null; @@ -715,14 +725,16 @@ export const EMBEDDED_HARNESS_HTML = ` const flush = state.heldChunks; state.heldChunks = []; - const lastIndex = flush.length - 1; - for (let index = 0; index < lastIndex; index += 1) { - terminal.write(flush[index]); + if (flush.length > 0) { + const lastIndex = flush.length - 1; + for (let index = 0; index < lastIndex; index += 1) { + terminal.write(flush[index]); + } + await new Promise((resolve) => { + terminal.write(flush[lastIndex], resolve); + }); + updateDocumentState(); } - await new Promise((resolve) => { - terminal.write(flush[lastIndex], resolve); - }); - updateDocumentState(); // The caller asks to note the painted graphemes of a recorded // replay here, so a flush costs one page call and not two. A failed diff --git a/test/integration/renderer-backend.test.ts b/test/integration/renderer-backend.test.ts index 8a1ee507..495bfcb7 100644 --- a/test/integration/renderer-backend.test.ts +++ b/test/integration/renderer-backend.test.ts @@ -1016,6 +1016,163 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { expect(screenRows.slice(0, 32)).toEqual(expectedRows); }); + // An output event with data "" passes the event schema, so a hand-made or + // older log can hold one anywhere. ghostty-web's terminal.write('') throws + // "RangeError: offset is out of bounds", so the page must not write it. + describe('empty output chunks', () => { + const outputEvents = (chunks: readonly string[]): ReplayInput['events'] => + chunks.map((data, seq) => ({ + seq, + ts: timestampFor(0), + type: 'output' as const, + payload: { data }, + })); + const screenText = ( + snapshot: Awaited>, + ): string => + snapshot.visibleLines + .map((line) => line.text) + .join('\n') + .trimEnd(); + const thousandXs = Array.from({ length: 1_000 }, () => 'x'); + + it.each([ + ['in the middle of a flush', ['ab', '', 'cd'], 'abcd', 4], + ['at the start of a flush', ['', 'ab', 'cd'], 'abcd', 4], + ['as the last chunk of a flush', ['ab', 'cd', ''], 'abcd', 4], + ['as every chunk of a flush', ['', '', ''], '', 0], + [ + 'as the only chunk of the call that ends a flush of 1001 chunks', + [...thousandXs, ''], + `${'x'.repeat(80)}\n`.repeat(12) + 'x'.repeat(40), + 40, + ], + ])( + 'replays an empty chunk %s', + async (_name, chunks, expectedText, expectedCursorCol) => { + await backend.boot(); + + const state = await backend.replayTo( + createReplayInput(outputEvents(chunks)), + ); + + expect(state.lastSeq).toBe(chunks.length - 1); + expect(screenText(await backend.snapshot())).toBe(expectedText); + expect(state.cursorCol).toBe(expectedCursorCol); + }, + ); + + it('lets a direct page write take an empty string', async () => { + await backend.boot(); + const { page } = backend as unknown as { page: Page }; + await page.evaluate('window.__agentTty.resize(80, 24)'); + + await page.evaluate(`window.__agentTty.write('')`); + await page.evaluate(`window.__agentTty.write('ok')`); + await page.evaluate(`window.__agentTty.write('')`); + + expect( + await page.evaluate( + `window.__agentTty.getVisibleText().split('\\\\n')[0]`, + ), + ).toBe('ok'); + }); + + it('replays empty chunks in a timed replay, as the last chunk, the only chunk and a middle chunk of a flush', async () => { + await backend.boot(); + const resize = (seq: number, cols: number): ReplayInput['events'][0] => ({ + seq, + ts: timestampFor(0), + type: 'resize', + payload: { cols, rows: 24 }, + }); + const output = (seq: number, data: string): ReplayInput['events'][0] => ({ + seq, + ts: timestampFor(0), + type: 'output', + payload: { data }, + }); + + // A resize ends the flush before it: [ab, ''], then [''], then the + // final flush [c, '', d]. + const state = await backend.replayWithTiming( + createReplayInput([ + output(0, 'ab'), + output(1, ''), + resize(2, 81), + output(3, ''), + resize(4, 80), + output(5, 'c'), + output(6, ''), + output(7, 'd'), + ]), + { mode: 'max-speed', minFrameHoldMs: 16, finalFrameHoldMs: 16 }, + ); + + expect(state.lastSeq).toBe(7); + expect(screenText(await backend.snapshot())).toBe('abcd'); + }); + + it('shows the same text and the same screenshot as the log without the empty chunks', async () => { + // A log of 300 chunks: text, colours, line breaks, cursor moves and + // erases. The twin has an empty chunk after about every third chunk. + let seed = 20_261_109; + const random = (limit: number): number => { + seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0; + return seed % limit; + }; + const pieces = [ + 'abc ', + 'The quick brown fox ', + '\r\n', + '\u001b[31mred\u001b[0m ', + '\u001b[1;44mbold\u001b[0m ', + '\u001b[3A', + '\u001b[10C', + '\u001b[K', + '\u001b[2;5H', + '\u00e9\u4e2d ', + ]; + const chunks = Array.from( + { length: 300 }, + () => pieces[random(pieces.length)] ?? '', + ); + const chunksWithEmpties = chunks.flatMap((chunk) => + random(3) === 0 ? [chunk, ''] : [chunk], + ); + expect(chunksWithEmpties.length).toBeGreaterThan(chunks.length); + + const twin = new GhosttyWebBackend(`${SESSION_ID}-twin`, PROFILE); + // oxfmt-ignore + const outputDir = await realpath(await mkdtemp(join(tmpdir(), 'agent-tty-renderer-empty-'))); + try { + await backend.boot(); + await twin.boot(); + await backend.replayTo(createReplayInput(outputEvents(chunks))); + await twin.replayTo( + createReplayInput(outputEvents(chunksWithEmpties), { + sessionId: `${SESSION_ID}-twin`, + }), + ); + + const plain = await backend.snapshot({ includeScrollback: true }); + const withEmpties = await twin.snapshot({ includeScrollback: true }); + expect(screenText(plain)).not.toBe(''); + expect(withEmpties.visibleLines).toEqual(plain.visibleLines); + expect(withEmpties.scrollbackLines).toEqual(plain.scrollbackLines); + expect(withEmpties.cursorRow).toBe(plain.cursorRow); + expect(withEmpties.cursorCol).toBe(plain.cursorCol); + + const plainShot = await backend.screenshot(join(outputDir, 'a.png')); + const twinShot = await twin.screenshot(join(outputDir, 'b.png')); + expect(twinShot.sha256).toBe(plainShot.sha256); + } finally { + await twin.dispose(); + await rm(outputDir, { recursive: true, force: true }); + } + }); + }); + // A flush of 1001 chunks takes two page calls, and the glyph that it paints // comes with its last chunk. The next flush erases it. it('records the glyphs that a flush painted, in the page call that ends the flush', async () => { From 54ec11532ac807ee8051a7604ee3458fac7b3a0f Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Mon, 9 Nov 2026 19:55:22 +0000 Subject: [PATCH 2/4] test: fix the escape in the direct page write test --- test/integration/renderer-backend.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/integration/renderer-backend.test.ts b/test/integration/renderer-backend.test.ts index 495bfcb7..6b1ebb83 100644 --- a/test/integration/renderer-backend.test.ts +++ b/test/integration/renderer-backend.test.ts @@ -1073,7 +1073,7 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { expect( await page.evaluate( - `window.__agentTty.getVisibleText().split('\\\\n')[0]`, + `window.__agentTty.getVisibleText().split('\\n')[0]`, ), ).toBe('ok'); }); From dcb46ee426550228feb968d78ed75cdb154b4be0 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 11:50:11 +0000 Subject: [PATCH 3/4] fix: leave an empty output event out of a timed replay An output event with empty data changes no pixel, but a timed replay still gave it a place on the schedule and put its empty chunk in a flush. Its timestamp opened a flush window, ended a flush before an input, a marker, an exit or a resize, and moved the max-speed split points, so a WebM export of a log with such an event had other frames than the export of the log without it. The replay now skips the event: it counts as applied for lastSeq and the progress reports, and it has no other effect. The page tests now cover an empty chunk in the first call of a flush above 1000 chunks, the painted-grapheme note of a flush of empty chunks, and the missing frame wait. The seeded twin test scrolls. --- src/renderer/ghosttyWeb/README.md | 6 +- src/renderer/ghosttyWeb/backend.ts | 24 +- test/integration/renderer-backend.test.ts | 84 +++++- test/unit/renderer/ghosttyWebBackend.test.ts | 269 +++++++++++++++++++ 4 files changed, 372 insertions(+), 11 deletions(-) diff --git a/src/renderer/ghosttyWeb/README.md b/src/renderer/ghosttyWeb/README.md index 77435fbf..5c89b328 100644 --- a/src/renderer/ghosttyWeb/README.md +++ b/src/renderer/ghosttyWeb/README.md @@ -29,10 +29,10 @@ This directory holds the `ghostty-web` reference renderer. `GhosttyWebBackend` s - The constructor asserts a non-empty `sessionId`, `profile.name` and `profile.fontFamily`, a positive `fontSize`, and hex colors. With `videoOptions`, `outputDir` must be absolute and the size must use positive integers [backend.ts:GhosttyWebBackend.constructor]. - Replay cannot rewind: `targetSeq` must be >= the last applied seq. The initial dimensions cannot change after the first replay. `input.sessionId` must match the backend [backend.ts:GhosttyWebBackend.replayTo]. -- `replayWithTiming` asserts strictly increasing seqs and non-decreasing timestamps [backend.ts:GhosttyWebBackend.replayWithTiming]. +- `replayWithTiming` asserts strictly increasing seqs and non-decreasing timestamps. An output event with empty data takes part in the seq check only [backend.ts:GhosttyWebBackend.replayWithTiming]. - `replayWithTiming` calls the optional `onProgress` with a `ReplayProgress` in four places. Before each wait on the schedule, `scheduledWaitMs` is the length of that wait. A wait longer than a timer runs (2^31-1 ms) is a chain of timers, so a recorded gap of 30 days takes 30 days, and a report that carries the rest of the wait follows each timer except the last. After each bridge call of a flush, and after the resize call of a resize event, `scheduledWaitMs` is 0. After the last event and its flush, and before the final calls to the renderer, `scheduledWaitMs` is 0 too, so a stall in those calls names the last event, also when that event changed no pixel. `seq` is the last event that the replay had reached. `generateWebmExport` uses these reports to tell a stall from a wait [backend.ts:GhosttyWebBackend.replayWithTiming]. -- `replayWithTiming` waits until each event's place on a schedule built from the recorded gaps: `recorded` keeps them, `accelerated` caps each at `maxGapMs`, and `max-speed` sets them to 0. It measures that schedule with `performance.now()`, so frame holds and bridge calls use up idle time instead of adding to it. In `recorded` and `accelerated` mode it flushes pending output once the next event is at least one frame hold after the batch's first output, unless the replay runs behind the schedule, in which case the batch grows until it catches up. `max-speed` has no schedule to fall behind, so recorded time ends most of its flushes. In `max-speed` mode, a flush ends at the first event that is at least one frame hold of recorded time after the batch's first output. Also in `max-speed` mode, an event that changes no pixel (input, marker, run-complete, signal or exit) ends a flush only through that rule, because a flush at each of them would cost a frame per event. In `recorded` and `accelerated` mode such an event ends a flush directly (see below). A resize that finds output waiting and the end of the replay end a flush in every mode. `max-speed` also spreads at most `MAX_SPEED_FRAME_BUDGET` (2,400) flushes over the recorded span of the events that it applies: the distance between the first output of a flush and the event that ends it is at least the larger of the frame hold and the span divided by 2,400. So in `max-speed` mode, recorded time makes about one flush per frame hold for a recording shorter than 38 s and at most about 2,400 flushes for a longer one, whatever its number of output events. A resize that finds output waiting ends a flush too. These flushes do not count against the 2,400, so each resize adds at most one flush, and none when no output is waiting: 100,000 outputs that each have a resize 1 ms after them make 100,000 flushes. In `max-speed` mode, output that arrives less than that distance after the first output of a flush shares the flush, also when an input or a marker sits between, so a state that the next output of the flush replaces is never painted. A flush waits for one animation frame. `max-speed` holds only after the last flush of the replay and around a resize. After the last flush and after a resize it holds the frame hold. Before a resize it holds one video frame (40 ms, from `VIDEO_FPS` = 25), or the frame hold when that is longer, because a screen that stays on the page for less than one video frame can be missing from the video. Two flushes that follow each other can paint within one video frame. In `recorded` and `accelerated` mode, every event that changes no pixel ends a flush, and a hold follows every flush. Output events with the same timestamp share a flush at any hold, 0 included, because the time check needs at least 1 ms of recorded time. An event between them that ends a flush splits them anyway: a resize in every mode, and in `recorded` and `accelerated` mode also an input, a marker, a run-complete, a signal or an exit [backend.ts:GhosttyWebBackend.replayWithTiming]. -- `writeBatch(chunks, endsFlush, paintedCap)` takes the chunks of one bridge call. A flush that has more than 1000 chunks needs several calls, and ghostty-web paints on every animation frame, so the page holds the chunks of a call that does not end the flush. The call that ends it (`endsFlush`, set by the backend on the last call only) writes every held chunk in one task, so no frame paints a state between two calls, and then resolves after one frame. ghostty-web calls a write callback one animation frame (about 17 ms) after the write, and waiting on every chunk (`write`) cost one frame per output event. `write()` and `resize()` throw while chunks are held. `write` and `writeBatch` skip an empty chunk, because ghostty-web throws a `RangeError` on `terminal.write('')` and an output event with empty data is valid in an Event Log. The write callback goes to the last chunk that is written, and a flush of empty chunks writes nothing and waits for no frame. `discardHeldChunks()` drops them: the backend calls it when a flush fails, because `HostRendererManager` keeps the backend and a retry would otherwise meet them. A `paintedCap` that is not null makes the call that ends the flush add the graphemes on the screen to the set that the missing-glyph check of `replayWithTiming` probes, so a flush is one page call. The call returns the message of a failed note and not an error, because a failed note must not fail the flush. In `replayWithTiming`, the chunks of one flush share one video frame [embeddedHarnessHtml.ts:EMBEDDED_HARNESS_HTML]. +- `replayWithTiming` waits until each event's place on a schedule built from the recorded gaps: `recorded` keeps them, `accelerated` caps each at `maxGapMs`, and `max-speed` sets them to 0. It measures that schedule with `performance.now()`, so frame holds and bridge calls use up idle time instead of adding to it. In `recorded` and `accelerated` mode it flushes pending output once the next event is at least one frame hold after the batch's first output, unless the replay runs behind the schedule, in which case the batch grows until it catches up. `max-speed` has no schedule to fall behind, so recorded time ends most of its flushes. In `max-speed` mode, a flush ends at the first event that is at least one frame hold of recorded time after the batch's first output. Also in `max-speed` mode, an event that changes no pixel (input, marker, run-complete, signal or exit) ends a flush only through that rule, because a flush at each of them would cost a frame per event. In `recorded` and `accelerated` mode such an event ends a flush directly (see below). A resize that finds output waiting and the end of the replay end a flush in every mode. `max-speed` also spreads at most `MAX_SPEED_FRAME_BUDGET` (2,400) flushes over the recorded span of the events that it applies: the distance between the first output of a flush and the event that ends it is at least the larger of the frame hold and the span divided by 2,400. So in `max-speed` mode, recorded time makes about one flush per frame hold for a recording shorter than 38 s and at most about 2,400 flushes for a longer one, whatever its number of output events. A resize that finds output waiting ends a flush too. These flushes do not count against the 2,400, so each resize adds at most one flush, and none when no output is waiting: 100,000 outputs that each have a resize 1 ms after them make 100,000 flushes. In `max-speed` mode, output that arrives less than that distance after the first output of a flush shares the flush, also when an input or a marker sits between, so a state that the next output of the flush replaces is never painted. A flush waits for one animation frame. `max-speed` holds only after the last flush of the replay and around a resize. After the last flush and after a resize it holds the frame hold. Before a resize it holds one video frame (40 ms, from `VIDEO_FPS` = 25), or the frame hold when that is longer, because a screen that stays on the page for less than one video frame can be missing from the video. Two flushes that follow each other can paint within one video frame. In `recorded` and `accelerated` mode, every event that changes no pixel ends a flush, and a hold follows every flush. Output events with the same timestamp share a flush at any hold, 0 included, because the time check needs at least 1 ms of recorded time. An event between them that ends a flush splits them anyway: a resize in every mode, and in `recorded` and `accelerated` mode also an input, a marker, a run-complete, a signal or an exit. An output event with empty data changes no pixel, so `replayWithTiming` takes it out of the recording before all of these rules: it has no place on the schedule, it opens and ends no flush, and it does not count in the recorded span. The replay is the one of the log without the event, and the event still counts as applied for `lastSeq` and the progress reports [backend.ts:GhosttyWebBackend.replayWithTiming]. +- `writeBatch(chunks, endsFlush, paintedCap)` takes the chunks of one bridge call. A flush that has more than 1000 chunks needs several calls, and ghostty-web paints on every animation frame, so the page holds the chunks of a call that does not end the flush. The call that ends it (`endsFlush`, set by the backend on the last call only) writes every held chunk in one task, so no frame paints a state between two calls, and then resolves after one frame. ghostty-web calls a write callback one animation frame (about 17 ms) after the write, and waiting on every chunk (`write`) cost one frame per output event. `write()` and `resize()` throw while chunks are held. `discardHeldChunks()` drops them: the backend calls it when a flush fails, because `HostRendererManager` keeps the backend and a retry would otherwise meet them. The page skips an empty chunk in `write` and `writeBatch`, because ghostty-web throws a `RangeError` on `terminal.write('')` and an output event with empty data is valid in an Event Log. The write callback goes to the last chunk that is written, and a call that ends a flush of only empty chunks writes nothing and waits for no frame. A `paintedCap` that is not null makes the call that ends the flush add the graphemes on the screen to the set that the missing-glyph check of `replayWithTiming` probes, so a flush is one page call. The call returns the message of a failed note and not an error, because a failed note must not fail the flush. In `replayWithTiming`, the chunks of one flush share one video frame [embeddedHarnessHtml.ts:EMBEDDED_HARNESS_HTML]. - `snapshot()` and `screenshot()` throw until `replayTo()` or `replayWithTiming()` has run once and set the initial size. Both replays accept `targetSeq` -1 for an empty Event Log. A replay over zero events sets `lastAppliedSeq` to `targetSeq` [backend.ts:GhosttyWebBackend.snapshot]. - `missingGlyphs` lists a code point only when a bundled face that lacks it drew it. Chromium draws a cluster with one font: the first stack font that covers all of it, or else the first font of the stack, which is bundled in both built-in profiles and then draws `.notdef` boxes. CDP names the face that drew a probe by its PostScript name, so a family alias or several faces under one family resolve to the right cmap. When one bundled face drew the whole grapheme, every code point that face lacks is listed. A probe with no reported font is judged against every face of the first family. When the grapheme is split over several fonts, each uncovered code point is judged by its own probe, and a mark's probe on its base is compared with a probe of the base alone. Glyphs that a host font draws are not listed. The check runs after the PNG is written, removes its probe spans, and caches each probe text's result for the page, clearing the cache when it would pass `MAX_CACHED_GLYPH_PROBES` (16384) entries. It probes at most `MAX_GLYPH_PROBES` texts and lists at most `MAX_MISSING_GLYPH_CELLS` cells (4096 each). `probeUsedFonts` sends its CDP queries in batches of 256 and stops starting batches after `GLYPH_PROBE_BUDGET_MS` (1000 ms). The result sets `missingGlyphsTruncated` when any of these stops the check early. Family names match without regard to case. A web font whose PostScript name the profile does not bundle fails the check. A failed check is logged at debug level and leaves the field out, so the screenshot still succeeds. `replayWithTiming()` records the viewport graphemes in the page after each output batch and each resize (at most `MAX_CACHED_GLYPH_PROBES`), and the final frame hold judges them once, with the hold as the probe budget, so the check does not lengthen the video. `recordedMissingGlyphs()` returns that result. A failed note or check leaves the record export fields out. A profile whose font stack does not start with a bundled font gets no `missingGlyphs` field [missingGlyphs.ts:probeUsedFonts] [backend.ts:GhosttyWebBackend.screenshot]. - `screenshot()` requires an absolute `outputPath` whose parent directory exists. It checks that the output is a non-empty PNG with a valid signature and an `IHDR` first chunk [backend.ts:GhosttyWebBackend.screenshot]. diff --git a/src/renderer/ghosttyWeb/backend.ts b/src/renderer/ghosttyWeb/backend.ts index f1957bb6..760742d7 100644 --- a/src/renderer/ghosttyWeb/backend.ts +++ b/src/renderer/ghosttyWeb/backend.ts @@ -180,10 +180,20 @@ let servedAssetsPromise: Promise< ReadonlyMap > | null = null; +/** + * An output event with empty data changes no pixel. A timed replay takes it + * out of the recording: the event has no place on the schedule and no effect + * on where a flush splits, so the replay is the one of the log without it. + */ +function isEmptyOutput(event: ReplayInput['events'][number]): boolean { + return event.type === 'output' && event.payload.data.length === 0; +} + /** * Recorded time between the first and the last event that a replay applies: - * the seqs above `afterSeq`, up to the target. It is 0 when a timestamp does - * not parse. The replay loop rejects such a timestamp itself. + * the seqs above `afterSeq`, up to the target, without an empty output event. + * It is 0 when a timestamp does not parse. The replay loop rejects such a + * timestamp itself. */ function recordedSpanMs(input: ReplayInput, afterSeq: number): number { let firstMs: number | undefined; @@ -195,6 +205,9 @@ function recordedSpanMs(input: ReplayInput, afterSeq: number): number { if (event.seq > input.targetSeq) { break; } + if (isEmptyOutput(event)) { + continue; + } lastMs = Date.parse(event.ts); firstMs ??= lastMs; } @@ -844,6 +857,13 @@ export class GhosttyWebBackend implements VideoCapableRendererBackend { break; } + // The replay still counts the event as applied, so `lastSeq` and the + // progress reports reach its seq. + if (isEmptyOutput(event)) { + highestProcessedSeq = event.seq; + continue; + } + const eventTimestampMs = parseEventTimestampMs(event.ts, event.seq); if (previousProcessedEventTimestampMs === null) { scheduleStartedAtMs = performance.now(); diff --git a/test/integration/renderer-backend.test.ts b/test/integration/renderer-backend.test.ts index 6b1ebb83..f9d06e71 100644 --- a/test/integration/renderer-backend.test.ts +++ b/test/integration/renderer-backend.test.ts @@ -1047,6 +1047,12 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { `${'x'.repeat(80)}\n`.repeat(12) + 'x'.repeat(40), 40, ], + [ + 'inside the first call of a flush of 1001 chunks', + [...thousandXs.slice(0, 500), '', ...thousandXs.slice(500)], + `${'x'.repeat(80)}\n`.repeat(12) + 'x'.repeat(40), + 40, + ], ])( 'replays an empty chunk %s', async (_name, chunks, expectedText, expectedCursorCol) => { @@ -1078,6 +1084,64 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { ).toBe('ok'); }); + // The call that ends a flush adds the graphemes on the screen to the set + // that the missing-glyph check probes, whether or not the flush wrote. + it('notes the painted graphemes in the call that ends a flush of only empty chunks', async () => { + await backend.boot(); + const { page } = backend as unknown as { page: Page }; + await page.evaluate('window.__agentTty.resize(80, 24)'); + const painted = (): Promise<{ + seen: string[]; + complete: boolean; + } | null> => + page.evaluate( + `(() => { + const state = window.__agentTtyPainted; + return state === undefined ? null : { seen: [...state.seen], complete: state.complete }; + })()`, + ) as Promise<{ seen: string[]; complete: boolean } | null>; + + await page.evaluate(`window.__agentTty.writeBatch(['abc'], true, null)`); + expect(await painted()).toBeNull(); + + await page.evaluate(`window.__agentTty.writeBatch(['', ''], true, 5)`); + expect(await painted()).toEqual({ + seen: ['a', 'b', 'c'], + complete: true, + }); + }); + + // ghostty-web calls a write callback one animation frame after the write. + // With no frame coming, a flush that wrote something stays pending, and a + // flush of only empty chunks has nothing to wait for. + it('ends a flush of only empty chunks without waiting for a frame', async () => { + await backend.boot(); + const { page } = backend as unknown as { page: Page }; + await page.evaluate('window.__agentTty.resize(80, 24)'); + + const settled = await page.evaluate( + `(async () => { + const requestFrame = window.requestAnimationFrame; + window.requestAnimationFrame = () => 0; + try { + const settles = (promise) => + Promise.race([ + promise.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 500)), + ]); + return [ + await settles(window.__agentTty.writeBatch(['', ''], true)), + await settles(window.__agentTty.writeBatch(['x'], true)), + ]; + } finally { + window.requestAnimationFrame = requestFrame; + } + })()`, + ); + + expect(settled).toEqual([true, false]); + }); + it('replays empty chunks in a timed replay, as the last chunk, the only chunk and a middle chunk of a flush', async () => { await backend.boot(); const resize = (seq: number, cols: number): ReplayInput['events'][0] => ({ @@ -1114,8 +1178,10 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { }); it('shows the same text and the same screenshot as the log without the empty chunks', async () => { - // A log of 300 chunks: text, colours, line breaks, cursor moves and - // erases. The twin has an empty chunk after about every third chunk. + // A log of 300 chunks on a screen of 40 columns and 10 rows: text, + // colours, line breaks, cursor moves and erases. It scrolls, so the + // scrollback and the visible rows both hold text. The twin has an empty + // chunk after about every third chunk. let seed = 20_261_109; const random = (limit: number): number => { seed = (Math.imul(seed, 1_664_525) + 1_013_904_223) >>> 0; @@ -1125,12 +1191,13 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { 'abc ', 'The quick brown fox ', '\r\n', + '\r\n', + '\r\n', '\u001b[31mred\u001b[0m ', '\u001b[1;44mbold\u001b[0m ', - '\u001b[3A', + '\u001b[1A', '\u001b[10C', '\u001b[K', - '\u001b[2;5H', '\u00e9\u4e2d ', ]; const chunks = Array.from( @@ -1148,16 +1215,21 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { try { await backend.boot(); await twin.boot(); - await backend.replayTo(createReplayInput(outputEvents(chunks))); + const size = { initialCols: 40, initialRows: 10 }; + await backend.replayTo(createReplayInput(outputEvents(chunks), size)); await twin.replayTo( createReplayInput(outputEvents(chunksWithEmpties), { + ...size, sessionId: `${SESSION_ID}-twin`, }), ); const plain = await backend.snapshot({ includeScrollback: true }); const withEmpties = await twin.snapshot({ includeScrollback: true }); - expect(screenText(plain)).not.toBe(''); + expect(plain.scrollbackLines?.length).toBeGreaterThan(20); + expect( + plain.visibleLines.filter((line) => line.text.trim() !== '').length, + ).toBeGreaterThan(6); expect(withEmpties.visibleLines).toEqual(plain.visibleLines); expect(withEmpties.scrollbackLines).toEqual(plain.scrollbackLines); expect(withEmpties.cursorRow).toBe(plain.cursorRow); diff --git a/test/unit/renderer/ghosttyWebBackend.test.ts b/test/unit/renderer/ghosttyWebBackend.test.ts index 339b06b7..28d70a17 100644 --- a/test/unit/renderer/ghosttyWebBackend.test.ts +++ b/test/unit/renderer/ghosttyWebBackend.test.ts @@ -2434,4 +2434,273 @@ describe('GhosttyWebBackend replayWithTiming output pacing', () => { expect(flushes.length).toBeGreaterThanOrEqual(30); expect(flushes.at(-1)?.atMs).toBeLessThanOrEqual(FIRST_EVENT_AT_MS + 1_020); }); + + // An output event with empty data changes no pixel. A timed replay takes it + // out of the recording, so the replay of a log that holds one is the replay + // of the log without it: the same steps at the same times. + describe('with output events that have empty data', () => { + type Step = readonly [ + kind: 'out' | 'empty' | 'input' | 'resize' | 'exit', + offsetMs: number, + ]; + const ACCELERATED: ReplayTimingOptions = { + mode: 'accelerated', + maxGapMs: 400, + minFrameHoldMs: 100, + finalFrameHoldMs: 1_000, + }; + const MODES = [ + ['recorded', RECORDED], + ['accelerated', ACCELERATED], + ['max-speed', MAX_SPEED], + ] as const; + + function chunkEvent( + seq: number, + offsetMs: number, + data: string, + ): ReplayInput['events'][number] { + return { + seq, + ts: new Date(BASE_TIMESTAMP_MS + offsetMs).toISOString(), + type: 'output', + payload: { data }, + }; + } + + // The data of an output is its place among the outputs that have data, so + // a log and its twin without the empty events carry the same chunks. + function buildLog(steps: readonly Step[]): ReplayInput['events'] { + let outputs = 0; + return steps.map(([kind, offsetMs], seq) => { + switch (kind) { + case 'out': { + outputs += 1; + return chunkEvent(seq, offsetMs, `o${String(outputs)}`); + } + case 'empty': + return chunkEvent(seq, offsetMs, ''); + case 'input': + return inputEvent(seq, offsetMs); + case 'resize': + return resizeEvent(seq, offsetMs); + case 'exit': + return exitEvent(seq, offsetMs); + } + }); + } + const withoutEmpties = (steps: readonly Step[]): readonly Step[] => + steps.filter(([kind]) => kind !== 'empty'); + const flushSteps = ( + timeline: readonly { step: string; atMs: number }[], + ): string[] => + timeline + .map((entry) => entry.step) + .filter((step) => step.startsWith('flush')); + + const SCENARIOS: readonly (readonly [string, readonly Step[]])[] = [ + [ + 'before an input', + [ + ['out', 0], + ['empty', 20], + ['input', 30], + ['out', 400], + ['exit', 500], + ], + ], + [ + 'before a resize', + [ + ['out', 0], + ['empty', 5], + ['resize', 10], + ['out', 300], + ['exit', 400], + ], + ], + [ + 'before an exit', + [ + ['out', 0], + ['empty', 5], + ['exit', 200], + ], + ], + [ + 'between two outputs of one flush', + [ + ['out', 0], + ['empty', 5], + ['out', 10], + ['exit', 500], + ], + ], + [ + 'between two outputs of two flushes', + [ + ['out', 0], + ['empty', 20], + ['out', 35], + ['out', 40], + ['exit', 500], + ], + ], + [ + 'before the first output', + [ + ['empty', 0], + ['out', 60], + ['out', 110], + ['out', 2_000], + ['exit', 2_100], + ], + ], + [ + 'after the last output', + [ + ['out', 0], + ['out', 20], + ['empty', 60], + ['exit', 500], + ], + ], + [ + 'far after the last output, which spreads the frame budget over its time', + [ + ['out', 0], + ['out', 20], + ['out', 40], + ['empty', 100_000_000], + ], + ], + [ + 'before each of 20 inputs', + [ + ...Array.from({ length: 20 }, (_, index): Step[] => [ + ['out', index * 100], + ['empty', index * 100 + 20], + ['input', index * 100 + 30], + ]).flat(), + ['exit', 2_100], + ], + ], + ]; + + it.each( + SCENARIOS.flatMap(([name, steps]) => + MODES.map(([mode, timing]) => [name, mode, steps, timing] as const), + ), + )( + 'replays an empty output event %s as a log without it, in %s mode', + async (_name, _mode, steps, timing) => { + const twin = await replayAndRecordTimeline( + buildLog(withoutEmpties(steps)), + timing, + ); + const timeline = await replayAndRecordTimeline(buildLog(steps), timing); + + expect(flushSteps(twin).length).toBeGreaterThan(0); + expect(timeline).toEqual(twin); + }, + ); + + it('keeps the outputs of an accelerated replay in the flushes that they share without the empty event', async () => { + const timeline = await replayAndRecordTimeline( + buildLog([ + ['empty', 0], + ['out', 60], + ['out', 110], + ['out', 2_000], + ['exit', 2_100], + ]), + ACCELERATED, + ); + + expect(flushSteps(timeline)).toEqual(['flush o1,o2', 'flush o3']); + }); + + it('ends a max-speed flush where the log without the empty event ends it', async () => { + const timeline = await replayAndRecordTimeline( + buildLog([ + ['out', 0], + ['empty', 20], + ['out', 35], + ['out', 40], + ]), + MAX_SPEED, + ); + + expect(flushSteps(timeline)).toEqual(['flush o1', 'flush o2,o3']); + }); + + // The replay still applies the event: the seq that the replay returns and + // the seq that its progress reports carry reach it. + async function replayLog(steps: readonly Step[]): Promise<{ + lastSeq: number; + reportedSeqs: number[]; + flushes: string[][]; + }> { + vi.useFakeTimers(); + const backend = createBackend(); + const flushes: string[][] = []; + Object.assign(backend as object, { + isBooted: true, + page: { isClosed: () => false }, + resizeBridge: () => Promise.resolve(), + flushOutputBatch: (_page: object, dataChunks: string[]) => { + flushes.push([...dataChunks]); + return Promise.resolve(); + }, + readHarnessSnapshot: () => + Promise.resolve( + createHarnessSnapshotPayload({ + cols: 80, + rows: 24, + visibleLines: [], + }), + ), + }); + const reportedSeqs: number[] = []; + const events = buildLog(steps); + const replay = backend.replayWithTiming( + { + sessionId: 'renderer-unit-session', + initialCols: 80, + initialRows: 24, + targetSeq: events.length - 1, + events, + }, + RECORDED, + (progress) => { + reportedSeqs.push(progress.seq); + }, + ); + await vi.runAllTimersAsync(); + const state = await replay; + return { lastSeq: state.lastSeq, reportedSeqs, flushes }; + } + + it('applies a trailing empty output event: the last seq and the last progress report reach it', async () => { + const replayed = await replayLog([ + ['out', 0], + ['empty', 10], + ['empty', 20], + ]); + + expect(replayed.flushes).toEqual([['o1']]); + expect(replayed.lastSeq).toBe(2); + expect(replayed.reportedSeqs.at(-1)).toBe(2); + }); + + it('writes nothing for a log of empty output events, and applies them', async () => { + const replayed = await replayLog([ + ['empty', 0], + ['empty', 10], + ]); + + expect(replayed.flushes).toEqual([]); + expect(replayed.lastSeq).toBe(1); + }); + }); }); From 63b6180c699286158482bdb6ba531b175f4f1c30 Mon Sep 17 00:00:00 2001 From: Thomas Kosiewski Date: Tue, 10 Nov 2026 12:03:35 +0000 Subject: [PATCH 4/4] test: name the timed replay test for what it checks now --- test/integration/renderer-backend.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/test/integration/renderer-backend.test.ts b/test/integration/renderer-backend.test.ts index f9d06e71..5a8f140b 100644 --- a/test/integration/renderer-backend.test.ts +++ b/test/integration/renderer-backend.test.ts @@ -1142,7 +1142,7 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { expect(settled).toEqual([true, false]); }); - it('replays empty chunks in a timed replay, as the last chunk, the only chunk and a middle chunk of a flush', async () => { + it('replays empty output events in a timed replay as the log without them', async () => { await backend.boot(); const resize = (seq: number, cols: number): ReplayInput['events'][0] => ({ seq, @@ -1157,8 +1157,9 @@ describe('GhosttyWebBackend integration', { timeout: 120_000 }, () => { payload: { data }, }); - // A resize ends the flush before it: [ab, ''], then [''], then the - // final flush [c, '', d]. + // The timed replay leaves the empty events out of its flushes: [ab], + // then after the resizes [c, d]. The screen shows abcd, and the replay + // still applies the last event. const state = await backend.replayWithTiming( createReplayInput([ output(0, 'ab'),