Merge remote-tracking branch 'origin/test/garble-differential-fuzz' into orca-performance

This commit is contained in:
Jinwoo-H
2026-07-03 20:52:22 -04:00
6 changed files with 2053 additions and 0 deletions
+283
View File
@@ -0,0 +1,283 @@
# Garble differential fuzz — divergence log
Findings from the HeadlessEmulator-vs-renderer-twin differential fuzz
(`src/main/daemon/headless-emulator-fidelity.fuzz.test.ts`). Each divergence is
a case where restoring a hidden terminal from its main-side snapshot
(`serialize → replay`, exactly as `applyMainBufferSnapshot` does on reveal)
produces a screen that differs from an always-visible renderer terminal fed the
same bytes. Any such diff is a user-visible garble on reveal.
## Method
- Corpus: seeded agent-TUI byte streams (`buildAgentTuiStreamOps`), 3 pane
sizes, PTY-style random chunk splitting.
- Differential: production `HeadlessEmulator` snapshot replayed into a fresh
renderer-parity terminal, compared cell-by-cell (text, per-cell style,
cursor, modes, scrollback) against an always-visible renderer-parity twin.
- Parity confirmed: `createRendererParityTerminal` mirrors the renderer pane's
buffer-affecting options exactly — `scrollback: 5000`, `allowProposedApi`,
`vtExtensions.kittyKeyboard`, `Unicode11Addon`, Orca ZWJ provider (verified
against `buildDefaultTerminalOptions` in
`src/renderer/src/lib/pane-manager/pane-terminal-options.ts` and
`pane-dom-creation.ts`). Render-only options (`minimumContrastRatio`,
`drawBoldTextInBrightColors`, font, cursor, scrollbar) do not alter stored
cell attributes, so their omission is not a source of false diffs.
`windowsMode` is unset in both (matches renderer). Addon versions:
`@xterm/addon-serialize` / `@xterm/headless` / `@xterm/addon-unicode11` all
`*-beta.287` (headless `6.1.0-beta.287`).
- Scan: seeds 1..2000. Every divergence is either the known serialize-wrap bug
(predicate `bufferHasSerializeHostileWrappedRow`, tolerated + counted) or is
listed below.
## Inventory
| bug | found by | seeds | classification |
| --- | --- | --- | --- |
| A — serialize wrap null-cell | fidelity (suite 1) | 31, 157, 171, 207, 423, 426, 502, 801, 815, 826, 865, 881, 923, 977, 1004, 1119, 1142, 1238, 1241, 1318, 1351, 1374, 1532, 1601, 1657, 1728, 1770 (27 in 1..2000) | (a) real serialize bug, pre-documented + pinned |
| B — SGR bold loss (`1;22`) | fidelity (suite 1) | 435, 770, 1321 | (a) real serialize bug — garbles style on reveal |
| C — cursor off-by-one at right margin | fidelity (suite 1) | 454, 1696 | (a) real serialize bug — misplaces cursor on reveal |
| D — DECSC saved-cursor lost across reveal | reconciliation (suite 2) | seed 3 | (a) real snapshot limitation — garbles a DECRC-in-tail reveal |
| E — snapshot boundary mid-escape-sequence | reconciliation (suite 2) | seed 4 (+~24% of corpus) | (a) real snapshot limitation — continuation renders literal |
All five bug classes are reproduced by dedicated minimal `test.skip` repros so
they cannot silently regress, AND each is tolerated + counted by its suite's
corpus loop so deep mode surfaces only genuinely NEW divergences:
- Suite 1 (fidelity): Bug A via `bufferHasSerializeHostileWrappedRow`, Bug B via
`snapshotHasSelfCancellingBoldReset` (matches the `1;22` in the serialized
snapshot), Bug C via `isMarginWrapPendingCursorOffByOne` (cursor x-1 with a
full-width content row). Green at the default 300 and at `FUZZ_ITERATIONS=2000`.
- Suite 2 (reconciliation): Bug E via `prefixEndsMidSequence`. Bug D and the
Bug-C cursor cascade are kept out of the corpus by an append-only racing tail
(no DECSC/cursor motion) and pinned only as standalone repros. Green at the
default 200 and at `FUZZ_ITERATIONS=1000`.
Each tolerance has a `< max(3, ITERATIONS*0.5)` guard so a predicate that starts
tripping on most seeds fails the suite instead of silently swallowing it.
Seed 113 (called out in the handoff as a "DECSC/DECRC detour writing colored
text mid-line") does not diverge on the current harness. It is a `savedCursor
Detour` op seed; DECSC/DECRC SGR carry is correctly preserved by both the
emulator and the serializer here. It was most likely an earlier observation
folded into Bug C (the DECRC cases 1696 also involve `\x1b7`/`\x1b8`), or a
transient during harness construction. No live divergence at 113.
---
## Bug A — SerializeAddon drops null cells at a soft-wrap boundary
**Classification: (a) real `@xterm/addon-serialize` bug.** Pre-existing; found
and minimized by the prior agent, pinned by two `test.skip` repros in the fuzz
suite (V1 seed 31, V2 seed 157). Full mechanism documented in
`bufferHasSerializeHostileWrappedRow` and the suite's headline comment.
- **V1 (cell loss):** a wrapped continuation row starting with a NULL cell
passes the addon's wrap-validity ternary, gets skipped with `CUF` which clamps
at the right margin, overwriting the previous row's last cell and shifting the
tail left by one. `cols=20: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K'`.
- **V2 (stray `-` filler):** a wrapped pair whose source row is entirely null
takes the forced-wrap "magic" path; cleanup emits `ESC[0C` (param 0 → 1) so
the ECH erase lands one cell right and the first filler `-` survives.
**Impact:** any snapshot consumer (hidden reveal, parked-tab reveal, sleep/wake,
mobile subscribe replay) paints lost/shifted characters or stray `-` fillers
when a TUI erases inside a soft-wrapped line. Tolerated + counted by the suite;
unskip the repros when upstream fixes or a local serialize post-processor lands.
---
## Bug B — SerializeAddon loses BOLD when serializing a dim→bold-only transition
**Classification: (a) real `@xterm/addon-serialize` bug.** New finding.
**Seeds:** 435, 770 (alt-screen), 1321 (minimal, 2 ops).
**Minimal repro (isolated, no fuzz corpus needed), cols=20:**
```
live bytes: "\x1b[2mA\x1b[22m\x1b[1mB"
SerializeAddon → : "\x1b[2mA\x1b[1;22mB"
live cell B: bold=1 dim=0 (style flags 100000)
restored cell B: bold=0 dim=0 (style flags 000000) ← BOLD LOST
```
**Mechanism:** cell A is dim, cell B is bold-only. The serializer diffs the pen
from A (dim on) to B (bold on, dim off). To clear dim it appends SGR 22 — but in
xterm/ECMA-48 **SGR 22 resets *both* bold and dim** (`normalIntensity`). So the
emitted `\x1b[1;22m` sets bold then immediately clears it: the restored cell is
neither dim nor bold. Verified directly: writing `\x1b[1;22mX` yields `bold=0`.
(`\x1b[1;2m` — the same-cell dim+bold case — round-trips fine, so the bug is
specific to a dim-cell → bold-only-cell attribute transition.)
**Why it garbles a real pane:** agent TUIs routinely draw a dim body line then a
bold status/spinner line (Claude Code, Codex). On the live screen the status
line is bold; after a hide→reveal snapshot restore it renders normal-weight.
The seed-1321 live row `⠦ bash: pnpm typecheck` is bold live, non-bold restored.
**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` →
`it.skip('preserves bold when serializing a dim cell followed by a bold-only cell')`.
Tolerated + counted in the corpus via `snapshotHasSelfCancellingBoldReset`.
---
## Bug C — SerializeAddon cursor restore is off-by-one when the last content row fills the right margin
**Classification: (a) real `@xterm/addon-serialize` bug.** New finding.
**Seeds:** 454 (minimal, plain CUP), 1696 (DECSC/DECRC + wide CJK).
**Minimal repro (isolated, pure serializer replay), cols=10:**
```
live bytes: "0123456789\x1b[3;5H" (fill row 0 to the margin, CUP to r3c5)
SerializeAddon → : "0123456789\x1b[2B\x1b[6D"
live cursor: { x: 4, y: 2 }
restored cursor: { x: 3, y: 2 } ← ONE COLUMN SHORT
```
Control (`"012\x1b[3;5H"` — row 0 not full) serializes to `"012\x1b[2B\x1b[1C"`
and round-trips the cursor exactly, isolating the trigger to a full-width final
content row.
**Mechanism:** after emitting a row filled to exactly `cols`, xterm is left in
the *wrap-pending* state (cursor visually on the last column, logically "one
past"). The serializer computes its final cursor-restore as relative
`CUD`/`CUB` moves from that ambiguous position; the horizontal delta is computed
one column short, so the restored cursor lands at `x-1`. Reproduced with pure
`serializeAddon.serialize()` replay into a fresh terminal — **no Orca preamble
or normalization involved**, confirming it is upstream, not Orca's snapshot
path.
**Why it garbles a real pane:** the cursor is where the next keystroke echoes
and where the block/bar cursor is drawn. On reveal of a TUI whose bottom line
reached the right edge (wide status lines, long prompts), the cursor sits one
cell left of where the live pane had it — visible as a mispositioned prompt
caret or spinner, and subsequent input can overwrite the wrong cell.
**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` →
`it.skip('restores the cursor exactly when the last content row fills the right margin')`.
Tolerated + counted in the corpus via `isMarginWrapPendingCursorOffByOne`.
---
## Bug D — snapshot does not preserve the DECSC saved-cursor register across a hide/reveal boundary
**Classification: (a) real bug — a structural snapshot limitation.** New
finding, surfaced by the reveal-reconciliation fuzz (suite 2), not the fidelity
fuzz.
**Minimal repro (cols=20):**
```
hidden bytes: "AB\x1b7\x1b[4;10HCD" (write AB, DECSC saves cursor at r0c2,
move to r3c9, write CD)
tail bytes: "\x1b8X" (DECRC restores the saved cursor, write X)
live (always visible): rows ["ABX", " CD"] cursor { x: 3, y: 0 }
reveal (snapshot+tail): rows ["XB", " CD"] cursor { x: 1, y: 0 }
^^ 'X' overwrote 'A' — DECRC landed at home, not r0c2
snapshotAnsi: "AB\r\n\r\n\r\n\x1b[9CCD" (no saved-cursor state at all)
```
**Mechanism:** the snapshot is a serialized *screen* (SerializeAddon) plus a few
rehydrated modes. The VT100 DECSC/DECRC saved-cursor register (also `CSI s` /
`CSI u`) is runtime state that never appears in the serialized buffer, so it
cannot survive a snapshot. When a hidden TUI runs `\x1b7` (or `\x1b[s`) before
the reveal seq and the racing tail (or any post-reveal output) runs `\x1b8` (or
`\x1b[u`), the restore targets the fresh terminal's default saved position
(home) instead of where the TUI saved it — the next writes land at the wrong
cell and overwrite live content.
**Why it garbles a real pane:** DECSC/DECRC is common in shell prompts and
status-line redraws (save cursor, jump to a corner to paint a clock/token
counter, restore). If the save happens while the pane is hidden and the restore
fires on reveal, the restored paint clobbers the wrong cells. Found by suite-2
seed 3 (a `savedCursorDetour` op whose `\x1b7` fell in the hidden prefix and
whose `\x1b8` fell in the racing tail after chunk-splitting).
**Handling:** suite 2 keeps its racing tail append-only (no DECSC/DECRC, cursor
motion, scroll regions, or alt frames) so the seq-reconciliation byte-stitch is
tested in isolation from this and the other terminal-state-loss garbles. Bug D
is instead pinned as a standalone repro,
`hidden-reveal-reconciliation.fuzz.test.ts` →
`it.skip('preserves the DECSC saved-cursor register across a hide/reveal …')`.
**Fix direction (not applied — no production changes in this task):** carry the
saved-cursor register out-of-band in the snapshot (like `oscLinks`), or have the
emulator re-emit a synthetic DECSC restoring the saved position after replay.
---
## Bug E — snapshot boundary mid-escape-sequence drops the partial sequence
**Classification: (a) real bug — a structural snapshot limitation.** New
finding, surfaced by the reveal-reconciliation fuzz (suite 2).
**Minimal repro (cols=20):**
```
hidden prefix: "AB\x1b[3" (write AB, then ESC [ 3 — no final byte yet)
tail bytes: "mCD" ('m' completes ESC[3m = italic, then CD)
live (always visible): rows ["ABCD"] (ESC[3m parsed atomically, CD italic)
reveal (snapshot+tail): rows ["ABmCD"] ← 'm' became a literal character
snapshotAnsi: "AB" (the partial ESC[3 is in the parser, gone)
```
**Mechanism:** a PTY read (one delivery record) can split an escape sequence.
If the pane is revealed while the emulator's parser sits mid-`ESC[…`, the
serialized SCREEN cannot carry the partial sequence (it lives in the parser
state machine, not the buffer). The racing tail supplies the sequence's
remaining bytes, but with the prefix gone the terminal parses them as literal
text. Reproduced end-to-end against the real `HeadlessEmulator.getSnapshot`.
**Why it garbles a real pane:** any TUI whose output is heavy with escape
sequences (all of them) can have a read boundary fall mid-escape; if a reveal
lands in that window the continuation renders as stray literal bytes (a rogue
`m`, `H`, digits) injected into the visible text.
**Reachability:** requires the reveal/snapshot to fire in the gap between the two
halves of a split escape. `main` writes each PTY read to the emulator and
records it as one delivery unit (`session.ts emitSubprocessOutput`), and the
snapshot is taken synchronously at a drain — so the window is a single delivered
record that ended mid-escape. Narrow but real.
**Handling:** suite 2 tolerates + counts scenarios whose hidden prefix ends
mid-escape-sequence (`prefixEndsMidSequence`), the same way suite 1 tolerates the
serialize wrap bug — it fired on ~24% of the corpus, confirming the class is
common. Pinned by `hidden-reveal-reconciliation.fuzz.test.ts` →
`it.skip('completes an escape sequence split across the hide/reveal boundary')`.
**Fix direction (not applied):** have `main` hold a trailing incomplete escape
out of the drain until it completes (a small parser-aware buffer), or defer the
snapshot to a parser-clean boundary.
---
## Known-legitimate normalization (NOT bugs)
- **OSC 8 hyperlink underline** — classification (c). xterm marks OSC-8 link
cells underlined; SerializeAddon never re-emits OSC 8. Production restores the
link ranges out-of-band via `snapshot.oscLinks`
(`collectHeadlessOscLinkRanges`), so byte replay keeps the text but drops the
underline by design. Pinned by the passing
`it('drops OSC 8 underline from byte replay but preserves the range …')`.
- **P256→P16 color mode** — classification (c). SerializeAddon re-emits palette
indices 0–15 written as `38;5;N` using classic SGR 30–37/90–97, so a restored
cell reports `CM_P16` where live reported `CM_P256`. Both resolve through the
same 16 theme slots — no visual difference. Canonicalized by
`canonicalColorMode` in the parity fixture.
---
## Corpus vs deep mode
- **Suite 1** (`headless-emulator-fidelity.fuzz.test.ts`): default
`FUZZ_ITERATIONS=300` (~17s). `FUZZ_ITERATIONS=2000` (~113s) is green — Bugs A,
B, and C are each tolerated + counted by a predicate, so the corpus fails only
on a genuinely new divergence.
- **Suite 2** (`hidden-reveal-reconciliation.fuzz.test.ts`): default
`FUZZ_ITERATIONS=200` (~5s). `FUZZ_ITERATIONS=1000` is green — the racing tail
is append-only, so the only tolerated class is Bug E (`prefixEndsMidSequence`).
- Combined default runtime is ~19s (well under the 60s gate).
- `FUZZ_SEED=<n>`: re-run exactly one seed for a repro (both suites).
@@ -0,0 +1,467 @@
import { describe, expect, it } from 'vitest'
import { HeadlessEmulator } from './headless-emulator'
import {
buildAgentTuiStreamOps,
mulberry32,
splitIntoRandomChunks,
type AgentTuiStreamDims
} from '../../shared/agent-tui-ansi-fuzz-stream'
import {
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY,
SNAPSHOT_REPLAY_PREAMBLE_ALT,
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
bufferHasSerializeHostileWrappedRow,
createRendererParityTerminal,
cursorPosition,
isMarginWrapPendingCursorOffByOne,
normalBufferRowsTrimmed,
snapshotHasSelfCancellingBoldReset,
visibleRowStyles,
visibleRows,
writeChunksToTerminal
} from '../../shared/terminal-restore-parity-fixture'
// Differential garble gate for the hidden-terminal model/view contract
// (docs/reference/terminal-model-view-contract.md): with the hidden-delivery
// gate on, a hidden pane receives NOTHING — main's HeadlessEmulator is the
// source of truth and reveal repaints the renderer xterm from
// preamble + rehydrateSequences + snapshotAnsi (applyMainBufferSnapshot).
// This fuzz feeds seeded agent-TUI byte streams to the production emulator
// and to an always-visible renderer-parity terminal, then asserts the
// serialize→replay round trip reproduces the exact screen the renderer would
// have shown. Any diff = a garble bug on reveal.
//
// Runtime knobs:
// FUZZ_ITERATIONS=5000 deep/nightly mode (default 300, <60s combined with
// the reveal-reconciliation suite)
// FUZZ_SEED=1234 re-run exactly one seed (repro from a failure log)
const DEFAULT_ITERATIONS = 300
const FIXED_SEED = readPositiveIntEnv('FUZZ_SEED')
const ITERATIONS =
FIXED_SEED !== null ? 1 : (readPositiveIntEnv('FUZZ_ITERATIONS') ?? DEFAULT_ITERATIONS)
// Matches HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS in pty-connection.ts — the
// scrollback budget the reveal restore actually requests from main.
const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000
function readPositiveIntEnv(name: string): number | null {
const raw = Number(process.env[name])
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null
}
const DIMS: readonly AgentTuiStreamDims[] = [
{ cols: 80, rows: 24 },
{ cols: 100, rows: 30 },
{ cols: 120, rows: 40 }
]
type FidelityCase = {
seed: number
dims: AgentTuiStreamDims
ops: string[]
chunked: boolean
}
type FidelityDiff = {
stage: string
expected: unknown
actual: unknown
/** True when the always-visible buffer matches the known upstream
* @xterm/addon-serialize blank-leading-wrapped-row bug predicate — see
* bufferHasSerializeHostileWrappedRow and the skipped repro test below. */
knownSerializeWrapBug?: boolean
/** True when the divergence is the known Bug B serialize `1;22` bold-reset
* self-cancel (snapshotHasSelfCancellingBoldReset). */
knownSerializeBoldReset?: boolean
/** True when the divergence is the known Bug C wrap-pending cursor off-by-one
* at the right margin (isMarginWrapPendingCursorOffByOne). */
knownMarginCursorOffByOne?: boolean
}
function buildCase(seed: number): FidelityCase {
const rng = mulberry32(seed)
const dims = DIMS[Math.floor(rng() * DIMS.length)]!
const opCount = 12 + Math.floor(rng() * 28)
const ops = buildAgentTuiStreamOps(rng, dims, {
includeMouseModes: true,
includeOscHyperlinks: false,
opCount
})
return { seed, dims, ops, chunked: true }
}
function firstDiff(stage: string, expected: unknown, actual: unknown): FidelityDiff | null {
return JSON.stringify(expected) === JSON.stringify(actual) ? null : { stage, expected, actual }
}
async function runFidelityCase(testCase: FidelityCase): Promise<FidelityDiff | null> {
const stream = testCase.ops.join('')
const chunks = testCase.chunked
? splitIntoRandomChunks(mulberry32(testCase.seed ^ 0x9e3779b9), stream, {
minLen: 3,
maxLen: 120
})
: [stream]
const emulator = new HeadlessEmulator({ cols: testCase.dims.cols, rows: testCase.dims.rows })
const control = createRendererParityTerminal(testCase.dims)
const restored = createRendererParityTerminal(testCase.dims)
try {
for (const chunk of chunks) {
await emulator.write(chunk)
}
await writeChunksToTerminal(control.terminal, chunks)
// Stage 1 — model fidelity: the emulator's screen must already match the
// renderer twin before any serialization enters the picture.
const modelDiff = firstDiff(
'model-visible (HeadlessEmulator vs renderer twin)',
visibleRows(control.terminal),
emulator.getVisibleLines()
)
if (modelDiff) {
return modelDiff
}
// Stage 2 — reveal round trip: serialize exactly like
// serializeHiddenOutputRecoveryBuffer, replay exactly like
// applyMainBufferSnapshot, then compare against the always-visible twin.
const alt = emulator.isAlternateScreen
const snapshot = emulator.getSnapshot({
scrollbackRows: alt ? 0 : HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS
})
await writeChunksToTerminal(restored.terminal, [
alt ? SNAPSHOT_REPLAY_PREAMBLE_ALT : SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
])
const diffs = [
firstDiff(
'restore-visible-text',
visibleRows(control.terminal),
visibleRows(restored.terminal)
),
firstDiff(
'restore-visible-styles',
visibleRowStyles(control.terminal),
visibleRowStyles(restored.terminal)
),
firstDiff(
'restore-cursor',
cursorPosition(control.terminal),
cursorPosition(restored.terminal)
),
firstDiff(
'restore-mode-bracketed-paste',
control.terminal.modes.bracketedPasteMode,
restored.terminal.modes.bracketedPasteMode
),
// Why alt is excluded from the two comparisons below:
// - scrollback: serializeHeadlessTerminalBuffer (orca-runtime.ts)
// deliberately forces scrollbackRows=0 while an alt-screen TUI is
// active, so normal-buffer history is not part of the alt contract.
// - application cursor: HeadlessEmulator.getModes reports
// applicationCursor false on the alternate buffer, so rehydrate omits
// ?1h there by design.
alt
? null
: firstDiff(
'restore-scrollback-text',
normalBufferRowsTrimmed(control.terminal),
normalBufferRowsTrimmed(restored.terminal)
),
alt
? null
: firstDiff(
'restore-mode-application-cursor',
control.terminal.modes.applicationCursorKeysMode,
restored.terminal.modes.applicationCursorKeysMode
)
]
const diff = diffs.find((candidate) => candidate !== null) ?? null
if (!diff) {
return null
}
if (bufferHasSerializeHostileWrappedRow(control.terminal)) {
return { ...diff, knownSerializeWrapBug: true }
}
if (
diff.stage === 'restore-visible-styles' &&
snapshotHasSelfCancellingBoldReset(snapshot.snapshotAnsi)
) {
return { ...diff, knownSerializeBoldReset: true }
}
if (
diff.stage === 'restore-cursor' &&
isMarginWrapPendingCursorOffByOne(control.terminal, restored.terminal)
) {
return { ...diff, knownMarginCursorOffByOne: true }
}
return diff
} finally {
emulator.dispose()
control.terminal.dispose()
restored.terminal.dispose()
}
}
/** Greedy op-drop minimizer: re-runs the full differential pipeline on
* smaller op lists so a failure report carries the smallest byte stream that
* still diverges (plus its seed for exact replay via FUZZ_SEED). */
async function minimizeFailure(testCase: FidelityCase): Promise<FidelityCase> {
let current = { ...testCase, chunked: false }
if ((await runFidelityCase(current)) === null) {
current = { ...testCase, chunked: true }
}
let budget = 400
let shrunk = true
while (shrunk && budget > 0) {
shrunk = false
for (let i = current.ops.length - 1; i >= 0 && budget > 0; i--) {
const candidate = { ...current, ops: current.ops.toSpliced(i, 1) }
budget -= 1
if ((await runFidelityCase(candidate)) !== null) {
current = candidate
shrunk = true
}
}
}
return current
}
function formatFailure(minimized: FidelityCase, diff: FidelityDiff | null): string {
return [
`HeadlessEmulator fidelity divergence — stage: ${diff?.stage ?? 'unknown'}`,
`seed: ${minimized.seed} (re-run: FUZZ_SEED=${minimized.seed} pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/headless-emulator-fidelity.fuzz.test.ts)`,
`dims: ${minimized.dims.cols}x${minimized.dims.rows} chunked: ${minimized.chunked}`,
`minimized ops (${minimized.ops.length}): ${JSON.stringify(minimized.ops)}`,
`expected (always-visible renderer twin): ${JSON.stringify(diff?.expected)}`,
`actual (snapshot restore replay): ${JSON.stringify(diff?.actual)}`
].join('\n')
}
describe('headless emulator snapshot fidelity fuzz', () => {
// Known-legitimate divergence, pinned so it cannot silently regress into a
// real one: xterm marks OSC 8 hyperlink cells underlined, SerializeAddon
// never re-emits OSC 8, and production compensates by shipping the ranges
// out-of-band in snapshot.oscLinks (collectHeadlessOscLinkRanges) for the
// renderer link provider to re-register. Byte-replay therefore keeps the
// TEXT but not the link underline — the metadata must carry the range.
it('drops OSC 8 underline from byte replay but preserves the range in snapshot metadata', async () => {
const emulator = new HeadlessEmulator({ cols: 60, rows: 10 })
const restored = createRendererParityTerminal({ cols: 60, rows: 10 })
try {
await emulator.write('\x1b]8;;https://example.com/pr/7\x07review link\x1b]8;;\x07 tail')
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
await writeChunksToTerminal(restored.terminal, [
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
])
expect(visibleRows(restored.terminal)[0]).toBe('review link tail')
expect(snapshot.oscLinks).toContainEqual({
row: 0,
startCol: 0,
endCol: 11,
uri: 'https://example.com/pr/7'
})
} finally {
emulator.dispose()
restored.terminal.dispose()
}
})
it(`matches an always-visible renderer twin across ${ITERATIONS} seeded agent-TUI streams`, async () => {
// Each known-and-pinned serialize bug (A wrap, B bold-reset, C margin
// cursor) is tolerated + counted so deep mode (FUZZ_ITERATIONS) surfaces
// only GENUINELY NEW divergences. All three are minimized and pinned by the
// skipped repro tests below; the counts guard against a degenerate corpus.
let knownSerializeWrapBugHits = 0
let knownSerializeBoldResetHits = 0
let knownMarginCursorOffByOneHits = 0
for (let i = 0; i < ITERATIONS; i++) {
const seed = FIXED_SEED ?? 1 + i
const testCase = buildCase(seed)
const diff = await runFidelityCase(testCase)
if (diff?.knownSerializeWrapBug) {
knownSerializeWrapBugHits += 1
continue
}
if (diff?.knownSerializeBoldReset) {
knownSerializeBoldResetHits += 1
continue
}
if (diff?.knownMarginCursorOffByOne) {
knownMarginCursorOffByOneHits += 1
continue
}
if (diff) {
const minimized = await minimizeFailure(testCase)
const minimizedDiff = await runFidelityCase(minimized)
expect.fail(formatFailure(minimized, minimizedDiff ?? diff))
}
}
// Guard each tolerance from swallowing the suite: a known predicate tripping
// on most seeds means the gate has gone degenerate.
expect(knownSerializeWrapBugHits).toBeLessThan(Math.max(3, ITERATIONS * 0.5))
expect(knownSerializeBoldResetHits).toBeLessThan(Math.max(3, ITERATIONS * 0.5))
expect(knownMarginCursorOffByOneHits).toBeLessThan(Math.max(3, ITERATIONS * 0.5))
}, 600_000)
// ── HEADLINE FINDING (do not delete while unfixed upstream) ──────────────
// @xterm/addon-serialize 0.15.0-beta.287 does not round-trip null cells
// that touch a soft-wrap boundary. Two variants, both found by this fuzz
// and minimized below. Every Orca snapshot consumer is affected: hidden
// reveal, parked-tab reveal, sleep/wake restore, and mobile subscribe
// replay paint lost/shifted characters or stray '-' fillers whenever a TUI
// erased inside a soft-wrapped line (shell line editing, status lines wider
// than the pane, Claude Code in-place prompt redraws).
//
// V1 — cell loss (found by seed 31, minimized to 2 ops):
// Root cause: the wrap-validity ternary in SerializeAddon.ts (~L214)
// nextRowFirstChar.getChars() && isNextRowFirstCharDoubleWidth
// ? this._nullCellCount <= 1 : this._nullCellCount <= 0
// binds as `(chars && doubleWidth) ? ...`, so a null-leading wrapped row
// passes as a "natural" wrap. The serializer then emits the previous row as
// full-width text (leaving xterm in wrap-pending) and skips the null cell
// with CUF (`ESC[1C`) — but CUF clamps at the right margin instead of
// crossing the wrap boundary, so the next character overwrites the previous
// row's last cell and the whole tail shifts left by one.
// cols=20: write 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K'
// live rows: ['ABCDEFGHIJKLMNOPQRST', ' VWXYZ12']
// serialize(): 'ABCDEFGHIJKLMNOPQRST\x1b[1CVWXYZ12\x1b[8D'
// replayed rows: ['ABCDEFGHIJKLMNOPQRSV', 'WXYZ12'] ← 'T' eaten, tail shifted
//
// V2 — stray filler '-' (found by seed 157, minimized below): when the
// SOURCE row of a wrapped pair is entirely null (a TUI erased the whole
// first half of a wrapped line), the addon's forced-wrap "magic" writes
// nullCellCount+1 dashes and then cleans up with
// ESC[A ESC[(length-nullCellCount)C ESC[(nullCellCount)X ...
// With length === nullCellCount that cursor-forward becomes `ESC[0C`, and
// CSI param 0 means 1, so the ECH erase lands one cell right and the first
// '-' stays visible on the restored row.
// Unskip once the upstream fix (or a local serialize post-processor) lands.
it.skip('round-trips a wrapped line whose continuation row starts with an erased cell', async () => {
const emulator = new HeadlessEmulator({ cols: 20, rows: 6 })
const control = createRendererParityTerminal({ cols: 20, rows: 6 })
const restored = createRendererParityTerminal({ cols: 20, rows: 6 })
try {
const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[1A\x1b[1K']
for (const chunk of bytes) {
await emulator.write(chunk)
}
await writeChunksToTerminal(control.terminal, bytes)
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
await writeChunksToTerminal(restored.terminal, [
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
])
expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal))
} finally {
emulator.dispose()
control.terminal.dispose()
restored.terminal.dispose()
}
})
// V2 repro of the headline finding above (stray '-' filler on a fully
// erased wrapped source row). Unskip alongside the V1 repro.
it.skip('round-trips a wrapped line whose source row was fully erased', async () => {
const emulator = new HeadlessEmulator({ cols: 20, rows: 6 })
const control = createRendererParityTerminal({ cols: 20, rows: 6 })
const restored = createRendererParityTerminal({ cols: 20, rows: 6 })
try {
// Wrap a 28-char line, then erase the entire first (source) row of the
// wrapped pair: cursor up twice onto it, EL 2.
const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[2A\x1b[2K']
for (const chunk of bytes) {
await emulator.write(chunk)
}
await writeChunksToTerminal(control.terminal, bytes)
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
await writeChunksToTerminal(restored.terminal, [
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
])
// Fails today: restored row 0 shows '-' where the live row is blank.
expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal))
} finally {
emulator.dispose()
control.terminal.dispose()
restored.terminal.dispose()
}
})
// ── Bug B: SGR bold lost on a dim→bold-only cell transition ──────────────
// @xterm/addon-serialize 0.15.0-beta.287: serializing a dim cell followed by
// a bold-only cell emits `\x1b[1;22m` for the transition — SGR 1 sets bold,
// SGR 22 (normalIntensity) then clears BOTH bold and dim, so the restored
// cell loses its bold. Found by fuzz seeds 435, 770, 1321. Garbles the bold
// status/spinner line agent TUIs draw after a dim body line, on every reveal.
// See notes/garble-fuzz-divergences.md (Bug B). Unskip when upstream fixes
// the pen diff (clear dim with an explicit re-apply of bold, not SGR 22).
it.skip('preserves bold when serializing a dim cell followed by a bold-only cell', async () => {
const emulator = new HeadlessEmulator({ cols: 20, rows: 4 })
const control = createRendererParityTerminal({ cols: 20, rows: 4 })
const restored = createRendererParityTerminal({ cols: 20, rows: 4 })
try {
// 'A' dim, 'B' bold-only. Live: A=dim, B=bold. Serializer emits 1;22 for
// the B transition, which zeroes bold on restore.
const bytes = ['\x1b[2mA\x1b[22m\x1b[1mB\x1b[0m']
for (const chunk of bytes) {
await emulator.write(chunk)
}
await writeChunksToTerminal(control.terminal, bytes)
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
await writeChunksToTerminal(restored.terminal, [
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
])
// Fails today: 'B' restores with bold cleared (style flags 000000 vs 100000).
expect(visibleRowStyles(restored.terminal)).toEqual(visibleRowStyles(control.terminal))
} finally {
emulator.dispose()
control.terminal.dispose()
restored.terminal.dispose()
}
})
// ── Bug C: cursor restored one column short when the last row fills the margin ──
// @xterm/addon-serialize 0.15.0-beta.287: after emitting a content row filled
// to exactly `cols`, xterm is left wrap-pending (cursor on the last column,
// logically one past). The serializer's relative cursor-restore computes the
// horizontal delta one short, so the cursor lands at x-1. Found by fuzz seeds
// 454, 1696. Garbles the caret/spinner position on reveal of a TUI whose
// bottom line reached the right edge; later input can echo into the wrong
// cell. See notes/garble-fuzz-divergences.md (Bug C). Unskip when upstream
// fixes the wrap-pending relative cursor math.
it.skip('restores the cursor exactly when the last content row fills the right margin', async () => {
const emulator = new HeadlessEmulator({ cols: 10, rows: 4 })
const control = createRendererParityTerminal({ cols: 10, rows: 4 })
const restored = createRendererParityTerminal({ cols: 10, rows: 4 })
try {
// Fill row 0 to exactly 10 cols (wrap-pending), then CUP the cursor to a
// known lower-row column. Live cursor is (x=4, y=2).
const bytes = ['0123456789\x1b[3;5H']
for (const chunk of bytes) {
await emulator.write(chunk)
}
await writeChunksToTerminal(control.terminal, bytes)
const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 })
await writeChunksToTerminal(restored.terminal, [
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
snapshot.rehydrateSequences + snapshot.snapshotAnsi,
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
])
// Fails today: restored cursor is one column short (x=3 vs x=4).
expect(cursorPosition(restored.terminal)).toEqual(cursorPosition(control.terminal))
} finally {
emulator.dispose()
control.terminal.dispose()
restored.terminal.dispose()
}
})
})
@@ -0,0 +1,582 @@
import { describe, expect, it } from 'vitest'
import {
buildAgentTuiStreamOps,
mulberry32,
splitIntoRandomChunks,
type AgentTuiStreamDims
} from '../../../../shared/agent-tui-ansi-fuzz-stream'
import {
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY,
SNAPSHOT_REPLAY_PREAMBLE_ALT,
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
buildParityMainBufferSnapshot,
createRendererParityTerminal,
cursorPosition,
normalBufferRowsTrimmed,
visibleRows,
visibleRowStyles,
writeChunksToTerminal
} from '../../../../shared/terminal-restore-parity-fixture'
// Property fuzz for the hidden-reveal seq-reconciliation path in
// pty-connection.ts. When a pane is hidden, main keeps metering PTY output as
// seq'd chunks. On reveal the renderer applies a HeadlessEmulator snapshot
// captured at seq S (paints everything <= S), then stitches the racing tail
// (chunks straddling / after S) on top WITHOUT duplicating or losing a byte.
// The byte arithmetic lives in two non-exported closures:
// - getChunkDataAfterSnapshot (pty-connection.ts ~L4629): slices a
// pending chunk against the snapshot seq.
// - reconcileChunkAgainstRestoredSnapshot (pty-connection.ts ~L4660): dedups
// backlog, detects a restarted seq domain, and heals dropped-output gaps by
// forcing a fresh restore.
// This suite mirrors those rules EXACTLY (each branch tagged with its source
// line) and asserts the invariant they guarantee: for any reveal boundary,
// snapshot(hidden prefix) + reconciled tail reproduces the same screen an
// always-visible terminal shows for the full stream. A wrong slice = a garble.
//
// Runtime knobs:
// FUZZ_ITERATIONS=2000 deep/nightly (default 200, <60s with the fidelity suite)
// FUZZ_SEED=1234 re-run exactly one seed
const DEFAULT_ITERATIONS = 200
const FIXED_SEED = readPositiveIntEnv('FUZZ_SEED')
const ITERATIONS =
FIXED_SEED !== null ? 1 : (readPositiveIntEnv('FUZZ_ITERATIONS') ?? DEFAULT_ITERATIONS)
function readPositiveIntEnv(name: string): number | null {
const raw = Number(process.env[name])
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null
}
const DIMS: readonly AgentTuiStreamDims[] = [
{ cols: 80, rows: 24 },
{ cols: 100, rows: 30 }
]
/** A metered chunk exactly as main delivers to onData: text, the seq of the
* LAST raw byte, the raw (pre-OSC-strip) length, and delivery-domain markers.
* domain increments across a ptyId-exit seq restart (production clears the
* restored baseline at that boundary, so the tail writes verbatim). */
type MeteredChunk = {
data: string
seq?: number
rawLength?: number
domain: number
droppedOutput?: boolean
}
/** Mirror of getChunkDataAfterSnapshot (pty-connection.ts ~L4629): how much of
* a chunk in the SNAPSHOT'S seq domain survives once the snapshot at
* snapshotSeq has painted everything <= snapshotSeq. null = "cannot slice,
* force a fresh snapshot" (OSC stripping desynced raw offsets). Byte-identical
* to production; each return tagged with its branch. */
function chunkDataAfterSnapshot(
chunk: MeteredChunk,
snapshotSeq: number | undefined
): string | null {
if (typeof snapshotSeq !== 'number' || typeof chunk.seq !== 'number') {
return chunk.data // L4633: unmetered — pass through
}
const rawLength = chunk.rawLength ?? chunk.data.length
const startSeq = chunk.seq - rawLength
if (snapshotSeq >= chunk.seq) {
return '' // L4638: fully before/at snapshot — already painted
}
if (snapshotSeq <= startSeq) {
return chunk.data // L4641: fully after snapshot — keep whole
}
const offset = snapshotSeq - startSeq
if (rawLength !== chunk.data.length) {
return null // L4645: OSC-stripped, offsets unmappable
}
return chunk.data.slice(offset) // L4648: straddles — drop the painted prefix
}
type RevealResult = {
/** Full normal-buffer content (visible + scrollback, trailing blanks
* trimmed). Viewport-independent: a byte lost or duplicated by reconciliation
* changes this even when the visible viewport happens to line up. */
content: string[]
rows: string[]
styles: string[]
cursor: { x: number; y: number }
/** True when the buffer scrolled (baseY > 0). The visible viewport's top
* anchor after a snapshot restore vs continuous writing can differ by a row
* or two purely from trailing-blank trimming — a snapshot-scrollback-depth
* concern owned by the fidelity suite, not seq reconciliation — so styles and
* cursor (viewport-relative) are only asserted on non-scrolled scenarios. */
scrolled: boolean
/** True when the terminal ended on the alternate screen. Alt has no
* scrollback and a fixed viewport, so its content is the visible rows and the
* normal-buffer content comparison does not apply (the alt-screen restore
* contract is scrollback-free by design — serializeHeadlessTerminalBuffer). */
alternate: boolean
forcedFreshRestore: boolean
}
/** Bug E detector: does the hidden prefix (what the snapshot captured) end
* mid-escape-sequence? A PTY read (one delivery record) can split an escape;
* the emulator then holds partial parser state that the serialized SCREEN
* cannot carry, so the racing tail's continuation bytes render as literal text.
* Returns true when the prefix ends with a dangling ESC, ESC[…(no final byte),
* ESC](no ST/BEL), or a lone ESC-intermediate. */
function prefixEndsMidSequence(prefix: string): boolean {
const esc = prefix.lastIndexOf('\x1b')
if (esc === -1) {
return false
}
const rest = prefix.slice(esc)
if (rest === '\x1b') {
return true // dangling ESC
}
const kind = rest[1]
if (kind === '[') {
// CSI: complete iff a final byte 0x40–0x7e appears after the params.
for (let i = 2; i < rest.length; i++) {
const code = rest.charCodeAt(i)
if (code >= 0x40 && code <= 0x7e) {
return false
}
}
return true
}
if (kind === ']') {
// OSC: complete iff terminated by BEL or ST (ESC \).
return !(rest.includes('\x07') || rest.includes('\x1b\\'))
}
// ESC 7 / ESC 8 / ESC-intermediate: two bytes complete it. Dangling iff only
// ESC was written (handled above) — a present kind byte means it completed.
return false
}
async function readScreen(
term: ReturnType<typeof createRendererParityTerminal>
): Promise<RevealResult> {
const alternate = term.terminal.buffer.active.type === 'alternate'
return {
content: alternate ? visibleRows(term.terminal) : normalBufferRowsTrimmed(term.terminal),
rows: visibleRows(term.terminal),
styles: visibleRowStyles(term.terminal),
cursor: cursorPosition(term.terminal),
scrolled: term.terminal.buffer.active.baseY > 0,
alternate,
forcedFreshRestore: false
}
}
/** The renderer's screen for a hide→reveal cycle. `revealIdx` is the delivery
* index at which the pane is revealed: chunks [0, revealIdx) were captured by
* the HeadlessEmulator snapshot; chunks [revealIdx, end) are the racing tail.
* The snapshot seq is the seq of the last hidden chunk in the snapshot's
* domain; the tail is stitched via the production slice/reconcile rules. */
async function revealFromSnapshot(
dims: AgentTuiStreamDims,
chunks: MeteredChunk[],
revealIdx: number
): Promise<RevealResult> {
const source = createRendererParityTerminal(dims)
const restored = createRendererParityTerminal(dims)
try {
// Snapshot source = every hidden chunk painted in order.
await writeChunksToTerminal(
source.terminal,
chunks.slice(0, revealIdx).map((c) => c.data)
)
// Snapshot seq = seq of the last hidden chunk that carried one (the seq the
// emulator would report). undefined when the hidden prefix was unmetered.
let snapshotSeq: number | undefined
let snapshotDomain = 0
for (let i = revealIdx - 1; i >= 0; i--) {
if (typeof chunks[i]!.seq === 'number') {
snapshotSeq = chunks[i]!.seq
snapshotDomain = chunks[i]!.domain
break
}
}
const snapshot = buildParityMainBufferSnapshot(source, snapshotSeq ?? 0)
const alt = snapshot.alternateScreen
await writeChunksToTerminal(restored.terminal, [
alt ? SNAPSHOT_REPLAY_PREAMBLE_ALT : SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
snapshot.data,
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY
])
// Stitch the tail. A chunk in the snapshot's domain is sliced against the
// snapshot seq (drains the painted prefix); a chunk from a later domain
// (post-exit seq restart) writes verbatim — production clears the baseline
// at the ptyId-exit boundary, so no seq dedupe applies across domains.
let forcedFreshRestore = false
for (let i = revealIdx; i < chunks.length; i++) {
const chunk = chunks[i]!
if (chunk.domain !== snapshotDomain) {
await writeChunksToTerminal(restored.terminal, [chunk.data])
continue
}
const sliced = chunkDataAfterSnapshot(chunk, snapshotSeq)
if (sliced === null) {
// Production re-fetches a fresh snapshot here; a fresh snapshot of the
// full stream trivially matches the always-visible screen, so mark the
// scenario as a fresh-restore (excluded from the stitch comparison).
forcedFreshRestore = true
continue
}
if (sliced) {
await writeChunksToTerminal(restored.terminal, [sliced])
}
}
const result = await readScreen(restored)
result.forcedFreshRestore = forcedFreshRestore
return result
} finally {
source.terminal.dispose()
restored.terminal.dispose()
}
}
async function alwaysVisible(
dims: AgentTuiStreamDims,
chunks: MeteredChunk[]
): Promise<RevealResult> {
const term = createRendererParityTerminal(dims)
try {
await writeChunksToTerminal(
term.terminal,
chunks.map((c) => c.data)
)
return await readScreen(term)
} finally {
term.terminal.dispose()
}
}
/** Reference for the seq-reconciliation gate: a snapshot of the WHOLE stream
* (reveal at the very end, no racing tail). Sharing the snapshot machinery with
* revealFromSnapshot cancels out snapshot-fidelity gaps (Bug C cursor restore,
* Bug B bold loss — owned by the fidelity suite), so a diff against a mid-point
* reveal is attributable purely to the tail-stitch seq arithmetic. */
async function fullSnapshotReference(
dims: AgentTuiStreamDims,
chunks: MeteredChunk[]
): Promise<RevealResult> {
return revealFromSnapshot(dims, chunks, chunks.length)
}
type Scenario = {
seed: number
dims: AgentTuiStreamDims
chunks: MeteredChunk[]
revealIdx: number
domains: number
hasDropped: boolean
}
const TAIL_WORDS = [
'reading',
'tokens 12.4k',
'你好世界',
'터미널',
'✅ done',
'PASS test.ts',
'+142 -37',
'diff --git'
] as const
// Why no SGR 7 (inverse): inverse marks trailing BLANK cells with an
// inverse-fg the serializer round-trips slightly differently depending on how
// much of the buffer it captures — a serialize SGR-fidelity nuance (the Bug B
// class) that has nothing to do with seq reconciliation. Keeping it out of the
// tail keeps the styles gate a clean function of the byte-stitch.
const TAIL_SGR = ['\x1b[0m', '\x1b[31m', '\x1b[1m', '\x1b[38;5;204m', '\x1b[22m'] as const
/** Append-only racing tail: SGR runs + text + newlines only. No absolute/
* relative cursor motion, scroll regions, alt frames, or DECSC — so the tail
* paints wherever the cursor sits and its screen effect is a pure function of
* which bytes the seq-reconciliation applies. Terminal-state-loss garbles
* (Bugs C/D/E, scroll region) are pinned separately; this suite isolates the
* seq slice. */
function buildAppendOnlyTail(rng: () => number, lines: number): string {
const out: string[] = []
for (let i = 0; i < lines; i++) {
const words: string[] = []
const count = 1 + Math.floor(rng() * 3)
for (let w = 0; w < count; w++) {
words.push(TAIL_WORDS[Math.floor(rng() * TAIL_WORDS.length)]!)
}
const sgr = TAIL_SGR[Math.floor(rng() * TAIL_SGR.length)]!
out.push(`${sgr}${words.join(' ')}\x1b[0m\r\n`)
}
return out.join('')
}
/** Builds a seeded metered stream with a random reveal boundary. A running seq
* counter tags the LAST byte of each chunk; at most ONE mid-stream restart
* bumps the domain and resets the counter low (a revived ptyId with a fresh
* main-side counter — production clears the restored baseline at that exit
* boundary, so the post-restart tail writes verbatim). Rare unmetered chunks
* (no seq) and droppedOutput markers model no-metering runtimes and pending-cap
* trims. Op counts are kept modest so most scenarios reach the full
* non-scrolled comparison (the tail-stitch invariant); scrolled scenarios fall
* back to the snapshot-depth-tolerant path (see the assertion loop). */
function buildScenario(seed: number): Scenario {
const rng = mulberry32(seed)
const dims = DIMS[Math.floor(rng() * DIMS.length)]!
// Hidden prefix: full agent-TUI churn (cursor motion, panels, alt frames,
// scroll regions, DECSC…) so the SNAPSHOT is taken over rich state.
const prefixOps = buildAgentTuiStreamOps(rng, dims, {
includeMouseModes: false,
includeOscHyperlinks: false,
opCount: 3 + Math.floor(rng() * 6)
})
const prefixStream = prefixOps.join('')
const prefixRaw = splitIntoRandomChunks(mulberry32(seed ^ 0x51ed270b), prefixStream, {
minLen: 2,
maxLen: 64
})
// Racing tail: append-only content. Isolates the seq-reconciliation byte
// stitch from terminal-state-loss garbles (Bugs C/D/E — pinned separately).
const tailStream = buildAppendOnlyTail(rng, 2 + Math.floor(rng() * 5))
const tailRaw = splitIntoRandomChunks(mulberry32(seed ^ 0x2545f491), tailStream, {
minLen: 2,
maxLen: 40
})
const chunks: MeteredChunk[] = []
let seq = 100 + Math.floor(rng() * 50)
let domain = 0
let hasDropped = false
// At most one restart, placed inside the tail so the snapshot's own domain
// has a stable prefix — mirrors a single mid-session ptyId revival.
const restartAt =
rng() < 0.25 ? prefixRaw.length + Math.floor(rng() * Math.max(1, tailRaw.length)) : -1
const allRaw = [...prefixRaw, ...tailRaw]
for (let i = 0; i < allRaw.length; i++) {
const data = allRaw[i]!
if (i === restartAt) {
seq = 10 + Math.floor(rng() * 20)
domain = 1
}
const rawLength = data.length
seq += rawLength
const chunk: MeteredChunk = { data, seq, rawLength, domain }
// Rare unmetered / dropped markers, only in the tail (the prefix must meter
// so the snapshot carries a seq).
if (i >= prefixRaw.length && rng() < 0.05) {
delete chunk.seq
delete chunk.rawLength
}
if (i >= prefixRaw.length && rng() < 0.04) {
chunk.droppedOutput = true
hasDropped = true
}
chunks.push(chunk)
}
// Reveal at or after the prefix ends, so the racing tail is purely the
// append-only region — the snapshot captures all cursor-motion/scroll/alt
// state and the tail cannot depend on unserialized terminal state. Landing
// the reveal at various points INSIDE the append-only tail exercises the
// straddle and "snapshot seq inside the tail" seq-slice cases.
const tailSpan = Math.max(1, chunks.length - prefixRaw.length)
const revealIdx = Math.min(chunks.length, prefixRaw.length + Math.floor(rng() * tailSpan))
return { seed, dims, chunks, revealIdx, domains: domain + 1, hasDropped }
}
function firstRowDiff(a: string[], b: string[]): number | null {
const n = Math.max(a.length, b.length)
for (let i = 0; i < n; i++) {
if ((a[i] ?? '') !== (b[i] ?? '')) {
return i
}
}
return null
}
function formatFailure(s: Scenario, stage: string, expected: unknown, actual: unknown): string {
return [
`hidden-reveal reconciliation divergence — stage: ${stage}`,
`seed: ${s.seed} (re-run: FUZZ_SEED=${s.seed})`,
`dims: ${s.dims.cols}x${s.dims.rows} revealIdx: ${s.revealIdx}/${s.chunks.length} domains: ${s.domains} dropped: ${s.hasDropped}`,
`chunks: ${JSON.stringify(
s.chunks.map((c) => ({ seq: c.seq, len: c.data.length, dom: c.domain }))
)}`,
`expected (always-visible): ${JSON.stringify(expected)}`,
`actual (reveal-from-snapshot): ${JSON.stringify(actual)}`
].join('\n')
}
describe('hidden reveal seq-reconciliation fuzz', () => {
it('is a byte-exact identity when the snapshot seq splits a straddling chunk', async () => {
// Pin the core invariant on a hand-built case so the property test cannot
// pass vacuously. rawLength === data.length so the straddle slices cleanly.
// Chunk 0 fully hidden; chunk 1 straddles the reveal; chunk 2 is the tail.
const dims = { cols: 40, rows: 6 }
const c0 = 'red line one\r\n'
const c1 = 'green straddle\r\n'
const c2 = 'plain tail rest'
const chunks: MeteredChunk[] = [
{ data: c0, seq: c0.length, rawLength: c0.length, domain: 0 },
{ data: c1, seq: c0.length + c1.length, rawLength: c1.length, domain: 0 },
{ data: c2, seq: c0.length + c1.length + c2.length, rawLength: c2.length, domain: 0 }
]
// Reveal after chunk 1 (snapshot seq = end of c1); tail = c2. Also exercise
// the straddle by revealing at chunk 1 with a snapshot seq mid-c1 handled by
// chunkDataAfterSnapshot when c1 is in the tail — covered by revealIdx=1.
const revealAfterC1 = await revealFromSnapshot(dims, chunks, 2)
const control = await alwaysVisible(dims, chunks)
expect(revealAfterC1.rows).toEqual(control.rows)
expect(revealAfterC1.styles).toEqual(control.styles)
expect(revealAfterC1.cursor).toEqual(control.cursor)
// Reveal at chunk 1: c0 hidden (snapshot seq = end of c0), c1+c2 are the
// tail. c1 is fully after the snapshot seq so it writes whole — identity.
const revealAtC1 = await revealFromSnapshot(dims, chunks, 1)
expect(revealAtC1.rows).toEqual(control.rows)
expect(revealAtC1.styles).toEqual(control.styles)
expect(revealAtC1.cursor).toEqual(control.cursor)
})
// ── Bug D: DECSC saved-cursor register lost across the hide/reveal boundary ──
// The snapshot is a serialized screen; the VT100 saved-cursor register
// (DECSC ESC 7 / CSI s) is runtime state it cannot carry. When a hidden TUI
// saves the cursor before the reveal seq and the racing tail restores it
// (DECRC ESC 8 / CSI u), the restore lands at home instead — the next writes
// clobber the wrong cells. Found by fuzz seed 3 (a savedCursorDetour op split
// across the reveal boundary). See notes/garble-fuzz-divergences.md (Bug D).
// Fix direction: carry the saved-cursor out-of-band in the snapshot (like
// oscLinks) or re-emit a synthetic DECSC after replay. Unskip once landed.
it.skip('preserves the DECSC saved-cursor register across a hide/reveal boundary', async () => {
const dims = { cols: 20, rows: 4 }
// Hidden: write 'AB', DECSC saves cursor at r0c2, move to r3c9, write 'CD'.
const hidden: MeteredChunk = {
data: 'AB\x1b7\x1b[4;10HCD',
seq: 'AB\x1b7\x1b[4;10HCD'.length,
rawLength: 'AB\x1b7\x1b[4;10HCD'.length,
domain: 0
}
// Tail: DECRC restores the saved cursor, write 'X' → live shows 'ABX'.
const tail: MeteredChunk = {
data: '\x1b8X',
seq: hidden.seq! + 3,
rawLength: 3,
domain: 0
}
const reveal = await revealFromSnapshot(dims, [hidden, tail], 1)
const live = await alwaysVisible(dims, [hidden, tail])
// Fails today: reveal shows 'XB' (DECRC landed at home) not live's 'ABX'.
expect(reveal.rows).toEqual(live.rows)
expect(reveal.cursor).toEqual(live.cursor)
})
// ── Bug E: snapshot boundary mid-escape-sequence drops the partial sequence ──
// A PTY read (one delivery record) can split an escape. If the pane is
// revealed with the emulator parser mid-ESC[…, the serialized SCREEN cannot
// carry the partial sequence, so the racing tail's continuation bytes render
// as literal text. Found by fuzz seed 4 (a hidden prefix chunk that ended on
// ESC[3, tail supplied 'm'). See notes/garble-fuzz-divergences.md (Bug E).
// Fix direction: have main hold a trailing incomplete escape out of the drain
// until it completes, or snapshot only at a parser-clean boundary. Unskip once
// landed.
it.skip('completes an escape sequence split across the hide/reveal boundary', async () => {
const dims = { cols: 20, rows: 3 }
// Hidden prefix ends mid-escape: 'AB' then ESC[3 (no final byte).
const hidden: MeteredChunk = { data: 'AB\x1b[3', seq: 5, rawLength: 5, domain: 0 }
// Tail completes it: 'm' → ESC[3m (italic), then 'CD' italic. Live: 'ABCD'.
const tail: MeteredChunk = { data: 'mCD', seq: 8, rawLength: 3, domain: 0 }
const reveal = await revealFromSnapshot(dims, [hidden, tail], 1)
const live = await alwaysVisible(dims, [hidden, tail])
// Fails today: reveal shows 'ABmCD' (the 'm' became literal).
expect(reveal.rows).toEqual(live.rows)
})
it(`stitches the racing tail losslessly across ${ITERATIONS} seeded hide/reveal scenarios`, async () => {
let statsCompared = 0
let statsSkippedScrolled = 0
let statsForcedFresh = 0
let statsMidSequenceBugE = 0
for (let i = 0; i < ITERATIONS; i++) {
const seed = FIXED_SEED ?? 1 + i
const scenario = buildScenario(seed)
// Bug E: the snapshot boundary falls mid-escape-sequence (a PTY read
// split an escape). The partial sequence lives in the emulator parser,
// not the serialized screen, so the tail's continuation renders literal.
// Tolerated + counted; pinned by the skipped repro below.
const hiddenPrefix = scenario.chunks
.slice(0, scenario.revealIdx)
.map((c) => c.data)
.join('')
if (prefixEndsMidSequence(hiddenPrefix)) {
statsMidSequenceBugE += 1
continue
}
// Primary control: a snapshot of the WHOLE stream. Sharing the snapshot
// machinery isolates the seq-reconciliation tail-stitch from
// snapshot-fidelity gaps (fidelity suite's Bugs B/C). If snapshot-at-S +
// tail differs from snapshot-of-everything, a byte was lost/duped/mis-
// sliced. Also compare against always-visible where the two agree, to
// catch a tail that diverges from live output.
const [reveal, reference, live] = await Promise.all([
revealFromSnapshot(scenario.dims, scenario.chunks, scenario.revealIdx),
fullSnapshotReference(scenario.dims, scenario.chunks),
alwaysVisible(scenario.dims, scenario.chunks)
])
if (reveal.forcedFreshRestore || reference.forcedFreshRestore) {
statsForcedFresh += 1
continue
}
if (reveal.alternate !== reference.alternate) {
expect.fail(
formatFailure(scenario, 'alt-screen-state', reference.alternate, reveal.alternate)
)
}
// Scrolled scenarios: top-row survival is a snapshot-depth question
// (fidelity suite), not seq reconciliation.
if (reveal.scrolled || reference.scrolled) {
statsSkippedScrolled += 1
continue
}
statsCompared += 1
// Seq-reconciliation gate (snapshot-fidelity-neutral).
if (firstRowDiff(reveal.content, reference.content) !== null) {
expect.fail(
formatFailure(scenario, 'tail-stitch-content', reference.content, reveal.content)
)
}
if (firstRowDiff(reveal.rows, reference.rows) !== null) {
expect.fail(formatFailure(scenario, 'tail-stitch-visible', reference.rows, reveal.rows))
}
if (firstRowDiff(reveal.styles, reference.styles) !== null) {
expect.fail(formatFailure(scenario, 'tail-stitch-styles', reference.styles, reveal.styles))
}
if (JSON.stringify(reveal.cursor) !== JSON.stringify(reference.cursor)) {
expect.fail(formatFailure(scenario, 'tail-stitch-cursor', reference.cursor, reveal.cursor))
}
// Stronger gate: when a full-stream SNAPSHOT already matches the
// always-visible LIVE screen (no fidelity gap in play), the mid-point
// reveal must match live too — a true end-to-end garble check.
if (
!live.scrolled &&
live.alternate === reference.alternate &&
firstRowDiff(reference.rows, live.rows) === null &&
firstRowDiff(reference.styles, live.styles) === null &&
JSON.stringify(reference.cursor) === JSON.stringify(live.cursor)
) {
if (firstRowDiff(reveal.rows, live.rows) !== null) {
expect.fail(formatFailure(scenario, 'live-visible', live.rows, reveal.rows))
}
if (firstRowDiff(reveal.styles, live.styles) !== null) {
expect.fail(formatFailure(scenario, 'live-styles', live.styles, reveal.styles))
}
if (JSON.stringify(reveal.cursor) !== JSON.stringify(live.cursor)) {
expect.fail(formatFailure(scenario, 'live-cursor', live.cursor, reveal.cursor))
}
}
}
// Guard against a degenerate corpus that skips its way to green: a healthy
// fraction of scenarios must reach the full non-scrolled comparison.
expect(statsCompared).toBeGreaterThan(ITERATIONS * 0.2)
// The mid-escape (Bug E) tolerance must not swallow the suite.
expect(statsMidSequenceBugE).toBeLessThan(Math.max(3, ITERATIONS * 0.5))
expect(
statsCompared + statsSkippedScrolled + statsForcedFresh + statsMidSequenceBugE
).toBeLessThanOrEqual(ITERATIONS)
}, 120_000)
})
+279
View File
@@ -0,0 +1,279 @@
// Seeded ANSI byte-stream generator for the terminal garble differential
// fuzz suites (headless-emulator-fidelity.fuzz.test.ts and
// hidden-reveal-reconciliation.fuzz.test.ts). The op mix models real agent
// TUI output (Claude Code / Codex): CR status-line redraws, cursor-up panel
// repaints, DEC 2026 synchronized frames, SGR color runs, wide CJK and ZWJ
// emoji, wrapped long lines, alt-screen sessions, and scroll regions.
//
// Deliberately excluded ops (they would fuzz behavior the snapshot/restore
// contract does not promise to preserve, so any diff would be noise, not a
// garble bug):
// - DECAWM (?7l) and IRM (CSI 4h): SerializeAddon does not re-emit them and
// rehydrateSequences (headless-emulator.ts buildRehydrateSequences) only
// covers alt-screen/bracketed-paste/app-cursor/mouse modes.
// - Terminal queries (DA/DSR/DECRQM): the emulator is write-only by contract
// (headless-emulator.ts onQueryReply gating); replies are a separate
// authority problem with its own pinned tests (session.test.ts).
/** Same seeded PRNG as retained-tail-redraw-window.equivalence.test.ts. */
export function mulberry32(seed: number): () => number {
let a = seed >>> 0
return () => {
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
export type AgentTuiStreamDims = { cols: number; rows: number }
export type AgentTuiStreamProfile = {
/** Mouse-mode toggles require mirroring TerminalMouseModeMirror to build
* rehydrate parity; the renderer-side fuzz cannot import that main-only
* module (tsconfig.tc.web.json excludes src/main/daemon), so it opts out. */
includeMouseModes: boolean
/** OSC 8 hyperlinks mark their cells underlined in the xterm buffer, but
* SerializeAddon never re-emits OSC 8 — production restores link ranges
* out-of-band via snapshot.oscLinks (collectHeadlessOscLinkRanges), so
* byte-replay fidelity legitimately drops that underline. The suites pin
* the metadata compensation in a targeted test instead of fuzzing it. */
includeOscHyperlinks: boolean
opCount: number
}
function int(rng: () => number, min: number, max: number): number {
return min + Math.floor(rng() * (max - min + 1))
}
function pick<T>(rng: () => number, values: readonly T[]): T {
return values[Math.floor(rng() * values.length)]!
}
const WORDS = [
'reading',
'src/main/daemon/session.ts',
'tokens 12.4k',
'esc to interrupt',
'Thinking…',
'bash: pnpm typecheck',
'+142 -37',
'PASS terminal.test.ts',
'waiting for approval',
'diff --git a/pty.ts'
] as const
const WIDE_RUNS = [
'你好世界',
'터미널 상태 확인',
'進捗を表示中',
'🟢 working',
'🤖 codex',
'✅ done ✨',
// ZWJ emoji join — the exact width divergence the Orca unicode provider
// exists for (shared/terminal-unicode-provider.ts).
'👨‍👩‍👧‍👦 team',
'🇰🇷 locale'
] as const
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'] as const
function sgr(rng: () => number): string {
const roll = rng()
if (roll < 0.2) {
return `\x1b[${pick(rng, ['0', '1', '2', '3', '4', '7', '9', '22', '24', '39', '49'])}m`
}
if (roll < 0.55) {
return `\x1b[${int(rng, 30, 37) + (rng() < 0.3 ? 60 : 0)}m`
}
if (roll < 0.8) {
return `\x1b[38;5;${int(rng, 0, 255)}m`
}
return `\x1b[38;2;${int(rng, 0, 255)};${int(rng, 0, 255)};${int(rng, 0, 255)}m`
}
function textRun(rng: () => number): string {
const parts: string[] = []
const count = int(rng, 1, 3)
for (let i = 0; i < count; i++) {
parts.push(rng() < 0.25 ? pick(rng, WIDE_RUNS) : pick(rng, WORDS))
}
return parts.join(' ')
}
function styledLine(rng: () => number): string {
return `${sgr(rng)}${textRun(rng)}\x1b[0m\r\n`
}
function panelRedraw(rng: () => number, dims: AgentTuiStreamDims): string {
const height = int(rng, 1, Math.min(6, dims.rows - 2))
const lines: string[] = [`\x1b[${height}A\r`]
if (rng() < 0.5) {
lines.push('\x1b[0J')
}
for (let i = 0; i < height; i++) {
lines.push(`\x1b[2K${sgr(rng)}│ ${textRun(rng)}\x1b[0m\r\n`)
}
return lines.join('')
}
function statusLineRewrite(rng: () => number): string {
return `\r\x1b[2K${sgr(rng)}${pick(rng, SPINNER)} ${textRun(rng)}\x1b[0m`
}
function cursorMotion(rng: () => number, dims: AgentTuiStreamDims): string {
const roll = rng()
if (roll < 0.4) {
return `\x1b[${int(rng, 1, dims.rows)};${int(rng, 1, dims.cols)}H`
}
if (roll < 0.55) {
return `\x1b[${int(rng, 1, dims.cols)}G`
}
return `\x1b[${int(rng, 1, 4)}${pick(rng, ['A', 'B', 'C', 'D'] as const)}`
}
function eraseOp(rng: () => number): string {
return pick(rng, ['\x1b[K', '\x1b[1K', '\x1b[2K', '\x1b[0J', '\x1b[1J'] as const)
}
function wrappedLongLine(rng: () => number, dims: AgentTuiStreamDims): string {
const unit = `${textRun(rng)} `
const repeats = Math.ceil((dims.cols * int(rng, 1, 3)) / Math.max(unit.length, 1)) + 1
return `${sgr(rng)}${unit.repeat(repeats)}\x1b[0m\r\n`
}
function scrollRegionBurst(rng: () => number, dims: AgentTuiStreamDims): string {
const top = int(rng, 1, Math.max(1, dims.rows - 4))
const bottom = int(rng, top + 1, dims.rows)
const body: string[] = [`\x1b[${top};${bottom}r`, `\x1b[${bottom};1H`]
for (let i = 0; i < int(rng, 1, 4); i++) {
body.push(`${textRun(rng)}\r\n`)
}
body.push('\x1b[r')
return body.join('')
}
function altScreenFrame(rng: () => number, dims: AgentTuiStreamDims): string {
const rows = int(rng, 2, Math.min(8, dims.rows))
const body: string[] = ['\x1b[?1049h', '\x1b[2J\x1b[H', '\x1b[?25l']
for (let i = 0; i < rows; i++) {
body.push(`${sgr(rng)}│ ${textRun(rng)}\x1b[0m${i === rows - 1 ? '' : '\r\n'}`)
}
body.push(`\x1b[${int(rng, 1, dims.rows)};${int(rng, 1, dims.cols)}H\x1b[?25h`)
if (rng() < 0.5) {
body.push('\x1b[?1049l')
}
return body.join('')
}
function synchronizedFrame(rng: () => number, dims: AgentTuiStreamDims): string {
return `\x1b[?2026h${rng() < 0.5 ? panelRedraw(rng, dims) : statusLineRewrite(rng)}\x1b[?2026l`
}
function savedCursorDetour(rng: () => number, dims: AgentTuiStreamDims): string {
return `\x1b7${cursorMotion(rng, dims)}${sgr(rng)}${textRun(rng)}\x1b[0m\x1b8`
}
function oscOp(rng: () => number, profile: AgentTuiStreamProfile): string {
const roll = rng()
if (roll < 0.5) {
return `\x1b]0;${textRun(rng)}\x07`
}
if (roll < 0.8) {
return profile.includeOscHyperlinks
? `\x1b]8;;https://example.com/${int(rng, 1, 999)}\x07link\x1b]8;;\x07`
: `https://example.com/pr/${int(rng, 1, 999)}\r\n`
}
return '\x1b]133;A\x07'
}
function modeToggle(rng: () => number, profile: AgentTuiStreamProfile): string {
const toggles = ['\x1b[?2004h', '\x1b[?2004l', '\x1b[?1h', '\x1b[?1l', '\x1b[?25l', '\x1b[?25h']
if (profile.includeMouseModes) {
toggles.push(
'\x1b[?1000h',
'\x1b[?1002h',
'\x1b[?1003h',
'\x1b[?1006h',
'\x1b[?1000l',
'\x1b[?1006l'
)
}
return pick(rng, toggles)
}
/** One seeded agent-TUI-shaped op. Weights favor the redraw ops that have
* historically produced hidden-restore garble (CR rewrites, cursor-up panel
* repaints, DEC 2026 frames, alt-screen churn). */
function nextOp(
rng: () => number,
dims: AgentTuiStreamDims,
profile: AgentTuiStreamProfile
): string {
const roll = rng()
if (roll < 0.18) {
return styledLine(rng)
}
if (roll < 0.32) {
return statusLineRewrite(rng)
}
if (roll < 0.46) {
return panelRedraw(rng, dims)
}
if (roll < 0.54) {
return synchronizedFrame(rng, dims)
}
if (roll < 0.62) {
return wrappedLongLine(rng, dims)
}
if (roll < 0.7) {
return cursorMotion(rng, dims) + eraseOp(rng)
}
if (roll < 0.76) {
return altScreenFrame(rng, dims)
}
if (roll < 0.82) {
return scrollRegionBurst(rng, dims)
}
if (roll < 0.88) {
return savedCursorDetour(rng, dims)
}
if (roll < 0.94) {
return oscOp(rng, profile)
}
return modeToggle(rng, profile)
}
export function buildAgentTuiStreamOps(
rng: () => number,
dims: AgentTuiStreamDims,
profile: AgentTuiStreamProfile
): string[] {
const ops: string[] = []
for (let i = 0; i < profile.opCount; i++) {
ops.push(nextOp(rng, dims, profile))
}
return ops
}
/** Splits a stream at random boundaries, including inside escape sequences
* (PTY chunking does that constantly). Never splits a surrogate pair: PTY
* bytes are decoded to complete code points before reaching JS strings. */
export function splitIntoRandomChunks(
rng: () => number,
stream: string,
bounds: { minLen: number; maxLen: number }
): string[] {
const chunks: string[] = []
let cursor = 0
while (cursor < stream.length) {
let end = Math.min(stream.length, cursor + int(rng, bounds.minLen, bounds.maxLen))
const boundaryCode = stream.charCodeAt(end - 1)
if (end < stream.length && boundaryCode >= 0xd800 && boundaryCode <= 0xdbff) {
end += 1
}
chunks.push(stream.slice(cursor, end))
cursor = end
}
return chunks
}
@@ -0,0 +1,299 @@
// Renderer-parity headless terminal + snapshot/replay mirrors for the garble
// differential fuzz suites (headless-emulator-fidelity.fuzz.test.ts and
// hidden-reveal-reconciliation.fuzz.test.ts). Lives in src/shared because the
// main-side and renderer-side fuzz suites both consume it and neither
// tsconfig (tsconfig.node.json / tsconfig.tc.web.json) includes the other
// side's sources.
import { Terminal } from '@xterm/headless'
import { SerializeAddon } from '@xterm/addon-serialize'
import { Unicode11Addon } from '@xterm/addon-unicode11'
import { activateOrcaTerminalUnicodeProvider } from './terminal-unicode-provider'
import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy'
export type ParityTerminal = {
terminal: Terminal
serializeAddon: SerializeAddon
}
/** Builds an @xterm/headless terminal configured exactly like the renderer
* pane where buffer state is concerned: scrollback + kitty vtExtensions from
* buildDefaultTerminalOptions (pane-terminal-options.ts), Unicode11Addon
* (pane-dom-creation.ts) and the Orca ZWJ provider (pane-lifecycle.ts).
* Font/cursor/render options are omitted — they never alter buffer cells. */
export function createRendererParityTerminal(dims: { cols: number; rows: number }): ParityTerminal {
const terminal = new Terminal({
cols: dims.cols,
rows: dims.rows,
scrollback: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT,
allowProposedApi: true,
vtExtensions: { kittyKeyboard: true }
})
const serializeAddon = new SerializeAddon()
terminal.loadAddon(serializeAddon)
terminal.loadAddon(new Unicode11Addon())
activateOrcaTerminalUnicodeProvider(terminal)
return { terminal, serializeAddon }
}
export function writeToTerminal(terminal: Terminal, data: string): Promise<void> {
return new Promise((resolve) => terminal.write(data, resolve))
}
export async function writeChunksToTerminal(terminal: Terminal, chunks: string[]): Promise<void> {
for (const chunk of chunks) {
await writeToTerminal(terminal, chunk)
}
}
/** Bottom-anchored visible screen rows (baseY, not viewportY — scroll intent
* is enforced separately by the production restore path). */
export function visibleRows(terminal: Terminal): string[] {
const buffer = terminal.buffer.active
const rows: string[] = []
for (let y = 0; y < terminal.rows; y++) {
rows.push(buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? '')
}
return rows
}
// xterm attribute color modes (Attributes CM_* in xterm's buffer model).
const COLOR_MODE_P16 = 16777216
const COLOR_MODE_P256 = 33554432
/** Known-legitimate serializer normalization: SerializeAddon re-emits palette
* indices 0-15 written via 38;5;N / 48;5;N as classic SGR 30-37/90-97, so a
* restored cell reports CM_P16 where the live cell reported CM_P256. Both
* modes resolve through the same 16 theme slots — no visual difference. */
function canonicalColorMode(mode: number, color: number): number {
return mode === COLOR_MODE_P256 && color >= 0 && color < 16 ? COLOR_MODE_P16 : mode
}
/** Per-cell descriptor rows so SGR runs that shift cells are caught even
* when the text matches. Encodes only VISUALLY EFFECTIVE state:
* - glyph cells: char, width, fg, bg, all attribute flags;
* - blank cells (null cells and spaces render identically): width, bg,
* underline/strikethrough (drawn across blanks), and fg only when inverse
* swaps it into the cell background. SerializeAddon legitimately skips
* null cells with cursor motion, dropping their invisible fg/bold/italic
* state, and may materialize a skipped run as plain spaces — neither can
* be seen, so neither may fail the garble gate.
* Trailing default blanks are trimmed: the serializer does not re-emit
* pristine cells past the last written column. */
export function visibleRowStyles(terminal: Terminal): string[] {
const buffer = terminal.buffer.active
const out: string[] = []
for (let y = 0; y < terminal.rows; y++) {
const line = buffer.getLine(buffer.baseY + y)
const cells: string[] = []
for (let x = 0; line && x < line.length; x++) {
const cell = line.getCell(x)
if (!cell) {
continue
}
const chars = cell.getChars()
const fgMode = canonicalColorMode(cell.getFgColorMode(), cell.getFgColor())
const bgMode = canonicalColorMode(cell.getBgColorMode(), cell.getBgColor())
if (chars === '' || chars === ' ') {
const blankFlags = [cell.isUnderline(), cell.isStrikethrough()]
.map((flag) => (flag ? '1' : '0'))
.join('')
const inverseFg = cell.isInverse() ? `·if${fgMode}:${cell.getFgColor()}` : ''
cells.push(
`▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${blankFlags}${inverseFg}`
)
continue
}
const flags = [
cell.isBold(),
cell.isDim(),
cell.isItalic(),
cell.isUnderline(),
cell.isInverse(),
cell.isStrikethrough()
]
.map((flag) => (flag ? '1' : '0'))
.join('')
cells.push(
`${chars}·w${cell.getWidth()}·f${fgMode}:${cell.getFgColor()}·b${bgMode}:${cell.getBgColor()}·${flags}`
)
}
const defaultBlank = `▯·w1·b0:-1·00`
while (cells.length > 0 && cells.at(-1) === defaultBlank) {
cells.pop()
}
out.push(cells.join('|'))
}
return out
}
export function cursorPosition(terminal: Terminal): { x: number; y: number } {
return { x: terminal.buffer.active.cursorX, y: terminal.buffer.active.cursorY }
}
/** KNOWN UPSTREAM BUG predicate (@xterm/addon-serialize 0.15.0-beta.287):
* null cells touching a soft-wrap boundary do not round-trip. Two confirmed
* variants (see the skipped repros in headless-emulator-fidelity.fuzz.test.ts):
* - V1 (cell loss): a wrapped continuation row starting with a NULL cell
* (only erasure creates those — typed spaces have chars ' ') passes the
* addon's wrap-validity ternary (SerializeAddon.ts ~L214 binds as
* `(chars && isDoubleWidth) ? ...`), so the blank is skipped with CUF —
* which clamps at the right margin instead of crossing the wrap boundary,
* overwriting the previous row's last cell and shifting the tail left.
* - V2 (filler artifact): a wrapped pair whose SOURCE row is entirely null
* takes the forced-wrap "magic" path, whose cleanup emits `ESC[0C`; CSI
* param 0 means 1, so the erase lands one cell right and the first filler
* '-' stays visible on the restored screen.
* The fuzz suites use this predicate to tolerate (and count) exactly these
* divergences without masking unknown ones. */
export function bufferHasSerializeHostileWrappedRow(terminal: Terminal): boolean {
const buffer = terminal.buffer.active
for (let y = 1; y < buffer.length; y++) {
const line = buffer.getLine(y)
if (!line?.isWrapped) {
continue
}
if (line.getCell(0)?.getChars() === '') {
return true
}
const previous = buffer.getLine(y - 1)
let previousIsAllNull = previous !== undefined
for (let x = 0; previous && x < previous.length; x++) {
if (previous.getCell(x)?.getChars() !== '') {
previousIsAllNull = false
break
}
}
if (previousIsAllNull) {
return true
}
}
return false
}
/** KNOWN UPSTREAM BUG predicate — Bug B (@xterm/addon-serialize 0.15.0-beta.287):
* serializing a dim cell followed by a bold-only cell emits `\x1b[1;22m`; SGR
* 22 (normalIntensity) clears bold too, so the restored cell loses bold. The
* fuzz suites tolerate + count exactly the case where the serialized snapshot
* contains that self-cancelling transition. Minimal repro + mechanism in
* headless-emulator-fidelity.fuzz.test.ts and notes/garble-fuzz-divergences.md. */
const SELF_CANCELLING_BOLD_RESET_RE = new RegExp(
// `1;22` in any SGR position: bold set then immediately reset in one CSI m.
`${String.fromCharCode(27)}\\[(?:[0-9;]*;)?1;22(?:;[0-9;]*)?m`
)
export function snapshotHasSelfCancellingBoldReset(snapshotAnsi: string): boolean {
return SELF_CANCELLING_BOLD_RESET_RE.test(snapshotAnsi)
}
/** KNOWN UPSTREAM BUG predicate — Bug C (@xterm/addon-serialize 0.15.0-beta.287):
* after a content row filled to exactly `cols`, xterm is wrap-pending and the
* serializer's relative cursor restore lands one column short. Tolerated +
* counted only when the live cursor is exactly one column right of the restored
* one AND some content row fills the full width (the wrap-pending trigger). */
export function isMarginWrapPendingCursorOffByOne(control: Terminal, restored: Terminal): boolean {
const live = cursorPosition(control)
const back = cursorPosition(restored)
if (live.y !== back.y || live.x !== back.x + 1) {
return false
}
const buffer = control.buffer.active
for (let y = 0; y < buffer.length; y++) {
const line = buffer.getLine(y)
if (!line) {
continue
}
const lastCol = control.cols - 1
if ((line.getCell(lastCol)?.getChars() ?? '') !== '') {
return true
}
}
return false
}
/** Full normal-buffer text with trailing blank rows trimmed (SerializeAddon
* restores content rows; both sides may differ only in trailing blanks). */
export function normalBufferRowsTrimmed(terminal: Terminal): string[] {
const buffer = terminal.buffer.normal
const rows: string[] = []
for (let y = 0; y < buffer.length; y++) {
rows.push(buffer.getLine(y)?.translateToString(true) ?? '')
}
while (rows.length > 0 && rows.at(-1) === '') {
rows.pop()
}
return rows
}
// Mirror of applyMainBufferSnapshot's clear preamble (pty-connection.ts):
// normal-buffer restores wipe screen+scrollback+home; alt-screen restores
// clear only the alt screen so the normal buffer's scrollback survives.
export const SNAPSHOT_REPLAY_PREAMBLE_NORMAL = '\x1b[2J\x1b[3J\x1b[H'
export const SNAPSHOT_REPLAY_PREAMBLE_ALT = '\x1b[?1049h\x1b[2J\x1b[H'
// Twin of POST_REPLAY_LIVE_SNAPSHOT_RESET (layout-serialization.ts) — the
// renderer suite pins equality against the real constant so drift fails fast.
export const POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY = '\x1b[0 q\x1b[?25h\x1b[?1004l'
export type ParityMainSnapshot = {
data: string
cols: number
rows: number
seq: number
alternateScreen: boolean
pendingDeliveryStartSeq?: number
}
/** Mirror of the production main-buffer snapshot the renderer restore path
* consumes: HeadlessEmulator.getSnapshot (snapshotAnsi normalization +
* rehydrateSequences) composed exactly like
* OrcaRuntime.serializeHeadlessTerminalBuffer (alt forces scrollback 0,
* data = rehydrateSequences + snapshotAnsi). The renderer fuzz cannot import
* HeadlessEmulator itself — tsconfig.tc.web.json excludes src/main/daemon. */
export function buildParityMainBufferSnapshot(
parity: ParityTerminal,
seq: number,
opts: { pendingDeliveryStartSeq?: number; scrollbackRows?: number } = {}
): ParityMainSnapshot {
const { terminal } = parity
const alternateScreen = terminal.buffer.active.type === 'alternate'
// Why scrollback 0 on alt: serializeHeadlessTerminalBuffer forces it so
// normal-buffer scrollback cannot replay underneath an alt-screen TUI frame.
const scrollback = alternateScreen
? 0
: (opts.scrollbackRows ?? DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT)
let snapshotAnsi = parity.serializeAddon.serialize({ scrollback })
if (alternateScreen) {
// Why: HeadlessEmulator.normalizeSnapshotAnsiForModes drops the
// serializer's duplicate ?1049h; rehydrateSequences re-enters alt.
const marker = '\x1b[?1049h'
const start = snapshotAnsi.lastIndexOf(marker)
if (start !== -1) {
snapshotAnsi = snapshotAnsi.slice(start + marker.length)
}
}
const seqs: string[] = []
if (alternateScreen) {
seqs.push('\x1b[?1049h')
}
if (terminal.modes.bracketedPasteMode) {
seqs.push('\x1b[?2004h')
}
// Why normal-buffer-only: HeadlessEmulator.getModes reports
// applicationCursor false while the alternate buffer is active, so the
// production rehydrate omits ?1h for alt-screen snapshots.
if (!alternateScreen && terminal.modes.applicationCursorKeysMode) {
seqs.push('\x1b[?1h')
}
// Mouse-mode rehydrate omitted: TerminalMouseModeMirror is main-only and
// mouse reporting is input encoding — it cannot alter rendered output.
const snapshot: ParityMainSnapshot = {
data: seqs.join('') + snapshotAnsi,
cols: terminal.cols,
rows: terminal.rows,
seq,
alternateScreen
}
if (opts.pendingDeliveryStartSeq !== undefined) {
snapshot.pendingDeliveryStartSeq = opts.pendingDeliveryStartSeq
}
return snapshot
}
@@ -73,6 +73,53 @@ function writeParkedFrameScript(scriptPath: string, runId: string): void {
)
}
// Deterministic static alt-screen frame for the park/reveal cycle test: rich
// styling (box drawing, SGR colors, wide glyphs) that exercises the snapshot
// restore, painted once and held so the on-screen content is stable across
// cycles. No spinner/progress churn — the frame must be byte-identical every
// reveal so drift is detectable.
function cycleReferenceFrame(runId: string): string {
const rows = [
'╭──────────────────────────────────────────────────────────╮',
`│ Park/reveal cycle reference ${runId.slice(0, 8)} 🟢 你好世界 터미널 │`,
'├───────────────┬──────────────────────────────────────────┤',
`│ model │ \x1b[1mcodex/opencode\x1b[22m stream +142 -37 │`,
`│ status │ \x1b[38;5;204mrunning\x1b[0m\x1b[2;36m diff --git a/pty.ts esc↩ │`,
'╰───────────────┴──────────────────────────────────────────╯',
`CYCLE_REFERENCE_${runId}`
]
return [
'\x1b[?1049h',
'\x1b[2J\x1b[H',
'\x1b[?25l',
rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'),
'\x1b[?25h'
].join('')
}
function writeCycleReferenceScript(scriptPath: string, runId: string): void {
mkdirSync(path.dirname(scriptPath), { recursive: true })
// Paint the frame once, then hold the process open so the alt-screen TUI
// stays on screen (and the parkable PTY session stays alive) across cycles.
writeFileSync(
scriptPath,
`process.stdout.write(${JSON.stringify(cycleReferenceFrame(runId))}); setInterval(() => {}, 1000)\n`
)
}
// Why: serialize() re-emits the buffer with cursor-restore trailer sequences
// (ESC[…H, ESC[?25h) and the exact CSI form can differ run-to-run without any
// visible change. Compare the CONTENT rows, not the trailer — strip trailing
// control sequences and normalize whitespace-only tail lines.
function terminalContentRows(serialized: string): string[] {
// eslint-disable-next-line no-control-regex
const stripped = serialized.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '')
return stripped
.split(/\r?\n/)
.map((line) => line.replace(/\s+$/, ''))
.filter((line) => line.length > 0)
}
async function readParkingWiring(
page: Page
): Promise<{ present: boolean; parkDelayMs: number | null }> {
@@ -406,4 +453,100 @@ test.describe('Terminal hidden view parking', () => {
expect(tabCState.hasManager).toBe(true)
expect(tabCState.paneCount).toBeGreaterThan(0)
})
// Drives 25 deterministic park→reveal cycles on a static rich TUI frame and
// asserts every reveal reproduces the SAME content the tab showed while it was
// continuously visible (the never-parked reference). This is the field-garble
// guard end-to-end: it exercises the real renderer teardown + HeadlessEmulator
// snapshot restore + PTY reattach path the fuzz suites model in isolation, and
// fails if any single cycle — or accumulated drift across 25 — garbles a cell.
test('reproduces a static frame byte-for-byte across 25 park/reveal cycles', async ({
orcaPage,
testRepoPath
}, testInfo: TestInfo) => {
test.setTimeout(180_000)
await waitForSessionReady(orcaPage)
const setup = await setUpParkableTabA(orcaPage)
const { worktreeId, tabAId, tabAPtyId } = setup
const runId = randomUUID()
const marker = `CYCLE_REFERENCE_${runId}`
const scriptPath = path.join(testRepoPath, `.orca-cycle-reference-${runId}.mjs`)
writeCycleReferenceScript(scriptPath, runId)
try {
await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 15_000,
message: 'cycle reference frame did not render while tab A was visible'
})
.toContain(marker)
// Tab B stays visible whenever tab A is parked; toggling the active tab
// between them is the deterministic hide/reveal driver.
const tabBId = await createActiveTerminalTab(orcaPage, worktreeId)
// One park/reveal cycle to run the frame through the snapshot restore for a
// baseline. Why not compare against the visible-before-park content: an
// alt-screen restore deliberately drops the normal-buffer scrollback
// (serializeHeadlessTerminalBuffer forces scrollback 0 under alt), so the
// pre-park serialize carries the shell command echo the restore correctly
// omits — that is contract, not garble. Baselining after one reveal makes
// both sides pass through identical machinery, so any later diff is drift.
const runOneParkRevealCycle = async (cycle: number): Promise<string[]> => {
await activateTerminalTab(orcaPage, tabBId)
await waitForTabParked(orcaPage, tabAId)
await activateTerminalTab(orcaPage, tabAId)
await waitForActiveTerminalManager(orcaPage, 30_000)
const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1)
expect(revealed.panes[0]?.ptyId).toBe(tabAPtyId)
await expect
.poll(() => getTerminalContent(orcaPage, 12_000), {
timeout: 15_000,
message: `cycle ${cycle}: reference frame did not restore on reveal`
})
.toContain(marker)
const rows = terminalContentRows(await getTerminalContent(orcaPage, 12_000))
// Garble sentinel: the hidden-skip banner must never appear.
expect(rows.join('\n')).not.toContain('Orca skipped hidden terminal output')
return rows
}
const referenceRows = await runOneParkRevealCycle(0)
expect(referenceRows.join('\n')).toContain(marker)
expect(referenceRows.join('\n')).toContain('╭')
// waitForTabParked (inside runOneParkRevealCycle) throws if the tab never
// parked, so reaching here means the machinery ran every cycle — no
// separate premise guard needed for a vacuous-green check.
const CYCLES = 25
const mismatches: string[] = []
for (let cycle = 1; cycle < CYCLES; cycle++) {
const rows = await runOneParkRevealCycle(cycle)
if (JSON.stringify(rows) !== JSON.stringify(referenceRows)) {
mismatches.push(
`cycle ${cycle}:\n expected: ${JSON.stringify(referenceRows)}\n actual: ${JSON.stringify(rows)}`
)
}
}
testInfo.annotations.push({
type: 'terminal-parking-cycles',
description: `cycles=${CYCLES} mismatches=${mismatches.length}`
})
expect(
mismatches,
`park/reveal drift across ${CYCLES} cycles:\n${mismatches.join('\n')}`
).toEqual([])
const screenshotPath = testInfo.outputPath('park-reveal-25-cycles-final.png')
await orcaPage.screenshot({ path: screenshotPath, fullPage: true })
await testInfo.attach('park-reveal-25-cycles-final.png', {
path: screenshotPath,
contentType: 'image/png'
})
} finally {
rmSync(scriptPath, { force: true })
}
})
})