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
194 changes: 194 additions & 0 deletions lib/viewport-corruption.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
/**
* Regression coverage for stale cells becoming visible after scroll growth.
*
* The bug requires ESC[0m reset-heavy output, default cursor background,
* repeated scrolls, and rows that are not fully overwritten.
*/

import { describe, expect, test } from 'bun:test';
import type { Terminal } from './terminal';
import { createIsolatedTerminal } from './test-helpers';
import type { GhosttyCell } from './types';

const ESC = '\x1b';
const MARKER_PATTERN = /R\d{2}L\d{3}/g;

function buildStressPayload(): Uint8Array {
const lines: string[] = [];
let lineNumber = 0;

const pushMarkedLine = (content: string): void => {
const marker = `R00L${lineNumber.toString().padStart(3, '0')}`;
lines.push(`${ESC}[38;5;${(lineNumber * 17) % 256}m${marker}${ESC}[0m ${content}`);
lineNumber++;
};

const pushBlankLine = (): void => {
lines.push('');
lineNumber++;
};

pushMarkedLine(`${ESC}[1m${'═'.repeat(72)}${ESC}[0m`);
pushMarkedLine(`${ESC}[1mTerminal rendering scroll-growth regression${ESC}[0m`);
pushBlankLine();

pushMarkedLine(`${ESC}[1m256-color palette${ESC}[0m`);
for (let row = 0; row < 8; row++) {
let content = '';
for (let i = 0; i < 32; i++) {
content += `${ESC}[48;5;${row * 32 + i}m ${ESC}[0m`;
}
pushMarkedLine(content);
}
pushBlankLine();

pushMarkedLine(`${ESC}[1mTruecolor gradients${ESC}[0m`);
for (let row = 0; row < 8; row++) {
let content = '';
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);
content += `${ESC}[48;2;${r};${g};${b}m ${ESC}[0m`;
}
pushMarkedLine(content);
}
pushBlankLine();

pushMarkedLine(`${ESC}[1mCombined styles${ESC}[0m`);
pushMarkedLine(
`${ESC}[1mBold${ESC}[0m ${ESC}[3mItalic${ESC}[0m ${ESC}[4mUnderline${ESC}[0m ${ESC}[7mReverse${ESC}[0m ${ESC}[9mStrike${ESC}[0m`
);
pushMarkedLine(
`${ESC}[1;31mBold Red${ESC}[0m ${ESC}[3;32mItalic Green${ESC}[0m ${ESC}[4;34mUnderline Blue${ESC}[0m ${ESC}[38;5;201mPalette Pink${ESC}[0m`
);
pushBlankLine();

pushMarkedLine(`${ESC}[1mSoft-wrap metadata seed${ESC}[0m`);
pushMarkedLine('wrap '.repeat(48));
pushBlankLine();

pushMarkedLine(`${ESC}[1mUnicode box drawing${ESC}[0m`);
for (const line of [
'┌──────────┬──────────┬──────────┐',
'│ Cell A │ Cell B │ Cell C │',
'├──────────┼──────────┼──────────┤',
'│ Cell D │ Cell E │ Cell F │',
'└──────────┴──────────┴──────────┘',
]) {
pushMarkedLine(line);
}
pushBlankLine();

for (let section = 0; section < 8; section++) {
pushMarkedLine(`${ESC}[1mColor grid ${String.fromCharCode(65 + section)}${ESC}[0m`);
for (let row = 0; row < 8; row++) {
let content = '';
for (let i = 0; i < 70; i++) {
const idx = (section * 64 + row * 8 + i) % 256;
content +=
(i + row) % 3 === 0
? `${ESC}[38;2;${(idx * 7) % 256};${(idx * 13) % 256};${(idx * 23) % 256}m*${ESC}[0m`
: `${ESC}[38;5;${idx}m*${ESC}[0m`;
}
pushMarkedLine(content);
}
pushBlankLine();
}

pushMarkedLine(`${ESC}[1m${'═'.repeat(72)}${ESC}[0m`);
pushMarkedLine(`${ESC}[32m✓${ESC}[0m Test complete`);
pushBlankLine();

const text = lines.join('\r\n') + '\r\n';
expect(text.match(/\x1b\[0m/g)?.length ?? 0).toBeGreaterThan(100);
expect(text).toContain('\r\n\r\n');

const data = new TextEncoder().encode(text);
expect(data.length).toBeGreaterThan(20_000);
return data;
}

function cellsToText(cells: GhosttyCell[]): string {
return cells
.filter((cell) => cell.width !== 0)
.map((cell) => String.fromCodePoint(cell.codepoint > 32 ? cell.codepoint : 32))
.join('')
.trimEnd();
}

function getViewportText(term: Terminal): string[] {
expect(term.wasmTerm).toBeDefined();
const viewport = term.wasmTerm!.getViewport();
const rows: string[] = [];

for (let row = 0; row < term.rows; row++) {
const start = row * term.cols;
rows.push(cellsToText(viewport.slice(start, start + term.cols)));
}

return rows;
}

function getLineText(term: Terminal, row: number): string {
expect(term.wasmTerm).toBeDefined();
const line = term.wasmTerm!.getLine(row);
expect(line).not.toBeNull();
return cellsToText(line!);
}

describe('viewport scroll-growth corruption', () => {
test('does not expose stale cells after ESC[0m reset-heavy scrolling', async () => {
const data = buildStressPayload();
const term = await createIsolatedTerminal({ cols: 160, rows: 39, scrollback: 10_000_000 });
const container = document.createElement('div');
document.body.appendChild(container);

try {
term.open(container);
let baseline: string[] | null = null;

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

for (let row = 0; row < term.rows; row++) {
const viewportText = rows[row];
const lineText = getLineText(term, row);
expect(viewportText).toBe(lineText);

if (viewportText.length === 0) {
expect(term.wasmTerm!.isRowWrapped(row)).toBe(false);
}

const markers = viewportText.match(MARKER_PATTERN) ?? [];
const uniqueMarkers = new Set(markers);
if (uniqueMarkers.size > 1) {
throw new Error(
`Rep ${rep}, row ${row} contains merged markers: ${[...uniqueMarkers].join(', ')}\n` +
`Row content: ${JSON.stringify(viewportText)}`
);
}
}

if (baseline === null) {
baseline = rows;
continue;
}

const changedRow = rows.findIndex((rowText, row) => rowText !== baseline![row]);
if (changedRow !== -1) {
throw new Error(
`Rep ${rep}, row ${changedRow} differs from the stable viewport baseline\n` +
`Expected: ${JSON.stringify(baseline[changedRow])}\n` +
`Received: ${JSON.stringify(rows[changedRow])}`
);
}
}
} finally {
term.dispose();
document.body.removeChild(container);
}
});
});
29 changes: 29 additions & 0 deletions patches/ghostty-wasm-api.patch
Original file line number Diff line number Diff line change
Expand Up @@ -1564,6 +1564,35 @@ 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
@@ -851,10 +851,19 @@ pub fn cursorDownScroll(self: *Screen) !void {
- // 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, such as after
+ // bare CRLF output with the default cursor style.
+ {
const page: *Page = &page_pin.node.data;
+ const row = self.cursor.page_row;
+ const cells_offset = row.cells;
+ const dirty = row.dirty;
self.clearCells(
page,
- self.cursor.page_row,
- page.getCells(self.cursor.page_row),
+ row,
+ page.getCells(row),
);
Comment thread
ThomasK33 marked this conversation as resolved.
+ // Reset row-level metadata that clearCells does not touch while
+ // preserving the backing cell offset and dirty state.
+ row.* = .{ .cells = cells_offset, .dirty = dirty };
}
diff --git a/src/terminal/render.zig b/src/terminal/render.zig
index b6430ea34..10e0ef79d 100644
--- a/src/terminal/render.zig
Expand Down
Loading