From 3aa83d89721cc6516ed3a49e411c94f65168791b Mon Sep 17 00:00:00 2001 From: Darrin Massena Date: Mon, 23 Feb 2026 20:50:13 -0800 Subject: [PATCH 1/2] fix: always clear new row cells during scroll to prevent stale data cursorDownScroll() only cleared new row cells when the cursor had a non-default background color. With default cursor style (after ESC[0m reset), new rows created during scrolling retained stale cell data from previously used page memory, causing periodic viewport corruption where content from old rows appeared horizontally merged into current rows. Make row clearing unconditional so stale cells never become visible, regardless of cursor style. Co-Authored-By: Claude Opus 4.6 --- patches/ghostty-wasm-api.patch | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/patches/ghostty-wasm-api.patch b/patches/ghostty-wasm-api.patch index bd3feb9..7c943f1 100644 --- a/patches/ghostty-wasm-api.patch +++ b/patches/ghostty-wasm-api.patch @@ -1564,6 +1564,26 @@ index 000000000..73ae2e6fa + try std.testing.expectEqual(@as(u32, 'l'), cells[3].codepoint); + try std.testing.expectEqual(@as(u32, 'o'), cells[4].codepoint); +} +diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig +index ba2af2473..b8be8f273 100644 +--- a/src/terminal/Screen.zig ++++ b/src/terminal/Screen.zig +@@ -848,9 +848,12 @@ pub fn cursorDownScroll(self: *Screen) !void { + // Our new row is always dirty + self.cursorMarkDirty(); + +- // Clear the new row so it gets our bg color. We only do this +- // if we have a bg color at all. +- if (self.cursor.style.bg_color != .none) { ++ // Always clear the new row's cells. When pages.grow() extends an ++ // existing page, the new row's cell memory may contain stale data ++ // from previously erased rows. Without clearing, these stale cells ++ // become visible when the row isn't fully overwritten (e.g., empty ++ // lines produced by bare \r\n sequences with default cursor style). ++ { + const page: *Page = &page_pin.node.data; + self.clearCells( + page, diff --git a/src/terminal/render.zig b/src/terminal/render.zig index b6430ea34..10e0ef79d 100644 --- a/src/terminal/render.zig From 1e56d6b20c9781f16337c48dab7451183445f033 Mon Sep 17 00:00:00 2001 From: Darrin Massena Date: Mon, 23 Feb 2026 20:54:18 -0800 Subject: [PATCH 2/2] test: add viewport row-merge corruption regression tests Co-Authored-By: Claude Opus 4.6 --- lib/viewport-corruption.test.ts | 349 ++++++++++++++++++++++++++++++ lib/viewport-row-merge.test.ts | 371 ++++++++++++++++++++++++++++++++ 2 files changed, 720 insertions(+) create mode 100644 lib/viewport-corruption.test.ts create mode 100644 lib/viewport-row-merge.test.ts diff --git a/lib/viewport-corruption.test.ts b/lib/viewport-corruption.test.ts new file mode 100644 index 0000000..ff3cf26 --- /dev/null +++ b/lib/viewport-corruption.test.ts @@ -0,0 +1,349 @@ +/** + * Viewport Corruption Tests + * + * Tests for the WASM viewport row-merge bug described in WASM_VIEWPORT_BUG.md. + * After repeated escape-heavy writes, getViewport() allegedly returns corrupted + * data where two terminal lines are horizontally concatenated into one row. + * + * These tests confirm or deny whether the bug exists. + */ + +import { describe, expect, test } from 'bun:test'; +import { createIsolatedTerminal } from './test-helpers'; +import type { Terminal } from './terminal'; + +/** + * Generate escape-heavy terminal output matching the bug report description. + * Exercises SGR 8/16/256/truecolor, text attributes, Unicode, and OSC sequences. + * Produces ~45 lines of output per call. + */ +function generateEscapeHeavyOutput(runNumber: number): string { + const lines: string[] = []; + const ESC = '\x1b'; + + // OSC 0: Set terminal title + lines.push(`${ESC}]0;Test Run ${runNumber}${ESC}\\`); + + // Section 1: Basic 8/16 colors + lines.push(`${ESC}[1m── 1. BASIC COLORS (Run ${runNumber}) ──${ESC}[0m`); + let colorLine = ''; + for (let i = 30; i <= 37; i++) { + colorLine += `${ESC}[${i}m Color${i} ${ESC}[0m`; + } + lines.push(colorLine); + let brightLine = ''; + for (let i = 90; i <= 97; i++) { + brightLine += `${ESC}[${i}m Bright${i} ${ESC}[0m`; + } + lines.push(brightLine); + + // Section 2: Text attributes + lines.push(`${ESC}[1m── 2. TEXT ATTRIBUTES ──${ESC}[0m`); + lines.push( + ` ${ESC}[1mBold${ESC}[0m ${ESC}[2mDim${ESC}[0m ${ESC}[3mItalic${ESC}[0m ${ESC}[4mUnderline${ESC}[0m ${ESC}[5mBlink${ESC}[0m ${ESC}[7mReverse${ESC}[0m ${ESC}[9mStrike${ESC}[0m` + ); + + // Section 3: 256-color backgrounds (2 rows of 128 each) + lines.push(`${ESC}[1m── 3. 256-COLOR PALETTE ──${ESC}[0m`); + let palette1 = ''; + for (let i = 0; i < 128; i++) { + palette1 += `${ESC}[48;5;${i}m ${ESC}[0m`; + } + lines.push(palette1); + let palette2 = ''; + for (let i = 128; i < 256; i++) { + palette2 += `${ESC}[48;5;${i}m ${ESC}[0m`; + } + lines.push(palette2); + + // Section 4: True color gradients + lines.push(`${ESC}[1m── 4. TRUE COLOR GRADIENTS ──${ESC}[0m`); + for (const [label, rFn, gFn, bFn] of [ + ['Red', (i: number) => i * 2, () => 0, () => 0], + ['Green', () => 0, (i: number) => i * 2, () => 0], + ['Blue', () => 0, () => 0, (i: number) => i * 2], + ['Rainbow', (i: number) => Math.sin(i * 0.05) * 127 + 128, (i: number) => Math.sin(i * 0.05 + 2) * 127 + 128, (i: number) => Math.sin(i * 0.05 + 4) * 127 + 128], + ] as [string, (i: number) => number, (i: number) => number, (i: number) => number][]) { + let grad = ` ${label}: `; + for (let i = 0; i < 64; i++) { + const r = Math.floor(rFn(i)); + const g = Math.floor(gFn(i)); + const b = Math.floor(bFn(i)); + grad += `${ESC}[48;2;${r};${g};${b}m ${ESC}[0m`; + } + lines.push(grad); + } + + // Section 5: More attributes with colors + lines.push(`${ESC}[1m── 5. COMBINED STYLES ──${ESC}[0m`); + lines.push(` ${ESC}[1;31mBold Red${ESC}[0m ${ESC}[3;32mItalic Green${ESC}[0m ${ESC}[4;34mUnderline Blue${ESC}[0m ${ESC}[1;3;35mBold Italic Magenta${ESC}[0m`); + lines.push(` ${ESC}[38;2;255;165;0m24-bit Orange${ESC}[0m ${ESC}[38;5;201mPalette Pink${ESC}[0m ${ESC}[7;36mReverse Cyan${ESC}[0m`); + + // Section 6: Unicode box drawing + lines.push(`${ESC}[1m── 6. UNICODE & BOX DRAWING ──${ESC}[0m`); + lines.push(''); + lines.push(' ┌──────────┬──────────┐'); + lines.push(' │ Cell A │ Cell B │'); + lines.push(' ├──────────┼──────────┤'); + lines.push(' │ Cell C │ Cell D │'); + lines.push(' └──────────┴──────────┘'); + lines.push(''); + lines.push(' Braille: ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ Arrows: ←↑→↓↔↕ Math: ∑∏∫∂√∞≠≈'); + + // Section 7: OSC 8 hyperlinks + lines.push(`${ESC}[1m── 7. OSC 8 HYPERLINKS ──${ESC}[0m`); + lines.push(` Click: ${ESC}]8;;https://example.com${ESC}\\Example Link${ESC}]8;;${ESC}\\ (OSC 8)`); + + // Section 8: Rainbow banner + lines.push(`${ESC}[1m── 8. RAINBOW BANNER ──${ESC}[0m`); + const bannerText = ' GHOSTTY WASM TERMINAL TEST '; + let banner = ''; + for (let i = 0; i < bannerText.length; i++) { + const colorIdx = 196 + (i % 36); + banner += `${ESC}[48;5;${colorIdx};1m${bannerText[i]}${ESC}[0m`; + } + lines.push(banner); + + // Section 9: Summary separator + lines.push(''); + lines.push('═'.repeat(80)); + lines.push(` ✓ Run ${runNumber} complete`); + lines.push('═'.repeat(80)); + lines.push(''); + + return lines.join('\r\n') + '\r\n'; +} + +/** + * Extract text content from a viewport row. + */ +function getViewportRowText(term: Terminal, row: number): string { + const viewport = term.wasmTerm?.getViewport(); + if (!viewport) return ''; + const cols = term.cols; + const start = row * cols; + return viewport + .slice(start, start + cols) + .map((c) => String.fromCodePoint(c.codepoint || 32)) + .join('') + .trimEnd(); +} + +/** + * Extract text content from getLine. + */ +function getLineRowText(term: Terminal, row: number): string { + const line = term.wasmTerm?.getLine(row); + if (!line) return ''; + return line + .map((c) => String.fromCodePoint(c.codepoint || 32)) + .join('') + .trimEnd(); +} + +/** + * Generate output with unique line markers for merge detection. + */ +function generateMarkedOutput(runNumber: number, lineCount: number): string { + const ESC = '\x1b'; + const lines: string[] = []; + for (let i = 0; i < lineCount; i++) { + const marker = `R${runNumber.toString().padStart(2, '0')}L${i.toString().padStart(2, '0')}`; + // Add escape sequences to stress the parser + lines.push( + `${ESC}[38;5;${(i * 7) % 256}m${marker}${ESC}[0m: ${ESC}[1m${ESC}[48;2;${i * 3};${i * 5};${i * 7}mContent line ${i} of run ${runNumber}${ESC}[0m ${'─'.repeat(40)}` + ); + } + return lines.join('\r\n') + '\r\n'; +} + +describe('Viewport Corruption', () => { + describe('getViewport consistency after repeated escape-heavy writes', () => { + test('getViewport and getLine return identical data after each run', async () => { + const term = await createIsolatedTerminal({ cols: 140, rows: 40, scrollback: 10000 }); + const container = document.createElement('div'); + term.open(container); + + for (let run = 1; run <= 10; run++) { + const output = generateEscapeHeavyOutput(run); + term.write(output); + term.wasmTerm!.update(); + + // Compare every row: getViewport vs getLine + for (let row = 0; row < term.rows; row++) { + const viewportText = getViewportRowText(term, row); + const lineText = getLineRowText(term, row); + expect(viewportText).toBe(lineText); + } + } + + term.dispose(); + }); + + test('getViewport returns identical data on consecutive calls', async () => { + const term = await createIsolatedTerminal({ cols: 140, rows: 40, scrollback: 10000 }); + const container = document.createElement('div'); + term.open(container); + + for (let run = 1; run <= 10; run++) { + const output = generateEscapeHeavyOutput(run); + term.write(output); + term.wasmTerm!.update(); + + const viewport1 = term.wasmTerm!.getViewport(); + const snapshot1 = viewport1.map((c) => ({ + codepoint: c.codepoint, + fg_r: c.fg_r, + fg_g: c.fg_g, + fg_b: c.fg_b, + bg_r: c.bg_r, + bg_g: c.bg_g, + bg_b: c.bg_b, + flags: c.flags, + width: c.width, + })); + + const viewport2 = term.wasmTerm!.getViewport(); + const snapshot2 = viewport2.map((c) => ({ + codepoint: c.codepoint, + fg_r: c.fg_r, + fg_g: c.fg_g, + fg_b: c.fg_b, + bg_r: c.bg_r, + bg_g: c.bg_g, + bg_b: c.bg_b, + flags: c.flags, + width: c.width, + })); + + expect(snapshot1).toEqual(snapshot2); + } + + term.dispose(); + }); + }); + + describe('row-merge detection with marked lines', () => { + test('no viewport row contains markers from two different lines', async () => { + const term = await createIsolatedTerminal({ cols: 140, rows: 40, scrollback: 10000 }); + const container = document.createElement('div'); + term.open(container); + + const linesPerRun = 45; + + for (let run = 1; run <= 10; run++) { + const output = generateMarkedOutput(run, linesPerRun); + term.write(output); + term.wasmTerm!.update(); + + // Check each viewport row for multiple markers + for (let row = 0; row < term.rows; row++) { + const text = getViewportRowText(term, row); + // Find all R##L## markers in this row + const markers = text.match(/R\d{2}L\d{2}/g) || []; + const uniqueMarkers = new Set(markers); + // A row should contain at most one unique marker + if (uniqueMarkers.size > 1) { + throw new Error( + `Run ${run}, row ${row}: found ${uniqueMarkers.size} different markers in one row: ${[...uniqueMarkers].join(', ')}\n` + + `Row content: "${text}"` + ); + } + } + } + + term.dispose(); + }); + + test('markers remain intact after accumulating scrollback', async () => { + const term = await createIsolatedTerminal({ cols: 140, rows: 40, scrollback: 10000 }); + const container = document.createElement('div'); + term.open(container); + + const linesPerRun = 45; + + for (let run = 1; run <= 10; run++) { + const output = generateMarkedOutput(run, linesPerRun); + term.write(output); + term.wasmTerm!.update(); + + // Verify viewport rows containing markers have the correct format + for (let row = 0; row < term.rows; row++) { + const text = getViewportRowText(term, row); + const match = text.match(/R(\d{2})L(\d{2})/); + if (match) { + const markerRun = parseInt(match[1], 10); + const markerLine = parseInt(match[2], 10); + // The marker should reference a valid run/line + expect(markerRun).toBeGreaterThanOrEqual(1); + expect(markerRun).toBeLessThanOrEqual(run); + expect(markerLine).toBeGreaterThanOrEqual(0); + expect(markerLine).toBeLessThan(linesPerRun); + } + } + } + + term.dispose(); + }); + }); + + describe('viewport stability across page boundaries', () => { + test('viewport consistent when output exceeds single page size', async () => { + // Use smaller scrollback to force page recycling sooner + const term = await createIsolatedTerminal({ cols: 140, rows: 40, scrollback: 500 }); + const container = document.createElement('div'); + term.open(container); + + // Write enough to overflow scrollback multiple times + for (let run = 1; run <= 20; run++) { + const output = generateMarkedOutput(run, 45); + term.write(output); + term.wasmTerm!.update(); + + // Verify getViewport and getLine still agree + for (let row = 0; row < term.rows; row++) { + const viewportText = getViewportRowText(term, row); + const lineText = getLineRowText(term, row); + expect(viewportText).toBe(lineText); + } + + // Check no row merging + for (let row = 0; row < term.rows; row++) { + const text = getViewportRowText(term, row); + const markers = text.match(/R\d{2}L\d{2}/g) || []; + const uniqueMarkers = new Set(markers); + if (uniqueMarkers.size > 1) { + throw new Error( + `Run ${run}, row ${row}: row merge detected with ${uniqueMarkers.size} markers: ${[...uniqueMarkers].join(', ')}\n` + + `Row content: "${text}"` + ); + } + } + } + + term.dispose(); + }); + + test('viewport consistent with large scrollback that triggers recycling', async () => { + // Very small scrollback to force aggressive recycling + const term = await createIsolatedTerminal({ cols: 140, rows: 40, scrollback: 100 }); + const container = document.createElement('div'); + term.open(container); + + for (let run = 1; run <= 15; run++) { + const output = generateEscapeHeavyOutput(run); + term.write(output); + term.wasmTerm!.update(); + + // getViewport and getLine must agree + for (let row = 0; row < term.rows; row++) { + const viewportText = getViewportRowText(term, row); + const lineText = getLineRowText(term, row); + expect(viewportText).toBe(lineText); + } + } + + term.dispose(); + }); + }); +}); diff --git a/lib/viewport-row-merge.test.ts b/lib/viewport-row-merge.test.ts new file mode 100644 index 0000000..6f3f474 --- /dev/null +++ b/lib/viewport-row-merge.test.ts @@ -0,0 +1,371 @@ +/** + * Viewport row-merging bug — self-contained reproduction. + * + * BUG: After writing enough escape-heavy output to accumulate scrollback, + * getViewport() periodically returns corrupted data where content from + * two rows is horizontally concatenated into a single row. + * + * Properties: + * - Transient: self-corrects on the next write (not consecutive) + * - Periodic: recurs at a fixed interval (~11 writes at cols=160 with this data) + * - All column widths affected, just at different frequencies + * - Independent of scrollback capacity (identical at 10KB..50MB) + * - In WASM state: both getViewport() and getLine() return the same wrong data + * + * The trigger requires enough per-write byte volume (~20KB+) to advance + * the ring buffer sufficiently. Smaller output (~3KB) only triggers the + * bug at narrow widths (cols≈120-130); larger output triggers it everywhere. + * + * 100% self-contained — no external fixture files needed. + */ + +import { describe, expect, test } from 'bun:test'; +import { createIsolatedTerminal } from './test-helpers'; +import type { Terminal } from './terminal'; + +const ESC = '\x1b'; + +/** + * Generate ~25KB of escape-heavy terminal output. Must be large enough + * to trigger the ring buffer misalignment at common widths (cols=160). + * + * The output simulates a color/rendering test script with: + * - 256-color palette blocks (SGR 48;5;N) + * - Truecolor gradients (SGR 48;2;R;G;B) + * - Text attribute combinations (bold, italic, underline, reverse) + * - Unicode box drawing + * - Dense colored grids (8 sections × 8 rows × 70 cols) + */ +function generateOutput(): Uint8Array { + const lines: string[] = []; + + lines.push(`${ESC}[1m${'═'.repeat(80)}${ESC}[0m`); + lines.push(`${ESC}[1m Terminal Rendering Test${ESC}[0m`); + lines.push(`${ESC}[1m${'═'.repeat(80)}${ESC}[0m`); + lines.push(''); + + // 256-color palette + lines.push(`${ESC}[1m── 1. 256-COLOR PALETTE ──${ESC}[0m`); + for (let row = 0; row < 8; row++) { + let line = ' '; + for (let i = 0; i < 32; i++) { + line += `${ESC}[48;5;${row * 32 + i}m ${ESC}[0m`; + } + lines.push(line); + } + lines.push(''); + + // Truecolor gradients + lines.push(`${ESC}[1m── 2. TRUECOLOR GRADIENTS ──${ESC}[0m`); + for (let row = 0; row < 8; row++) { + let line = ' '; + for (let i = 0; i < 80; i++) { + const r = Math.floor(Math.sin(i * 0.08 + row) * 127 + 128); + const g = Math.floor(Math.sin(i * 0.08 + row + 2) * 127 + 128); + const b = Math.floor(Math.sin(i * 0.08 + row + 4) * 127 + 128); + line += `${ESC}[48;2;${r};${g};${b}m ${ESC}[0m`; + } + lines.push(line); + } + lines.push(''); + + // Text attributes + lines.push(`${ESC}[1m── 3. TEXT ATTRIBUTES ──${ESC}[0m`); + lines.push(` ${ESC}[1mBold${ESC}[0m ${ESC}[3mItalic${ESC}[0m ${ESC}[4mUnderline${ESC}[0m ${ESC}[7mReverse${ESC}[0m ${ESC}[9mStrike${ESC}[0m`); + lines.push(` ${ESC}[1;3mBold+Italic${ESC}[0m ${ESC}[1;4mBold+Under${ESC}[0m ${ESC}[3;4mItalic+Under${ESC}[0m`); + lines.push(''); + + // Unicode box drawing + lines.push(`${ESC}[1m── 4. UNICODE BOX DRAWING ──${ESC}[0m`); + lines.push(' ┌──────────┬──────────┬──────────┐'); + lines.push(' │ Cell A │ Cell B │ Cell C │'); + lines.push(' ├──────────┼──────────┼──────────┤'); + lines.push(' │ Cell D │ Cell E │ Cell F │'); + lines.push(' └──────────┴──────────┴──────────┘'); + lines.push(''); + + // Dense colored grids — this is the bulk, producing enough byte volume + for (let section = 0; section < 8; section++) { + lines.push(`${ESC}[1m── ${section + 5}. COLOR GRID ${String.fromCharCode(65 + section)} ──${ESC}[0m`); + for (let row = 0; row < 8; row++) { + let line = ' '; + for (let i = 0; i < 70; i++) { + const idx = (section * 64 + row * 8 + i) % 256; + if ((i + row) % 3 === 0) { + line += `${ESC}[38;2;${(idx * 7) % 256};${(idx * 13) % 256};${(idx * 23) % 256}m*${ESC}[0m`; + } else { + line += `${ESC}[38;5;${idx}m*${ESC}[0m`; + } + } + lines.push(line); + } + lines.push(''); + } + + lines.push(`${ESC}[1m${'═'.repeat(80)}${ESC}[0m`); + lines.push(` ${ESC}[32m✓${ESC}[0m Test complete`); + lines.push(`${ESC}[1m${'═'.repeat(80)}${ESC}[0m`); + lines.push(''); + + return new TextEncoder().encode(lines.join('\r\n') + '\r\n'); +} + +/** Read viewport as text rows. */ +function getViewportText(term: Terminal): string[] { + const vp = term.wasmTerm!.getViewport(); + const cols = term.cols; + const rows: string[] = []; + for (let r = 0; r < term.rows; r++) { + let text = ''; + for (let c = 0; c < cols; c++) { + const cell = vp[r * cols + c]; + if (cell.width === 0) continue; + text += cell.codepoint > 32 ? String.fromCodePoint(cell.codepoint) : ' '; + } + rows.push(text.trimEnd()); + } + return rows; +} + +/** Count rows that differ between two viewport snapshots. */ +function countDiffs(a: string[], b: string[]): number { + let n = 0; + for (let i = 0; i < Math.max(a.length, b.length); i++) { + if ((a[i] || '') !== (b[i] || '')) n++; + } + return n; +} + +describe('Viewport row-merge bug', () => { + const data = generateOutput(); + + test('test data is large enough (>20KB)', () => { + expect(data.length).toBeGreaterThan(20_000); + }); + + /** + * Primary assertion: viewport text should be identical after every write + * of the same data. The bug causes periodic corruption where rows are + * horizontally merged. + */ + test('viewport text is stable after repeated writes', async () => { + const term = await createIsolatedTerminal({ cols: 160, rows: 39, scrollback: 10_000_000 }); + const container = document.createElement('div'); + term.open(container); + + let baseline: string[] | null = null; + const corruptReps: number[] = []; + + for (let rep = 0; rep < 30; rep++) { + term.write(data); + term.wasmTerm!.update(); + const text = getViewportText(term); + + if (!baseline) { + baseline = text; + } else { + if (countDiffs(text, baseline) > 0) corruptReps.push(rep); + } + } + + if (corruptReps.length > 0) { + console.log(`Corrupt at reps: [${corruptReps.join(', ')}]`); + } + expect(corruptReps.length).toBe(0); + + term.dispose(); + }); + + /** + * The corruption is transient — it never appears on consecutive writes. + * The write after a corrupt read always produces a correct viewport. + */ + test('corruption is never consecutive', async () => { + const term = await createIsolatedTerminal({ cols: 160, rows: 39, scrollback: 10_000_000 }); + const container = document.createElement('div'); + term.open(container); + + let baseline: string[] | null = null; + let prevCorrupt = false; + let consecutivePairs = 0; + + for (let rep = 0; rep < 30; rep++) { + term.write(data); + term.wasmTerm!.update(); + const text = getViewportText(term); + + if (!baseline) { + baseline = text; + prevCorrupt = false; + } else { + const corrupt = countDiffs(text, baseline) > 0; + if (corrupt && prevCorrupt) consecutivePairs++; + prevCorrupt = corrupt; + } + } + + expect(consecutivePairs).toBe(0); + term.dispose(); + }); + + /** + * The corruption is independent of scrollback capacity. The same + * writes corrupt at the same reps regardless of buffer size. + */ + test('corruption pattern is identical across scrollback sizes', async () => { + const patterns: string[] = []; + + for (const sb of [10_000, 1_000_000, 50_000_000]) { + const term = await createIsolatedTerminal({ cols: 160, rows: 39, scrollback: sb }); + const container = document.createElement('div'); + term.open(container); + + let baseline: string[] | null = null; + const corruptReps: number[] = []; + + for (let rep = 0; rep < 15; rep++) { + term.write(data); + term.wasmTerm!.update(); + const text = getViewportText(term); + + if (!baseline) baseline = text; + else if (countDiffs(text, baseline) > 0) corruptReps.push(rep); + } + + patterns.push(corruptReps.join(',')); + console.log(`scrollback=${sb}: corrupt at [${corruptReps.join(', ')}]`); + term.dispose(); + } + + // All patterns should be identical + expect(new Set(patterns).size).toBe(1); + }); + + /** + * Verify no row corruption occurs over many writes (regression guard). + * Previously, rows showed horizontally merged content from stale page cells. + */ + test('no row corruption over extended writes', async () => { + const term = await createIsolatedTerminal({ cols: 160, rows: 39, scrollback: 10_000_000 }); + const container = document.createElement('div'); + term.open(container); + + let baseline: string[] | null = null; + let corruptCount = 0; + + for (let rep = 0; rep < 30; rep++) { + term.write(data); + term.wasmTerm!.update(); + const text = getViewportText(term); + + if (!baseline) { baseline = text; continue; } + if (countDiffs(text, baseline) > 0) corruptCount++; + } + + expect(corruptCount).toBe(0); + + term.dispose(); + }); + + /** + * WORKAROUND: Replace every ESC[0m (SGR reset) with ESC[0;48;2;R;G;Bm + * where R,G,B is the terminal's background color. This keeps bg_color + * set to a non-.none value at all times, which triggers the row-clear + * path in cursorDownScroll even in the unpatched WASM code. + * + * The visual result is identical — the explicit bg color matches the + * terminal default — but the internal state differs enough to prevent + * stale cells from surviving page growth. + */ + test('workaround: replacing ESC[0m with ESC[0;48;2;bg;bg;bgm prevents corruption', async () => { + const term = await createIsolatedTerminal({ cols: 160, rows: 39, scrollback: 10_000_000 }); + const container = document.createElement('div'); + term.open(container); + + // Theme bg for dark terminal: (10, 10, 10) — the default #0a0a0a + const bgR = 10, bgG = 10, bgB = 10; + const resetReplacement = new TextEncoder().encode(`\x1b[0;48;2;${bgR};${bgG};${bgB}m`); + const resetSeq = new TextEncoder().encode('\x1b[0m'); + + // Patch: replace every ESC[0m with ESC[0;48;2;R;G;Bm in the data + function patchResets(src: Uint8Array): Uint8Array { + // Find all occurrences of ESC[0m (bytes: 1B 5B 30 6D) + const positions: number[] = []; + for (let i = 0; i < src.length - 3; i++) { + if (src[i] === 0x1B && src[i+1] === 0x5B && src[i+2] === 0x30 && src[i+3] === 0x6D) { + positions.push(i); + } + } + if (positions.length === 0) return src; + + const extra = resetReplacement.length - resetSeq.length; + const out = new Uint8Array(src.length + positions.length * extra); + let si = 0, di = 0; + for (const pos of positions) { + const chunk = src.subarray(si, pos); + out.set(chunk, di); + di += chunk.length; + out.set(resetReplacement, di); + di += resetReplacement.length; + si = pos + resetSeq.length; + } + const tail = src.subarray(si); + out.set(tail, di); + di += tail.length; + return out.subarray(0, di); + } + + const patched = patchResets(data); + console.log(`Original: ${data.length} bytes, patched: ${patched.length} bytes`); + + let baseline: string[] | null = null; + const corruptReps: number[] = []; + + for (let rep = 0; rep < 30; rep++) { + term.write(patched); + term.wasmTerm!.update(); + const text = getViewportText(term); + + if (!baseline) { baseline = text; continue; } + if (countDiffs(text, baseline) > 0) corruptReps.push(rep); + } + + console.log(`With workaround: corrupt at [${corruptReps.join(', ')}] (${corruptReps.length}/30)`); + expect(corruptReps.length).toBe(0); + + term.dispose(); + }); + + /** + * Both getViewport() and getLine() return the same wrong data, + * proving the corruption is in the WASM ring buffer, not the API layer. + */ + test('getViewport and getLine agree at the corrupt state', async () => { + const term = await createIsolatedTerminal({ cols: 160, rows: 39, scrollback: 10_000_000 }); + const container = document.createElement('div'); + term.open(container); + + let baseline: string[] | null = null; + + for (let rep = 0; rep < 30; rep++) { + term.write(data); + term.wasmTerm!.update(); + const text = getViewportText(term); + if (!baseline) { baseline = text; continue; } + if (countDiffs(text, baseline) > 0) break; // stop at first corruption + } + + // Compare APIs at whatever state we're in (corrupt or not) + const vpText = getViewportText(term); + let mismatches = 0; + for (let row = 0; row < term.rows; row++) { + const line = term.wasmTerm?.getLine(row); + if (!line) continue; + const lineText = line.map(c => String.fromCodePoint(c.codepoint || 32)).join('').trimEnd(); + if (vpText[row] !== lineText) mismatches++; + } + + expect(mismatches).toBe(0); + term.dispose(); + }); +});