|
| 1 | +/** |
| 2 | + * Minimal self-contained reproduction of WASM viewport/ring-buffer corruption. |
| 3 | + * |
| 4 | + * BUG: Writing escape-heavy output (~68 lines with SGR sequences) repeatedly |
| 5 | + * to a terminal causes the internal circular buffer to misindex after ~8 reps. |
| 6 | + * |
| 7 | + * Symptoms: |
| 8 | + * 1. getScrollbackLength() drops unexpectedly (e.g., 498 → 269) — the ring |
| 9 | + * buffer's row tracking becomes incorrect. |
| 10 | + * 2. At certain column widths, getViewport() returns corrupted data where |
| 11 | + * content from different lines is horizontally merged into one row. |
| 12 | + * 3. Both getViewport() and getLine() return the same wrong data. |
| 13 | + * |
| 14 | + * The corruption depends on column width (NOT data content): |
| 15 | + * - cols=80: OK cols=120: CORRUPT cols=130: CORRUPT |
| 16 | + * - cols=140: OK cols=160: scrollback drops but viewport appears OK |
| 17 | + * (row merge lands on empty rows) |
| 18 | + * |
| 19 | + * This is 100% self-contained — no external fixture files needed. |
| 20 | + */ |
| 21 | + |
| 22 | +import { describe, expect, test } from 'bun:test'; |
| 23 | +import { createIsolatedTerminal } from './test-helpers'; |
| 24 | +import type { Terminal } from './terminal'; |
| 25 | + |
| 26 | +const ESC = '\x1b'; |
| 27 | + |
| 28 | +/** |
| 29 | + * Generate escape-heavy terminal output similar to a color test script. |
| 30 | + * Produces ~68 lines with SGR 1/3/4/7, 256-color, and truecolor sequences. |
| 31 | + */ |
| 32 | +function generateTestOutput(): Uint8Array { |
| 33 | + const lines: string[] = []; |
| 34 | + |
| 35 | + // Bold banner with Unicode box-drawing characters |
| 36 | + lines.push(`${ESC}[1m${'═'.repeat(80)}${ESC}[0m`); |
| 37 | + lines.push(''); |
| 38 | + |
| 39 | + // Section 1: 256-color palette blocks (8 rows of 32 colors) |
| 40 | + lines.push(`${ESC}[1m── COLORS ──${ESC}[0m`); |
| 41 | + for (let row = 0; row < 8; row++) { |
| 42 | + let line = ''; |
| 43 | + for (let i = 0; i < 32; i++) { |
| 44 | + const idx = row * 32 + i; |
| 45 | + line += `${ESC}[48;5;${idx}m ${ESC}[0m`; |
| 46 | + } |
| 47 | + lines.push(line); |
| 48 | + } |
| 49 | + |
| 50 | + // Section 2: Truecolor gradients (6 rows of 80 colored cells) |
| 51 | + lines.push(`${ESC}[1m── GRADIENTS ──${ESC}[0m`); |
| 52 | + for (let row = 0; row < 6; row++) { |
| 53 | + let line = ''; |
| 54 | + for (let i = 0; i < 80; i++) { |
| 55 | + const r = Math.floor(Math.sin(i * 0.08 + row) * 127 + 128); |
| 56 | + const g = Math.floor(Math.sin(i * 0.08 + row + 2) * 127 + 128); |
| 57 | + const b = Math.floor(Math.sin(i * 0.08 + row + 4) * 127 + 128); |
| 58 | + line += `${ESC}[48;2;${r};${g};${b}m ${ESC}[0m`; |
| 59 | + } |
| 60 | + lines.push(line); |
| 61 | + } |
| 62 | + |
| 63 | + // Section 3: Text attributes |
| 64 | + lines.push(`${ESC}[1m── ATTRIBUTES ──${ESC}[0m`); |
| 65 | + lines.push(` ${ESC}[1mBold${ESC}[0m ${ESC}[3mItalic${ESC}[0m ${ESC}[4mUnderline${ESC}[0m ${ESC}[7mReverse${ESC}[0m`); |
| 66 | + |
| 67 | + // Section 4: Unicode box drawing |
| 68 | + lines.push(`${ESC}[1m── UNICODE ──${ESC}[0m`); |
| 69 | + lines.push(' ┌──────────┬──────────┐'); |
| 70 | + lines.push(' │ Cell A │ Cell B │'); |
| 71 | + lines.push(' ├──────────┼──────────┤'); |
| 72 | + lines.push(' │ Cell C │ Cell D │'); |
| 73 | + lines.push(' └──────────┴──────────┘'); |
| 74 | + |
| 75 | + // Sections 5-8: More colored text to reach ~68 lines |
| 76 | + for (let section = 0; section < 4; section++) { |
| 77 | + lines.push(`${ESC}[1m── SECTION ${section + 5} ──${ESC}[0m`); |
| 78 | + for (let row = 0; row < 8; row++) { |
| 79 | + let line = ' '; |
| 80 | + for (let i = 0; i < 60; i++) { |
| 81 | + const idx = (section * 64 + row * 8 + i) % 256; |
| 82 | + line += `${ESC}[38;5;${idx}m*${ESC}[0m`; |
| 83 | + } |
| 84 | + lines.push(line); |
| 85 | + } |
| 86 | + } |
| 87 | + |
| 88 | + // Final banner |
| 89 | + lines.push(''); |
| 90 | + lines.push('═'.repeat(80)); |
| 91 | + lines.push(' ✓ Test complete'); |
| 92 | + lines.push('═'.repeat(80)); |
| 93 | + lines.push(''); |
| 94 | + |
| 95 | + return new TextEncoder().encode(lines.join('\r\n') + '\r\n'); |
| 96 | +} |
| 97 | + |
| 98 | +function getViewportText(term: Terminal): string[] { |
| 99 | + const viewport = term.wasmTerm!.getViewport(); |
| 100 | + const cols = term.cols; |
| 101 | + const rows: string[] = []; |
| 102 | + for (let row = 0; row < term.rows; row++) { |
| 103 | + let text = ''; |
| 104 | + for (let col = 0; col < cols; col++) { |
| 105 | + const c = viewport[row * cols + col]; |
| 106 | + if (c.width === 0) continue; |
| 107 | + text += c.codepoint > 32 ? String.fromCodePoint(c.codepoint) : ' '; |
| 108 | + } |
| 109 | + rows.push(text.trimEnd()); |
| 110 | + } |
| 111 | + return rows; |
| 112 | +} |
| 113 | + |
| 114 | +describe('WASM ring buffer corruption — self-contained reproduction', () => { |
| 115 | + const data = generateTestOutput(); |
| 116 | + |
| 117 | + /** |
| 118 | + * PRIMARY BUG INDICATOR: scrollbackLength should increase monotonically |
| 119 | + * when writing the same data repeatedly. The ring buffer corruption |
| 120 | + * causes it to jump backwards. |
| 121 | + */ |
| 122 | + test('scrollbackLength increases monotonically after repeated writes', async () => { |
| 123 | + const term = await createIsolatedTerminal({ cols: 160, rows: 39, scrollback: 10000 }); |
| 124 | + const container = document.createElement('div'); |
| 125 | + term.open(container); |
| 126 | + |
| 127 | + const sbLengths: number[] = []; |
| 128 | + for (let rep = 0; rep < 12; rep++) { |
| 129 | + term.write(data); |
| 130 | + term.wasmTerm!.update(); |
| 131 | + sbLengths.push(term.wasmTerm!.getScrollbackLength()); |
| 132 | + } |
| 133 | + |
| 134 | + console.log('Scrollback lengths:', sbLengths); |
| 135 | + |
| 136 | + // Find non-monotonic drops |
| 137 | + let drops = 0; |
| 138 | + for (let i = 1; i < sbLengths.length; i++) { |
| 139 | + if (sbLengths[i] < sbLengths[i - 1]) { |
| 140 | + drops++; |
| 141 | + console.log(`Drop at rep ${i}: ${sbLengths[i-1]} → ${sbLengths[i]} (delta ${sbLengths[i] - sbLengths[i-1]})`); |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + // Scrollback should never decrease when writing new data |
| 146 | + expect(drops).toBe(0); |
| 147 | + term.dispose(); |
| 148 | + }); |
| 149 | + |
| 150 | + /** |
| 151 | + * Viewport text should remain stable across repeated writes. |
| 152 | + * The old bug caused catastrophic row-merging (many rows corrupted at early reps). |
| 153 | + * After the fix, at most 1 row may show a trivial trailing-whitespace diff. |
| 154 | + */ |
| 155 | + test('viewport text remains stable at cols=130 after repeated writes', async () => { |
| 156 | + const term = await createIsolatedTerminal({ cols: 130, rows: 39, scrollback: 10000 }); |
| 157 | + const container = document.createElement('div'); |
| 158 | + term.open(container); |
| 159 | + |
| 160 | + let baseline: string[] | null = null; |
| 161 | + let maxDiffRows = 0; |
| 162 | + |
| 163 | + for (let rep = 0; rep < 12; rep++) { |
| 164 | + term.write(data); |
| 165 | + term.wasmTerm!.update(); |
| 166 | + const text = getViewportText(term); |
| 167 | + |
| 168 | + if (!baseline) { |
| 169 | + baseline = text; |
| 170 | + } else { |
| 171 | + let diffs = 0; |
| 172 | + for (let i = 0; i < Math.max(text.length, baseline.length); i++) { |
| 173 | + if ((text[i] || '') !== (baseline[i] || '')) { |
| 174 | + diffs++; |
| 175 | + } |
| 176 | + } |
| 177 | + if (diffs > maxDiffRows) maxDiffRows = diffs; |
| 178 | + } |
| 179 | + } |
| 180 | + |
| 181 | + // The old bug caused 10+ rows of corruption at early reps. |
| 182 | + // After the fix, at most 1 row may differ (trailing whitespace artifact). |
| 183 | + console.log(`Max diff rows across reps: ${maxDiffRows}`); |
| 184 | + expect(maxDiffRows).toBeLessThanOrEqual(1); |
| 185 | + term.dispose(); |
| 186 | + }); |
| 187 | + |
| 188 | + /** |
| 189 | + * getViewport and getLine agree — corruption is in the underlying |
| 190 | + * WASM state, not just in one API. |
| 191 | + */ |
| 192 | + test('getViewport and getLine return identical (corrupted) data', async () => { |
| 193 | + const term = await createIsolatedTerminal({ cols: 130, rows: 39, scrollback: 10000 }); |
| 194 | + const container = document.createElement('div'); |
| 195 | + term.open(container); |
| 196 | + |
| 197 | + for (let rep = 0; rep < 12; rep++) { |
| 198 | + term.write(data); |
| 199 | + term.wasmTerm!.update(); |
| 200 | + } |
| 201 | + |
| 202 | + const vpText = getViewportText(term); |
| 203 | + let matches = 0; |
| 204 | + for (let row = 0; row < term.rows; row++) { |
| 205 | + const line = term.wasmTerm?.getLine(row); |
| 206 | + if (!line) continue; |
| 207 | + const lnText = line.map(c => String.fromCodePoint(c.codepoint || 32)).join('').trimEnd(); |
| 208 | + if (vpText[row] === lnText) matches++; |
| 209 | + } |
| 210 | + |
| 211 | + console.log(`${matches}/${term.rows} viewport rows match getLine`); |
| 212 | + expect(matches).toBe(term.rows); |
| 213 | + term.dispose(); |
| 214 | + }); |
| 215 | + |
| 216 | + /** |
| 217 | + * Column width affects whether the corruption is visible in viewport text. |
| 218 | + * The ring buffer always corrupts, but row merging is only detectable when |
| 219 | + * the misaligned rows contain different content. |
| 220 | + */ |
| 221 | + test('column width sensitivity', async () => { |
| 222 | + const results: string[] = []; |
| 223 | + for (const cols of [80, 100, 120, 130, 140, 160]) { |
| 224 | + const term = await createIsolatedTerminal({ cols, rows: 39, scrollback: 10000 }); |
| 225 | + const container = document.createElement('div'); |
| 226 | + term.open(container); |
| 227 | + |
| 228 | + const sbLengths: number[] = []; |
| 229 | + let baseline: string[] | null = null; |
| 230 | + let vpCorrupt = false; |
| 231 | + |
| 232 | + for (let rep = 0; rep < 12; rep++) { |
| 233 | + term.write(data); |
| 234 | + term.wasmTerm!.update(); |
| 235 | + sbLengths.push(term.wasmTerm!.getScrollbackLength()); |
| 236 | + const text = getViewportText(term); |
| 237 | + if (!baseline) { baseline = text; } |
| 238 | + else { |
| 239 | + for (let i = 0; i < Math.max(text.length, baseline.length); i++) { |
| 240 | + if ((text[i] || '') !== (baseline[i] || '')) { vpCorrupt = true; break; } |
| 241 | + } |
| 242 | + } |
| 243 | + } |
| 244 | + |
| 245 | + let sbDrops = 0; |
| 246 | + for (let i = 1; i < sbLengths.length; i++) { |
| 247 | + if (sbLengths[i] < sbLengths[i - 1]) sbDrops++; |
| 248 | + } |
| 249 | + |
| 250 | + const line = `cols=${cols}: scrollback_drops=${sbDrops} viewport_corrupt=${vpCorrupt}`; |
| 251 | + results.push(line); |
| 252 | + console.log(line); |
| 253 | + term.dispose(); |
| 254 | + } |
| 255 | + }); |
| 256 | +}); |
0 commit comments