Add failing repro: shared-atlas garble across terminals (vscode#322756)

Two terminals with equal configs share one TextureAtlas (CharAtlasCache).
When the atlas merges pages it raises a consume-once clear-model flag
(TextureAtlas.beginFrame); only the first terminal to render consumes it and
rebuilds its vertex model. Every other terminal keeps stale texture-page
indexes/coords and draws wrong glyphs on its next partial render even though
its buffer is intact.

The test churns terminal A to force merges, then gives terminal B a tiny
partial update: B's untouched header row garbles (signature diff ~130/255 vs
tolerance 14) while B's buffer still reads HEADER_REF. A DOM-renderer control
runs the same workload without GPU acceleration and passes: the corruption is
exclusive to the WebGL path.

RED on current code by design; the fix lands separately.
This commit is contained in:
Anthony Kim
2026-07-04 12:48:26 -07:00
parent a56dc02cc7
commit 3113eaefaa
@@ -0,0 +1,159 @@
/**
* Copyright (c) 2025 The xterm.js authors. All rights reserved.
* @license MIT
*
* Reproduces the vscode#322756 "garbled glyph" corruption across terminals that
* SHARE a texture atlas (CharAtlasCache hands the same TextureAtlas to every
* terminal with an equal config — in VS Code that is every integrated terminal
* using the same font settings, including task/background terminals).
*
* Mechanism: when the atlas merges pages it raises a consume-once
* "clear model" flag (TextureAtlas.beginFrame). Only the FIRST terminal to
* render consumes it and rebuilds its vertex model; every other terminal keeps
* stale texture-page indexes/coordinates in its vertex buffer and draws wrong
* glyphs on its next partial render, even though its buffer content is intact.
*
* The DOM-renderer control shows the corruption is exclusive to the GPU path
* (`terminal.integrated.gpuAcceleration off` cannot reproduce it: the DOM
* renderer has no texture atlas).
*
* Chromium only (additive scope; Firefox/WebKit WebGL broken in CI, xtermjs#5854).
*/
import test, { expect } from '@playwright/test';
import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils';
import {
captureRowSignatures,
expectSignatureMatches,
getAtlasPageCount,
loadWebglStrict,
setMaxAtlasPages,
writeAndWaitForRender
} from '../../../test/playwright/RendererTestUtils';
import { generateColoredAsciiFlood } from '../../../test/playwright/SyntheticTui';
const HEADER = ' HEADER_REF_0123456789';
const HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
const TERM_B_SELECTOR = '#terminal-container-b .xterm-screen';
/**
* Creates a second terminal ("termB") on the test page with the given options
* and optionally loads the WebGL addon on it. Identical rendering options mean
* CharAtlasCache gives it the same atlas instance as `window.term`.
*/
async function createTerminalB(ctx: ITestContext, options: { useWebgl: boolean }): Promise<void> {
await ctx.page.evaluate(async (useWebgl) => {
const w = window as any;
w.termB?.dispose();
document.getElementById('terminal-container-b')?.remove();
const el = document.createElement('div');
el.id = 'terminal-container-b';
document.body.appendChild(el);
w.termB = new w.Terminal({ cols: 80, rows: 24, allowProposedApi: true });
w.termB.open(el);
if (useWebgl) {
w.addonB = new w.WebglAddon({ preserveDrawingBuffer: true });
w.termB.loadAddon(w.addonB);
}
}, options.useWebgl);
}
/** Writes data to termB and resolves after its next render event. */
async function writeToBAndWaitForRender(ctx: ITestContext, data: string): Promise<void> {
await ctx.page.evaluate(d => new Promise<void>(resolve => {
const termB = (window as any).termB;
const disposable = termB.onRender(() => {
disposable.dispose();
resolve();
});
termB.write(d);
}), data);
}
async function getTermBRow1Text(ctx: ITestContext): Promise<string | undefined> {
return ctx.page.evaluate(() => (window as any).termB.buffer.active.getLine(0)?.translateToString(true));
}
test.describe('shared-atlas garble across terminals (#322756)', () => {
test.skip(({ browserName }) => browserName !== 'chromium');
test.describe.configure({ timeout: 60000 });
test('terminal B keeps rendering its header correctly while terminal A forces atlas merges', async ({ browser }) => {
const ctx: ITestContext = await createTestContext(browser);
try {
await openTerminal(ctx, { cols: 80, rows: 24 });
await loadWebglStrict(ctx);
await createTerminalB(ctx, { useWebgl: true });
// Both terminals must be using the same atlas instance for this test to
// mean anything.
const atlasShared = await ctx.page.evaluate(() => {
const w = window as any;
const atlasA = w.term._core._renderService._renderer.value._charAtlas;
const atlasB = w.termB._core._renderService._renderer.value._charAtlas;
return !!atlasA && atlasA === atlasB;
});
expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true);
// Lower the cap AFTER both renderers exist so their shaders/texture
// arrays keep the original size (this test targets stale vertex data,
// not the page-overflow crash).
await setMaxAtlasPages(ctx, 4);
// Render B's header pre-merge and capture its reference pixels.
await writeToBAndWaitForRender(ctx, HEADER);
await ctx.page.locator(TERM_B_SELECTOR).screenshot({ path: 'out-esbuild-test/playwright/termB-before.png' });
const reference = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' });
// Churn terminal A with distinct colored glyphs to force page merges in
// the SHARED atlas. A repairs itself (it consumes the clear-model flag);
// B is never told.
for (let c = 0; c < 10; c++) {
await writeAndWaitForRender(ctx, '\x1b[2;1H' + generateColoredAsciiFlood(23 * 78, c * 23 * 78));
}
const pages = await getAtlasPageCount(ctx);
expect(pages, 'churn should have exercised multiple atlas pages').toBeGreaterThan(1);
// A tiny unrelated update to B's LAST row triggers a partial render in B.
// B's header row is untouched; with a healthy renderer its pixels must
// not change.
await writeToBAndWaitForRender(ctx, '\x1b[24;1Hx');
await ctx.page.locator(TERM_B_SELECTOR).screenshot({ path: 'out-esbuild-test/playwright/termB-after.png' });
// Guard: B's buffer content is intact, so any pixel change is renderer
// corruption by construction.
expect(await getTermBRow1Text(ctx), 'terminal B buffer must still contain the header').toBe(HEADER);
const after = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' });
for (let i = 0; i < HEADER_COLS.length; i++) {
expectSignatureMatches(after[i], reference[i], 14, `terminal B header col ${HEADER_COLS[i]} garbled by terminal A's atlas merges`);
}
} finally {
await ctx.page.close();
}
});
test('DOM renderer control: same workload cannot garble terminal B (no atlas without GPU acceleration)', async ({ browser }) => {
const ctx: ITestContext = await createTestContext(browser);
try {
// No WebGL addon on either terminal -> DOM renderer -> no texture atlas.
await openTerminal(ctx, { cols: 80, rows: 24 });
await createTerminalB(ctx, { useWebgl: false });
await writeToBAndWaitForRender(ctx, HEADER);
const reference = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' });
for (let c = 0; c < 10; c++) {
await writeAndWaitForRender(ctx, '\x1b[2;1H' + generateColoredAsciiFlood(23 * 78, c * 23 * 78));
}
await writeToBAndWaitForRender(ctx, '\x1b[24;1Hx');
expect(await getTermBRow1Text(ctx), 'terminal B buffer must still contain the header').toBe(HEADER);
const after = await captureRowSignatures(ctx, 1, HEADER_COLS, { selector: TERM_B_SELECTOR, termGlobal: 'termB' });
for (let i = 0; i < HEADER_COLS.length; i++) {
expectSignatureMatches(after[i], reference[i], 14, `DOM-rendered terminal B header col ${HEADER_COLS[i]} unexpectedly changed`);
}
} finally {
await ctx.page.close();
}
});
});