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(); + }); +}); 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