Skip to content
Merged
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('');

Check warning on line 37 in lib/iris-repro-final.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not call `Array#push()` multiple times.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5XSdvt6Ms3vxq6Gyu_&open=AZ5XSdvt6Ms3vxq6Gyu_&pullRequest=19

// Section 1: 256-color palette blocks (8 rows of 32 colors)
lines.push(`${ESC}[1m── COLORS ──${ESC}[0m`);

Check warning on line 40 in lib/iris-repro-final.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not call `Array#push()` multiple times.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5XSdvt6Ms3vxq6GyvA&open=AZ5XSdvt6Ms3vxq6GyvA&pullRequest=19
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`);

Check warning on line 65 in lib/iris-repro-final.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not call `Array#push()` multiple times.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5XSdvt6Ms3vxq6GyvB&open=AZ5XSdvt6Ms3vxq6GyvB&pullRequest=19

// Section 4: Unicode box drawing
lines.push(`${ESC}[1m── UNICODE ──${ESC}[0m`);

Check warning on line 68 in lib/iris-repro-final.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not call `Array#push()` multiple times.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5XSdvt6Ms3vxq6GyvC&open=AZ5XSdvt6Ms3vxq6GyvC&pullRequest=19
lines.push(' ┌──────────┬──────────┐');

Check warning on line 69 in lib/iris-repro-final.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not call `Array#push()` multiple times.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5XSdvt6Ms3vxq6GyvD&open=AZ5XSdvt6Ms3vxq6GyvD&pullRequest=19
lines.push(' │ Cell A │ Cell B │');

Check warning on line 70 in lib/iris-repro-final.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not call `Array#push()` multiple times.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5XSdvt6Ms3vxq6GyvE&open=AZ5XSdvt6Ms3vxq6GyvE&pullRequest=19
lines.push(' ├──────────┼──────────┤');

Check warning on line 71 in lib/iris-repro-final.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not call `Array#push()` multiple times.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5XSdvt6Ms3vxq6GyvF&open=AZ5XSdvt6Ms3vxq6GyvF&pullRequest=19
lines.push(' │ Cell C │ Cell D │');
lines.push(' └──────────┴──────────┘');

Check warning on line 73 in lib/iris-repro-final.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not call `Array#push()` multiple times.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5XSdvt6Ms3vxq6GyvH&open=AZ5XSdvt6Ms3vxq6GyvH&pullRequest=19

// 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));

Check warning on line 90 in lib/iris-repro-final.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not call `Array#push()` multiple times.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5XSdvt6Ms3vxq6GyvI&open=AZ5XSdvt6Ms3vxq6GyvI&pullRequest=19
lines.push(' ✓ Test complete');
lines.push('═'.repeat(80));

Check warning on line 92 in lib/iris-repro-final.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not call `Array#push()` multiple times.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5XSdvt6Ms3vxq6GyvK&open=AZ5XSdvt6Ms3vxq6GyvK&pullRequest=19
lines.push('');

Check warning on line 93 in lib/iris-repro-final.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not call `Array#push()` multiple times.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5XSdvt6Ms3vxq6GyvL&open=AZ5XSdvt6Ms3vxq6GyvL&pullRequest=19

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[] = [];

Check warning on line 222 in lib/iris-repro-final.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Either use this collection's contents or remove the collection.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5XSdvt6Ms3vxq6GyvO&open=AZ5XSdvt6Ms3vxq6GyvO&pullRequest=19
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; }

Check warning on line 237 in lib/iris-repro-final.test.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected negated condition.

See more on https://sonarcloud.io/project/issues?id=diegosouzapw_ghostty-web&issues=AZ5XSdvt6Ms3vxq6GyvP&open=AZ5XSdvt6Ms3vxq6GyvP&pullRequest=19
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