From 8481bfd8dd374672378491f17fbe05b9e8ef8f13 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Sat, 4 Jul 2026 12:58:33 -0700 Subject: [PATCH] Add failing repro: atlas page overflow crashes GlyphRenderer.render MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GlyphRenderer sizes its GL texture array and fragment-shader sampler array to exactly TextureAtlas.maxAtlasPages at construction, but _createNewPage pushes past that cap whenever a 4-page same-size merge is impossible (heterogeneous sizes, e.g. 1024+512+512+512). The next render throws TypeError: Cannot read properties of undefined (reading 'version') and rendering freezes — the single-terminal breakage vector for long heavy-CLI sessions (vscode#322756). The test builds the renderer with maxAtlasPages=4 (same code path as the production 8/16/32 values), churns distinct glyphs, and asserts no pageerror, pages.length <= texture capacity, and an intact pinned header. RED today: 4x TypeError from GlyphRenderer.render. --- .../test/WebglAtlasOverflow.test.ts | 109 ++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 addons/addon-webgl/test/WebglAtlasOverflow.test.ts diff --git a/addons/addon-webgl/test/WebglAtlasOverflow.test.ts b/addons/addon-webgl/test/WebglAtlasOverflow.test.ts new file mode 100644 index 000000000..98a4c5944 --- /dev/null +++ b/addons/addon-webgl/test/WebglAtlasOverflow.test.ts @@ -0,0 +1,109 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + * + * Reproduces the single-terminal WebGL crash/garble when the texture atlas + * page count overflows TextureAtlas.maxAtlasPages (vscode#322756 class). + * + * GlyphRenderer sizes its GL texture array and fragment-shader sampler array + * to exactly maxAtlasPages at construction time, but TextureAtlas._createNewPage + * pushes past that cap whenever a 4-page same-size merge is impossible (e.g. a + * heterogeneous mix like 1024+512+512+512). The next GlyphRenderer.render then + * throws "Cannot read properties of undefined (reading 'version')" and cells + * reference sampler indexes the shader does not have, freezing/garbling the + * terminal. Long heavy-CLI sessions hit this in production; the test makes it + * fast by building the renderer with a small cap (the same code path as the + * production 8/16/32 values). + * + * 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, + loadWebglStrict, + setMaxAtlasPages, + writeAndWaitForRender +} from '../../../test/playwright/RendererTestUtils'; +import { generateUniqueGlyphFlood } from '../../../test/playwright/SyntheticTui'; + +const HEADER = ' OVERFLOW_REF_0123456789'; +const HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; + +test.describe('atlas page overflow (#322756 class)', () => { + test.skip(({ browserName }) => browserName !== 'chromium'); + test.describe.configure({ timeout: 60000 }); + + test('renderer survives the atlas exceeding maxAtlasPages-sized textures', async ({ browser }) => { + const ctx: ITestContext = await createTestContext(browser); + const errors: string[] = []; + const onError = (e: Error): void => { errors.push(e.message); }; + ctx.page.on('pageerror', onError); + try { + // Bootstrap: create a throwaway WebGL terminal so the TextureAtlas class + // statics exist, then lower the page cap. openTerminal() disposes the old + // terminal (and its atlas), so the next WebGL renderer is CONSTRUCTED + // with the small cap: its GL texture array and fragment-shader sampler + // array have exactly 4 entries, faithfully mirroring production where + // they always equal maxAtlasPages. + await openTerminal(ctx, { cols: 80, rows: 24 }); + await loadWebglStrict(ctx); + await setMaxAtlasPages(ctx, 4); + await openTerminal(ctx, { cols: 80, rows: 24 }); + await loadWebglStrict(ctx); + + const atlasTexturesLength = await ctx.page.evaluate(() => + (window as any).term._core._renderService._renderer.value._glyphRenderer.value._atlasTextures.length); + expect(atlasTexturesLength, 'renderer must be built with the lowered page cap').toBe(4); + + // Pinned header in the alt buffer; body churns distinct CJK glyphs below + // (cursor-positioned, no trailing linefeed on the bottom row -> no scroll). + await writeAndWaitForRender(ctx, `\x1b[?1049h\x1b[H${HEADER}`); + const reference = await captureRowSignatures(ctx, 1, HEADER_COLS); + await ctx.page.locator('#terminal-container .xterm-screen').screenshot({ path: 'out-esbuild-test/playwright/overflow-before.png' }); + + const glyphsPerChunk = 23 * 40 - 1; + for (let c = 0; c < 16; c++) { + // Plain write + short wait instead of waiting for a render event: once + // the overflow crash fires, render events stop arriving and a render + // wait would hang the test instead of failing it. + await ctx.proxy.write('\x1b[2;1H' + generateUniqueGlyphFlood(glyphsPerChunk, 80, c * glyphsPerChunk)); + await ctx.page.waitForTimeout(50); + } + await ctx.page.waitForTimeout(300); + await ctx.page.locator('#terminal-container .xterm-screen').screenshot({ path: 'out-esbuild-test/playwright/overflow-after.png' }); + + const state = await ctx.page.evaluate(() => { + const renderer = (window as any).term._core._renderService._renderer.value; + return { + pages: renderer._charAtlas.pages.length, + atlasTextures: renderer._glyphRenderer.value._atlasTextures.length + }; + }); + console.error('[overflow] pages=', state.pages, 'atlasTextures=', state.atlasTextures, 'pageErrors=', errors.length, errors[0] ?? ''); + + // Contract: the page count must never exceed the renderer's texture and + // sampler capacity, and rendering must never throw. + expect(errors, `GlyphRenderer must not crash when the atlas is saturated: ${errors[0] ?? ''}`).toEqual([]); + expect(state.pages, 'atlas page count must stay within the renderer texture capacity').toBeLessThanOrEqual(state.atlasTextures); + + // The untouched header must still render correctly after the churn. + const after = await captureRowSignatures(ctx, 1, HEADER_COLS); + for (let i = 0; i < HEADER_COLS.length; i++) { + expectSignatureMatches(after[i], reference[i], 14, `header col ${HEADER_COLS[i]} corrupted after atlas saturation`); + } + } finally { + ctx.page.off('pageerror', onError); + // Restore the default so a lowered cap can never leak into other tests. + await ctx.page.evaluate(() => { + const renderer = (window as any).term._core._renderService._renderer.value; + const atlas = renderer?._charAtlas; + if (atlas) { + (atlas.constructor as any).maxAtlasPages = undefined; + } + }).catch(() => { /* page may already be unusable after a crash */ }); + await ctx.page.close(); + } + }); +});