From 0b1c0b5c5a9e159a41d326eedbc3bfe0282783d4 Mon Sep 17 00:00:00 2001 From: Enes Uysal Date: Thu, 16 Jul 2026 18:38:24 +0300 Subject: [PATCH] Fix stale rendering after a shared texture atlas is cleared TextureAtlas.clearTexture() wipes every page and both cache maps but never bumped _pageLayoutVersion, so it silently broke the invalidation contract that #6042 introduced for shared atlases. The atlas is shared by every terminal with an equal config (CharAtlasCache), but WebglRenderer.clearTextureAtlas() only clears the *calling* renderer's model. Every other owner keeps vertex data pointing into the rows that were just wiped, and GlyphRenderer.beginFrame() has no way to notice, so those terminals keep drawing from stale texture coords until something else happens to invalidate them. Once the clearing terminal rasterizes new glyphs into the freed rows, the siblings render whatever landed there instead of their own text. Embedders hit this whenever they force a redraw on one terminal while others are alive; VS Code calls Terminal.clearTextureAtlas() on OS resume, which is why a stale terminal there stays garbled even after opening a new one (the new terminal re-acquires the same cached atlas) and only recovers once the whole panel is closed and the last owner drops the atlas. Bump _pageLayoutVersion on clear so sibling renderers rebuild their models on their next frame, matching what page merges and evictions already do. The added test fails on master (terminal B's row renders terminal A's flood glyphs) and passes with the fix. --- addons/addon-webgl/src/TextureAtlas.ts | 5 +++ .../test/WebglSharedAtlasGarble.test.ts | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index dba1ad474..4977ad741 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -145,6 +145,11 @@ export class TextureAtlas implements ITextureAtlas { this._cacheMap.clear(); this._cacheMapCombined.clear(); this._didWarmUp = false; + + // Invalidate renderer models so all texture pages are refreshed. The atlas may be shared, in + // which case the clearing renderer has cleared only its own model and every other owner still + // holds texture coords into the rows just wiped. + this._pageLayoutVersion++; } private _createNewPage(): AtlasPage { diff --git a/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts b/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts index b3066b0c9..3e85bcc30 100644 --- a/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts +++ b/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts @@ -240,4 +240,43 @@ test.describe('shared-atlas garble across terminals (#6038)', () => { await ctx.page.close(); } }); + + test('keeps a second terminal rendering correctly after terminal A clears the shared atlas', async ({ browser }) => { + const ctx = await createTestContext(browser); + try { + await openTerminal(ctx, { cols: 80, rows: 24 }); + await loadWebglStrict(ctx); + await createTerminalB(ctx); + + const atlasShared = await ctx.page.evaluate(() => { + return window.term._core?._renderService?._renderer?.value?._charAtlas === window.termB?._core?._renderService?._renderer?.value?._charAtlas; + }); + expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true); + + await writeToBAndWaitForRender(ctx, HEADER); + const reference = await captureRowSignatures(ctx, 1, HEADER_COLS); + + // Terminal A alone clears the shared atlas, as embedders do to force a redraw (VS Code calls + // this for every terminal on OS resume). This wipes the pages that terminal B's model still + // holds texture coords into. + await ctx.proxy.clearTextureAtlas(); + + // Terminal A repopulates the cleared pages with a different glyph set, so B's stale coords now + // resolve to whatever A rasterized into those slots. + await writeAndWaitForRender(ctx, '\x1b[2;1H' + generateColoredAsciiFlood(23 * 78)); + + // Nudge B into a render that does not touch row 1, mirroring a terminal that is idle but + // visible. Row 1 must still be drawn from a model B rebuilt against the current atlas. + await writeToBAndWaitForRender(ctx, '\x1b[24;1Hx'); + const termBHeader = await ctx.page.evaluate(() => window.termB?.buffer.active.getLine(0)?.translateToString(true)); + expect(termBHeader, 'terminal B buffer must still contain the header').toBe(HEADER); + + const after = await captureRowSignatures(ctx, 1, HEADER_COLS); + for (let i = 0; i < HEADER_COLS.length; i++) { + expectSignatureMatches(after[i], reference[i], `terminal B header col ${HEADER_COLS[i]} garbled by terminal A's atlas clear`); + } + } finally { + await ctx.page.close(); + } + }); });