diff --git a/notes/garble-fuzz-divergences.md b/notes/garble-fuzz-divergences.md index e357b3c0a2f..d53951ebfa9 100644 --- a/notes/garble-fuzz-divergences.md +++ b/notes/garble-fuzz-divergences.md @@ -29,22 +29,23 @@ same bytes. Any such diff is a user-visible garble on reveal. (predicate `bufferHasSerializeHostileWrappedRow`, tolerated + counted) or is listed below. -## Inventory (seeds 1..2000) +## Inventory -| class | seeds | count | classification | +| bug | found by | seeds | classification | | --- | --- | --- | --- | -| Bug A — serialize wrap null-cell | 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 | (a) real serialize bug, pre-documented + pinned | -| Bug B — SGR bold loss (`1;22`) | 435, 770, 1321 | 3 | (a) real serialize bug — garbles style on reveal | -| Bug C — cursor off-by-one at right margin | 454, 1696 | 2 | (a) real serialize bug — misplaces cursor on reveal | +| 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 | -The default suite runs seeds 1..300, so it only hits Bug A in range (171, 207, -31, 157) — all tolerated — which is why it passes green. The three new -divergence *classes* are all reproduced by dedicated minimal `test.skip` repros -so they cannot silently regress. `FUZZ_ITERATIONS=2000` (or higher) surfaces -Bugs B and C live; the suite skips them via the same wrap-style predicate is NOT -applicable — instead they are pinned as standalone skipped repros and excluded -from the corpus assertion by keeping the default corpus at 300. See the -"Corpus vs deep mode" note at the bottom. +The suite-1 default corpus runs seeds 1..300, so it only hits Bug A in range +(31, 157, 171, 207) — all tolerated — which is why it passes green. All five +bug *classes* are reproduced by dedicated minimal `test.skip` repros so they +cannot silently regress. `FUZZ_ITERATIONS=2000` (or higher) surfaces Bugs B and +C as live corpus divergences; because they lack a cheap buffer-state predicate +(unlike Bug A's wrap predicate) they are pinned as standalone skipped repros and +kept out of range by the default 300 corpus. See "Corpus vs deep mode" below. 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 @@ -149,6 +150,100 @@ caret or spinner, and subsequent input can overwrite the wrong cell. --- +## 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 diff --git a/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts b/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts new file mode 100644 index 00000000000..a2598734480 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts @@ -0,0 +1,577 @@ +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 +): Promise { + 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 { + 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 { + 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 { + 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 +const TAIL_SGR = ['\x1b[0m', '\x1b[31m', '\x1b[1m', '\x1b[38;5;204m', '\x1b[7m'] 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) +})