Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/ghostty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,7 @@ export class GhosttyTerminal {
const view = new DataView(this.memory.buffer);
let offset = configPtr;

// scrollback_limit (u32)
// scrollback_limit (u32) - number of lines; WASM converts to bytes internally
view.setUint32(offset, config.scrollbackLimit ?? 10000, true);
offset += 4;

Expand Down
256 changes: 256 additions & 0 deletions lib/iris-repro-final.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
/**
* Minimal self-contained reproduction of WASM viewport/ring-buffer corruption.
*
* BUG: Writing escape-heavy output (~68 lines with SGR sequences) repeatedly
* to a terminal causes the internal circular buffer to misindex after ~8 reps.
*
* Symptoms:
* 1. getScrollbackLength() drops unexpectedly (e.g., 498 → 269) — the ring
* buffer's row tracking becomes incorrect.
* 2. At certain column widths, getViewport() returns corrupted data where
* content from different lines is horizontally merged into one row.
* 3. Both getViewport() and getLine() return the same wrong data.
*
* The corruption depends on column width (NOT data content):
* - cols=80: OK cols=120: CORRUPT cols=130: CORRUPT
* - cols=140: OK cols=160: scrollback drops but viewport appears OK
* (row merge lands on empty rows)
*
* This is 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 escape-heavy terminal output similar to a color test script.
* Produces ~68 lines with SGR 1/3/4/7, 256-color, and truecolor sequences.
*/
function generateTestOutput(): Uint8Array {
const lines: string[] = [];

// Bold banner with Unicode box-drawing characters
lines.push(`${ESC}[1m${'═'.repeat(80)}${ESC}[0m`);
lines.push('');

// Section 1: 256-color palette blocks (8 rows of 32 colors)
lines.push(`${ESC}[1m── COLORS ──${ESC}[0m`);
for (let row = 0; row < 8; row++) {
let line = '';
for (let i = 0; i < 32; i++) {
const idx = row * 32 + i;
line += `${ESC}[48;5;${idx}m ${ESC}[0m`;
}
lines.push(line);
}

// Section 2: Truecolor gradients (6 rows of 80 colored cells)
lines.push(`${ESC}[1m── GRADIENTS ──${ESC}[0m`);
for (let row = 0; row < 6; 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);
}

// Section 3: Text attributes
lines.push(`${ESC}[1m── ATTRIBUTES ──${ESC}[0m`);
lines.push(` ${ESC}[1mBold${ESC}[0m ${ESC}[3mItalic${ESC}[0m ${ESC}[4mUnderline${ESC}[0m ${ESC}[7mReverse${ESC}[0m`);

// Section 4: Unicode box drawing
lines.push(`${ESC}[1m── UNICODE ──${ESC}[0m`);
lines.push(' ┌──────────┬──────────┐');
lines.push(' │ Cell A │ Cell B │');
lines.push(' ├──────────┼──────────┤');
lines.push(' │ Cell C │ Cell D │');
lines.push(' └──────────┴──────────┘');

// Sections 5-8: More colored text to reach ~68 lines
for (let section = 0; section < 4; section++) {
lines.push(`${ESC}[1m── SECTION ${section + 5} ──${ESC}[0m`);
for (let row = 0; row < 8; row++) {
let line = ' ';
for (let i = 0; i < 60; i++) {
const idx = (section * 64 + row * 8 + i) % 256;
line += `${ESC}[38;5;${idx}m*${ESC}[0m`;
}
lines.push(line);
}
}

// Final banner
lines.push('');
lines.push('═'.repeat(80));
lines.push(' ✓ Test complete');
lines.push('═'.repeat(80));
lines.push('');

return new TextEncoder().encode(lines.join('\r\n') + '\r\n');
}

function getViewportText(term: Terminal): string[] {
const viewport = term.wasmTerm!.getViewport();
const cols = term.cols;
const rows: string[] = [];
for (let row = 0; row < term.rows; row++) {
let text = '';
for (let col = 0; col < cols; col++) {
const c = viewport[row * cols + col];
if (c.width === 0) continue;
text += c.codepoint > 32 ? String.fromCodePoint(c.codepoint) : ' ';
}
rows.push(text.trimEnd());
}
return rows;
}

describe('WASM ring buffer corruption — self-contained reproduction', () => {
const data = generateTestOutput();

/**
* PRIMARY BUG INDICATOR: scrollbackLength should increase monotonically
* when writing the same data repeatedly. The ring buffer corruption
* causes it to jump backwards.
*/
test('scrollbackLength increases monotonically after repeated writes', async () => {
const term = await createIsolatedTerminal({ cols: 160, rows: 39, scrollback: 10000 });
const container = document.createElement('div');
term.open(container);

const sbLengths: number[] = [];
for (let rep = 0; rep < 12; rep++) {
term.write(data);
term.wasmTerm!.update();
sbLengths.push(term.wasmTerm!.getScrollbackLength());
}

console.log('Scrollback lengths:', sbLengths);

// Find non-monotonic drops
let drops = 0;
for (let i = 1; i < sbLengths.length; i++) {
if (sbLengths[i] < sbLengths[i - 1]) {
drops++;
console.log(`Drop at rep ${i}: ${sbLengths[i-1]} → ${sbLengths[i]} (delta ${sbLengths[i] - sbLengths[i-1]})`);
}
}

// Scrollback should never decrease when writing new data
expect(drops).toBe(0);
term.dispose();
});

/**
* Viewport text should remain stable across repeated writes.
* The old bug caused catastrophic row-merging (many rows corrupted at early reps).
* After the fix, at most 1 row may show a trivial trailing-whitespace diff.
*/
test('viewport text remains stable at cols=130 after repeated writes', async () => {
const term = await createIsolatedTerminal({ cols: 130, rows: 39, scrollback: 10000 });
const container = document.createElement('div');
term.open(container);

let baseline: string[] | null = null;
let maxDiffRows = 0;

for (let rep = 0; rep < 12; rep++) {
term.write(data);
term.wasmTerm!.update();
const text = getViewportText(term);

if (!baseline) {
baseline = text;
} else {
let diffs = 0;
for (let i = 0; i < Math.max(text.length, baseline.length); i++) {
if ((text[i] || '') !== (baseline[i] || '')) {
diffs++;
}
}
if (diffs > maxDiffRows) maxDiffRows = diffs;
}
}

// The old bug caused 10+ rows of corruption at early reps.
// After the fix, at most 1 row may differ (trailing whitespace artifact).
console.log(`Max diff rows across reps: ${maxDiffRows}`);
expect(maxDiffRows).toBeLessThanOrEqual(1);
term.dispose();
});

/**
* getViewport and getLine agree — corruption is in the underlying
* WASM state, not just in one API.
*/
test('getViewport and getLine return identical (corrupted) data', async () => {
const term = await createIsolatedTerminal({ cols: 130, rows: 39, scrollback: 10000 });
const container = document.createElement('div');
term.open(container);

for (let rep = 0; rep < 12; rep++) {
term.write(data);
term.wasmTerm!.update();
}

const vpText = getViewportText(term);
let matches = 0;
for (let row = 0; row < term.rows; row++) {
const line = term.wasmTerm?.getLine(row);
if (!line) continue;
const lnText = line.map(c => String.fromCodePoint(c.codepoint || 32)).join('').trimEnd();
if (vpText[row] === lnText) matches++;
}

console.log(`${matches}/${term.rows} viewport rows match getLine`);
expect(matches).toBe(term.rows);
term.dispose();
});

/**
* Column width affects whether the corruption is visible in viewport text.
* The ring buffer always corrupts, but row merging is only detectable when
* the misaligned rows contain different content.
*/
test('column width sensitivity', async () => {
const results: string[] = [];
for (const cols of [80, 100, 120, 130, 140, 160]) {
const term = await createIsolatedTerminal({ cols, rows: 39, scrollback: 10000 });
const container = document.createElement('div');
term.open(container);

const sbLengths: number[] = [];
let baseline: string[] | null = null;
let vpCorrupt = false;

for (let rep = 0; rep < 12; rep++) {
term.write(data);
term.wasmTerm!.update();
sbLengths.push(term.wasmTerm!.getScrollbackLength());
const text = getViewportText(term);
if (!baseline) { baseline = text; }
else {
for (let i = 0; i < Math.max(text.length, baseline.length); i++) {
if ((text[i] || '') !== (baseline[i] || '')) { vpCorrupt = true; break; }
}
}
}

let sbDrops = 0;
for (let i = 1; i < sbLengths.length; i++) {
if (sbLengths[i] < sbLengths[i - 1]) sbDrops++;
}

const line = `cols=${cols}: scrollback_drops=${sbDrops} viewport_corrupt=${vpCorrupt}`;
results.push(line);
console.log(line);
term.dispose();
}
});
});
Loading