From 55dbe3b65e674aa499ee7c2500ae3a6b2125cf68 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 16 Jun 2026 19:19:02 -0700 Subject: [PATCH 01/36] use current document for mouse listeners --- src/browser/services/MouseService.test.ts | 93 +++++++++++++++++++++++ src/browser/services/MouseService.ts | 28 ++++--- 2 files changed, 111 insertions(+), 10 deletions(-) diff --git a/src/browser/services/MouseService.test.ts b/src/browser/services/MouseService.test.ts index b4655a1a1..bb48bc0b4 100644 --- a/src/browser/services/MouseService.test.ts +++ b/src/browser/services/MouseService.test.ts @@ -351,3 +351,96 @@ describe('MouseService mouseEventsRequireAlt', () => { assert.isFalse(element.classList.contains(MouseEventCssClasses.ENABLE_MOUSE_EVENTS)); }); }); + +describe('MouseService multi-window', () => { + interface IRecordingDocument { + doc: Document; + added: string[]; + removed: string[]; + listeners: { [type: string]: EventListener[] }; + } + + function createRecordingDocument(): IRecordingDocument { + const added: string[] = []; + const removed: string[] = []; + const listeners: { [type: string]: EventListener[] } = {}; + const doc = { + addEventListener: (type: string, listener: EventListener) => { + added.push(type); + (listeners[type] ??= []).push(listener); + }, + removeEventListener: (type: string) => removed.push(type) + } as any; + return { doc, added, removed, listeners }; + } + + it('should manage protocol mouseup/mousemove listeners on the element\'s current document, not the document the terminal was opened with', () => { + const mouseStateService = new MouseStateService(); + const optionsService = new OptionsService({}); + const mouseService = new MouseService( + new MockRenderService(), + { + getMouseReportCoords: () => ({ col: 0, row: 0, x: 0, y: 0 }) + } as any, + mouseStateService, + { + triggerDataEvent: () => {}, + triggerBinaryEvent: () => {}, + decPrivateModes: { applicationCursorKeys: false } + } as any, + bufferService, + optionsService, + new TestSelectionService(), + logService, + new MockCoreBrowserService() + ); + + // Simulate a terminal that was opened with one document but whose element now lives in + // another document (eg. it was moved to a child window) + const openDocument = createRecordingDocument(); + const childDocument = createRecordingDocument(); + const elementListeners: { [type: string]: EventListener[] } = {}; + const element = { + classList: { + add: () => {}, + remove: () => {}, + contains: () => false + }, + addEventListener: (type: string, listener: EventListener) => { + (elementListeners[type] ??= []).push(listener); + }, + removeEventListener: () => {}, + ownerDocument: childDocument.doc + } as any; + + mouseService.bindMouse({ + element, + screenElement: createTestMouseTargetElement(), + document: openDocument.doc + }, disposable => disposable, () => {}); + mouseStateService.activeProtocol = 'ANY'; + + elementListeners['mousedown'][0]({ + type: 'mousedown', + button: 0, + buttons: 1, + altKey: false, + ctrlKey: false, + shiftKey: false, + preventDefault: () => {} + } as any); + assert.deepEqual(childDocument.added, ['mouseup', 'mousemove']); + assert.deepEqual(openDocument.added, []); + + childDocument.listeners['mouseup'][0]({ + type: 'mouseup', + button: 0, + buttons: 0, + altKey: false, + ctrlKey: false, + shiftKey: false + } as any); + assert.deepEqual(childDocument.removed, ['mouseup', 'mousemove']); + assert.deepEqual(openDocument.removed, []); + }); +}); diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index 61cbfa86c..4239f8d1b 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -21,6 +21,7 @@ interface IMouseBindContext { readonly target: IMouseServiceTarget; readonly focus: () => void; readonly requestedEvents: RequestedMouseEvents; + listenerDocument: Document | null; } export class MouseService implements IMouseService { @@ -61,7 +62,7 @@ export class MouseService implements IMouseService { mousedrag: null, mousemove: null }; - const ctx: IMouseBindContext = { target, focus, requestedEvents }; + const ctx: IMouseBindContext = { target, focus, requestedEvents, listenerDocument: null }; const eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener> = { mouseup: (ev: Event) => this._handleMouseUp(ctx, ev as MouseEvent), wheel: (ev: Event) => this._handleWheel(ctx, ev as WheelEvent), @@ -87,11 +88,12 @@ export class MouseService implements IMouseService { // Ensure document-level listeners are removed on dispose register(toDisposable(() => { + const listenerDocument = ctx.listenerDocument ?? document; if (requestedEvents.mouseup) { - document.removeEventListener('mouseup', requestedEvents.mouseup); + listenerDocument.removeEventListener('mouseup', requestedEvents.mouseup); } if (requestedEvents.mousedrag) { - document.removeEventListener('mousemove', requestedEvents.mousedrag); + listenerDocument.removeEventListener('mousemove', requestedEvents.mousedrag); } })); @@ -199,12 +201,14 @@ export class MouseService implements IMouseService { this._sendEvent(ctx, ev); if (!ev.buttons) { // if no other button is held remove global handlers + const listenerDocument = ctx.listenerDocument ?? ctx.target.document; if (ctx.requestedEvents.mouseup) { - ctx.target.document.removeEventListener('mouseup', ctx.requestedEvents.mouseup); + listenerDocument.removeEventListener('mouseup', ctx.requestedEvents.mouseup); } if (ctx.requestedEvents.mousedrag) { - ctx.target.document.removeEventListener('mousemove', ctx.requestedEvents.mousedrag); + listenerDocument.removeEventListener('mousemove', ctx.requestedEvents.mousedrag); } + ctx.listenerDocument = null; } } @@ -246,12 +250,15 @@ export class MouseService implements IMouseService { // of the terminal element. // Note: Other emulators also do this for 'mousedown' while a button // is held, we currently limit 'mousedown' to the terminal only. + // Use the element's current document in case it moved to another window after open. + const listenerDocument = ctx.target.element.ownerDocument ?? ctx.target.document; if (ctx.requestedEvents.mouseup) { - ctx.target.document.addEventListener('mouseup', ctx.requestedEvents.mouseup); + listenerDocument.addEventListener('mouseup', ctx.requestedEvents.mouseup); } if (ctx.requestedEvents.mousedrag) { - ctx.target.document.addEventListener('mousemove', ctx.requestedEvents.mousedrag); + listenerDocument.addEventListener('mousemove', ctx.requestedEvents.mousedrag); } + ctx.listenerDocument = listenerDocument; } private _handlePassiveWheel(ctx: IMouseBindContext, ev: WheelEvent): false | void { @@ -398,8 +405,9 @@ export class MouseService implements IMouseService { } private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void { - const { element, document } = ctx.target; + const { element } = ctx.target; const { requestedEvents } = ctx; + const listenerDocument = ctx.listenerDocument ?? ctx.target.document; // apply global changes on events if (events) { if (this._optionsService.rawOptions.logLevel === 'debug') { @@ -434,7 +442,7 @@ export class MouseService implements IMouseService { if (!(events & CoreMouseEventType.UP)) { if (requestedEvents.mouseup) { - document.removeEventListener('mouseup', requestedEvents.mouseup); + listenerDocument.removeEventListener('mouseup', requestedEvents.mouseup); } requestedEvents.mouseup = null; } else { @@ -443,7 +451,7 @@ export class MouseService implements IMouseService { if (!(events & CoreMouseEventType.DRAG)) { if (requestedEvents.mousedrag) { - document.removeEventListener('mousemove', requestedEvents.mousedrag); + listenerDocument.removeEventListener('mousemove', requestedEvents.mousedrag); } requestedEvents.mousedrag = null; } else { From 1701dcef900effdd07433e4a672ca4007a8be5da Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 16 Jun 2026 19:35:29 -0700 Subject: [PATCH 02/36] use disposables for transient mouse listeners --- src/browser/services/MouseService.ts | 48 +++++++++------------------- 1 file changed, 15 insertions(+), 33 deletions(-) diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index 4239f8d1b..d399adf57 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -7,7 +7,7 @@ import { addDisposableListener } from '../Dom'; import { IBufferService, IMouseStateService, ICoreService, ILogService, IOptionsService } from '../../common/services/Services'; import { CoreMouseAction, CoreMouseButton, CoreMouseEventType, ICoreMouseEvent, IDisposable } from '../../common/Types'; import { C0 } from '../../common/data/EscapeSequences'; -import { DisposableStore, MutableDisposable, toDisposable } from '../../common/Lifecycle'; +import { DisposableStore, MutableDisposable } from '../../common/Lifecycle'; import { ICoreBrowserService, IMouseCoordsService, IMouseService, IMouseServiceTarget, IRenderService, ISelectionService } from './Services'; import { Gesture, EventType as GestureEventType, IGestureEvent } from '../scrollable/touch'; @@ -21,7 +21,8 @@ interface IMouseBindContext { readonly target: IMouseServiceTarget; readonly focus: () => void; readonly requestedEvents: RequestedMouseEvents; - listenerDocument: Document | null; + readonly mouseupListener: MutableDisposable; + readonly mousedragListener: MutableDisposable; } export class MouseService implements IMouseService { @@ -46,7 +47,7 @@ export class MouseService implements IMouseService { } public bindMouse(target: IMouseServiceTarget, register: (disposable: IDisposable) => void, focus: () => void): void { - const { element, document } = target; + const { element } = target; /** * Event listener state handling. @@ -62,7 +63,11 @@ export class MouseService implements IMouseService { mousedrag: null, mousemove: null }; - const ctx: IMouseBindContext = { target, focus, requestedEvents, listenerDocument: null }; + const mouseupListener = new MutableDisposable(); + const mousedragListener = new MutableDisposable(); + register(mouseupListener); + register(mousedragListener); + const ctx: IMouseBindContext = { target, focus, requestedEvents, mouseupListener, mousedragListener }; const eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener> = { mouseup: (ev: Event) => this._handleMouseUp(ctx, ev as MouseEvent), wheel: (ev: Event) => this._handleWheel(ctx, ev as WheelEvent), @@ -86,17 +91,6 @@ export class MouseService implements IMouseService { // force initial onProtocolChange so we dont miss early mouse requests this._mouseStateService.activeProtocol = this._mouseStateService.activeProtocol; - // Ensure document-level listeners are removed on dispose - register(toDisposable(() => { - const listenerDocument = ctx.listenerDocument ?? document; - if (requestedEvents.mouseup) { - listenerDocument.removeEventListener('mouseup', requestedEvents.mouseup); - } - if (requestedEvents.mousedrag) { - listenerDocument.removeEventListener('mousemove', requestedEvents.mousedrag); - } - })); - /** * "Always on" event listeners. */ @@ -201,14 +195,8 @@ export class MouseService implements IMouseService { this._sendEvent(ctx, ev); if (!ev.buttons) { // if no other button is held remove global handlers - const listenerDocument = ctx.listenerDocument ?? ctx.target.document; - if (ctx.requestedEvents.mouseup) { - listenerDocument.removeEventListener('mouseup', ctx.requestedEvents.mouseup); - } - if (ctx.requestedEvents.mousedrag) { - listenerDocument.removeEventListener('mousemove', ctx.requestedEvents.mousedrag); - } - ctx.listenerDocument = null; + ctx.mouseupListener.clear(); + ctx.mousedragListener.clear(); } } @@ -253,12 +241,11 @@ export class MouseService implements IMouseService { // Use the element's current document in case it moved to another window after open. const listenerDocument = ctx.target.element.ownerDocument ?? ctx.target.document; if (ctx.requestedEvents.mouseup) { - listenerDocument.addEventListener('mouseup', ctx.requestedEvents.mouseup); + ctx.mouseupListener.value = addDisposableListener(listenerDocument, 'mouseup', ctx.requestedEvents.mouseup); } if (ctx.requestedEvents.mousedrag) { - listenerDocument.addEventListener('mousemove', ctx.requestedEvents.mousedrag); + ctx.mousedragListener.value = addDisposableListener(listenerDocument, 'mousemove', ctx.requestedEvents.mousedrag); } - ctx.listenerDocument = listenerDocument; } private _handlePassiveWheel(ctx: IMouseBindContext, ev: WheelEvent): false | void { @@ -407,7 +394,6 @@ export class MouseService implements IMouseService { private _handleProtocolChange(ctx: IMouseBindContext, eventListeners: Record<'mouseup' | 'wheel' | 'mousedrag' | 'mousemove', EventListener>, events: CoreMouseEventType): void { const { element } = ctx.target; const { requestedEvents } = ctx; - const listenerDocument = ctx.listenerDocument ?? ctx.target.document; // apply global changes on events if (events) { if (this._optionsService.rawOptions.logLevel === 'debug') { @@ -441,18 +427,14 @@ export class MouseService implements IMouseService { } if (!(events & CoreMouseEventType.UP)) { - if (requestedEvents.mouseup) { - listenerDocument.removeEventListener('mouseup', requestedEvents.mouseup); - } + ctx.mouseupListener.clear(); requestedEvents.mouseup = null; } else { requestedEvents.mouseup ??= eventListeners.mouseup; } if (!(events & CoreMouseEventType.DRAG)) { - if (requestedEvents.mousedrag) { - listenerDocument.removeEventListener('mousemove', requestedEvents.mousedrag); - } + ctx.mousedragListener.clear(); requestedEvents.mousedrag = null; } else { requestedEvents.mousedrag ??= eventListeners.mousedrag; From 1efd80cd174ce2dd67d3a1636f8d15c1a1b7367e Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 16 Jun 2026 19:44:27 -0700 Subject: [PATCH 03/36] Dont forget target.document --- src/browser/services/MouseService.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index d399adf57..4b3e14dc4 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -76,7 +76,7 @@ export class MouseService implements IMouseService { }; this._altMouseCursor = new AltMouseCursorController( element, - document, + target.document, () => this._mouseStateService.areMouseEventsActive && !!this._optionsService.rawOptions.mouseEventsRequireAlt ); From 9f232d981b81368fdcea34c44aa6baec651da0cb Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Tue, 16 Jun 2026 19:53:03 -0700 Subject: [PATCH 04/36] destructure doc from target like the old days --- src/browser/services/MouseService.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/browser/services/MouseService.ts b/src/browser/services/MouseService.ts index 4b3e14dc4..353e0581d 100644 --- a/src/browser/services/MouseService.ts +++ b/src/browser/services/MouseService.ts @@ -47,7 +47,7 @@ export class MouseService implements IMouseService { } public bindMouse(target: IMouseServiceTarget, register: (disposable: IDisposable) => void, focus: () => void): void { - const { element } = target; + const { element, document } = target; /** * Event listener state handling. @@ -76,7 +76,7 @@ export class MouseService implements IMouseService { }; this._altMouseCursor = new AltMouseCursorController( element, - target.document, + document, () => this._mouseStateService.areMouseEventsActive && !!this._optionsService.rawOptions.mouseEventsRequireAlt ); @@ -239,7 +239,8 @@ export class MouseService implements IMouseService { // Note: Other emulators also do this for 'mousedown' while a button // is held, we currently limit 'mousedown' to the terminal only. // Use the element's current document in case it moved to another window after open. - const listenerDocument = ctx.target.element.ownerDocument ?? ctx.target.document; + const { element, document: targetDocument } = ctx.target; + const listenerDocument = element.ownerDocument ?? targetDocument; if (ctx.requestedEvents.mouseup) { ctx.mouseupListener.value = addDisposableListener(listenerDocument, 'mouseup', ctx.requestedEvents.mouseup); } From 780cbef3cfb296513acd7774284377d73cae5d1d Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Sat, 4 Jul 2026 14:43:52 -0700 Subject: [PATCH 05/36] Add shared atlas page merge garble repro --- .../test/WebglSharedAtlasGarble.test.ts | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts diff --git a/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts b/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts new file mode 100644 index 000000000..cfd7af785 --- /dev/null +++ b/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts @@ -0,0 +1,201 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IImage32, decodePng } from '@lunapaint/png-codec'; +import test, { expect } from '@playwright/test'; +import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils'; + +type CellSignature = number[]; + +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'; + +async function loadWebglStrict(ctx: ITestContext): Promise { + await ctx.page.evaluate(() => { + const w = window as any; + w.addon = new w.WebglAddon({ preserveDrawingBuffer: true }); + w.term.loadAddon(w.addon); + }); + const isWebglRenderer = await ctx.page.evaluate(() => { + const w = window as any; + return !!w.addon && w.term?._core?._renderService?._renderer?.value === w.addon._renderer; + }); + expect(isWebglRenderer, 'WebGL renderer must be active').toBe(true); +} + +async function createTerminalB(ctx: ITestContext): Promise { + await ctx.page.evaluate(() => { + 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); + w.addonB = new w.WebglAddon({ preserveDrawingBuffer: true }); + w.termB.loadAddon(w.addonB); + }); +} + +async function writeToBAndWaitForRender(ctx: ITestContext, data: string): Promise { + await ctx.page.evaluate(d => new Promise(resolve => { + const termB = (window as any).termB; + const disposable = termB.onRender(() => { + disposable.dispose(); + resolve(); + }); + termB.write(d); + }), data); +} + +async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise { + const renderPromise = new Promise(resolve => { + const disposable = ctx.proxy.onRender(() => { + disposable.dispose(); + resolve(); + }); + }); + await ctx.proxy.write(data); + await renderPromise; +} + +async function setMaxAtlasPages(ctx: ITestContext, maxAtlasPages: number): Promise { + const applied = await ctx.page.evaluate(max => { + const atlas = (window as any).term?._core?._renderService?._renderer?.value?._charAtlas; + if (!atlas) { + return false; + } + (atlas.constructor as any).maxAtlasPages = max; + return true; + }, maxAtlasPages); + expect(applied, 'should be able to set TextureAtlas.maxAtlasPages').toBe(true); +} + +async function resetMaxAtlasPages(ctx: ITestContext): Promise { + await ctx.page.evaluate(() => { + const atlas = (window as any).term?._core?._renderService?._renderer?.value?._charAtlas; + if (atlas) { + (atlas.constructor as any).maxAtlasPages = undefined; + } + }); +} + +function generateColoredAsciiFlood(cells: number, offset: number = 0): string { + const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; + let out = ''; + for (let i = 0; i < cells; i++) { + const ch = letters[(offset + i) % letters.length]; + const fg = (offset + i) % 256; + const bg = (offset + i * 7) % 256; + out += `\x1b[38;5;${fg}m\x1b[48;5;${bg}m${ch}`; + if ((i + 1) % 78 === 0 && i + 1 < cells) { + out += '\x1b[0m\r\n'; + } + } + return out + '\x1b[0m'; +} + +async function captureRowSignatures(ctx: ITestContext, row: number, cols: number[]): Promise { + const screenshotOptions = process.env.DEBUG ? { path: 'out-esbuild-test/playwright/shared-atlas-term-b.png' } : undefined; + const buffer = await ctx.page.locator(TERM_B_SELECTOR).screenshot(screenshotOptions); + const frame = { + cols: 80, + rows: 24, + decoded: (await decodePng(new Uint8Array(buffer), { force32: true })).image + }; + return cols.map(col => cellSignature(frame, col, row)); +} + +function cellSignature(frame: { cols: number, rows: number, decoded: IImage32 }, col: number, row: number): CellSignature { + const grid = 4; + const data = frame.decoded.data; + const imageWidth = frame.decoded.width; + const cellWidth = frame.decoded.width / frame.cols; + const cellHeight = frame.decoded.height / frame.rows; + const x0 = (col - 1) * cellWidth; + const y0 = (row - 1) * cellHeight; + const sums = new Array(grid * grid * 4).fill(0); + const counts = new Array(grid * grid).fill(0); + const startX = Math.max(0, Math.floor(x0)); + const startY = Math.max(0, Math.floor(y0)); + const endX = Math.min(frame.decoded.width, Math.ceil(x0 + cellWidth)); + const endY = Math.min(frame.decoded.height, Math.ceil(y0 + cellHeight)); + + for (let y = startY; y < endY; y++) { + const gy = Math.min(grid - 1, Math.max(0, Math.floor(((y - y0) / cellHeight) * grid))); + for (let x = startX; x < endX; x++) { + const gx = Math.min(grid - 1, Math.max(0, Math.floor(((x - x0) / cellWidth) * grid))); + const sub = gy * grid + gx; + const i = (y * imageWidth + x) * 4; + sums[sub * 4] += data[i]; + sums[sub * 4 + 1] += data[i + 1]; + sums[sub * 4 + 2] += data[i + 2]; + sums[sub * 4 + 3] += data[i + 3]; + counts[sub]++; + } + } + + const signature = new Array(grid * grid * 4).fill(0); + for (let sub = 0; sub < counts.length; sub++) { + if (counts[sub] > 0) { + signature[sub * 4] = sums[sub * 4] / counts[sub]; + signature[sub * 4 + 1] = sums[sub * 4 + 1] / counts[sub]; + signature[sub * 4 + 2] = sums[sub * 4 + 2] / counts[sub]; + signature[sub * 4 + 3] = sums[sub * 4 + 3] / counts[sub]; + } + } + return signature; +} + +function expectSignatureMatches(actual: CellSignature, reference: CellSignature, message: string): void { + let diff = 0; + for (let i = 0; i < actual.length; i++) { + diff = Math.max(diff, Math.abs(actual[i] - reference[i])); + } + expect(diff, `${message}; max channel diff ${diff}`).toBeLessThanOrEqual(14); +} + +test.describe('shared-atlas garble across terminals (#6038)', () => { + test.skip(({ browserName }) => browserName !== 'chromium'); + test.describe.configure({ timeout: 60000 }); + + test('keeps a second terminal rendering correctly after shared atlas page merges', 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(() => { + const w = window as any; + return w.term._core._renderService._renderer.value._charAtlas === w.termB._core._renderService._renderer.value._charAtlas; + }); + expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true); + + await setMaxAtlasPages(ctx, 4); + + await writeToBAndWaitForRender(ctx, HEADER); + const reference = await captureRowSignatures(ctx, 1, HEADER_COLS); + + 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'); + const termBHeader = await ctx.page.evaluate(() => (window as any).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 merges`); + } + } finally { + await resetMaxAtlasPages(ctx).catch(() => {}); + await ctx.page.close(); + } + }); +}); From 6fe6fe1af303a4ed0b5e6ae2ab393dfd2be09347 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Sat, 4 Jul 2026 14:44:23 -0700 Subject: [PATCH 06/36] Fix shared atlas page invalidation per renderer --- addons/addon-webgl/src/GlyphRenderer.ts | 11 ++++++++++- addons/addon-webgl/src/TextureAtlas.ts | 16 ++++++---------- addons/addon-webgl/src/Types.ts | 5 ++--- 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index be1990c2a..1289adec2 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -100,6 +100,7 @@ export class GlyphRenderer extends Disposable { private readonly _attributesBuffer: WebGLBuffer; private _atlas: ITextureAtlas | undefined; + private _lastAtlasPagesVersion: number = -1; private _activeBuffer: number = 0; private readonly _vertices: IVertices = { count: 0, @@ -214,7 +215,14 @@ export class GlyphRenderer extends Disposable { } public beginFrame(): boolean { - return this._atlas ? this._atlas.beginFrame() : true; + if (!this._atlas) { + return true; + } + if (this._atlas.pagesVersion !== this._lastAtlasPagesVersion) { + this._lastAtlasPagesVersion = this._atlas.pagesVersion; + return true; + } + return false; } public updateCell(x: number, y: number, code: number, bg: number, fg: number, ext: number, chars: string, width: number, lastBg: number): void { @@ -375,6 +383,7 @@ export class GlyphRenderer extends Disposable { public setAtlas(atlas: ITextureAtlas): void { this._atlas = atlas; + this._lastAtlasPagesVersion = -1; this.invalidateAtlasTextures(); } diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index 1c3e08a1c..73056b87e 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -132,12 +132,8 @@ export class TextureAtlas implements ITextureAtlas { } } - private _requestClearModel = false; - public beginFrame(): boolean { - const result = this._requestClearModel; - this._requestClearModel = false; - return result; - } + private _pagesVersion = 0; + public get pagesVersion(): number { return this._pagesVersion; } public clearTexture(): void { if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) { @@ -206,8 +202,8 @@ export class TextureAtlas implements ITextureAtlas { // Add the new merged page to the end this.pages.push(mergedPage); - // Request the model to be cleared to refresh all texture pages. - this._requestClearModel = true; + // Invalidate renderer models so all texture pages are refreshed. + this._pagesVersion++; this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); } @@ -818,8 +814,8 @@ export class TextureAtlas implements ITextureAtlas { this._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize); this.pages.push(this._overflowSizePage); - // Request the model to be cleared to refresh all texture pages. - this._requestClearModel = true; + // Invalidate renderer models so all texture pages are refreshed. + this._pagesVersion++; this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas); } activePage = this._overflowSizePage; diff --git a/addons/addon-webgl/src/Types.ts b/addons/addon-webgl/src/Types.ts index 294582e37..3a85eb4d0 100644 --- a/addons/addon-webgl/src/Types.ts +++ b/addons/addon-webgl/src/Types.ts @@ -68,10 +68,9 @@ export interface ITextureAtlas extends IDisposable { warmUp(): void; /** - * Call when a frame is being drawn, this will return true if the atlas was cleared to make room - * for a new set of glyphs. + * Incremented whenever texture page indexes or coordinates cached by renderers may be stale. */ - beginFrame(): boolean; + readonly pagesVersion: number; /** * Clear all glyphs from the texture atlas. From 77bf56066c1e2197fe7c50b415fc376ced59eb29 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 9 Jul 2026 11:00:31 -0700 Subject: [PATCH 07/36] wordings --- addons/addon-webgl/src/GlyphRenderer.ts | 8 ++++---- addons/addon-webgl/src/Types.ts | 4 +++- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index 1289adec2..3c77c7cf5 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -100,7 +100,7 @@ export class GlyphRenderer extends Disposable { private readonly _attributesBuffer: WebGLBuffer; private _atlas: ITextureAtlas | undefined; - private _lastAtlasPagesVersion: number = -1; + private _lastSeenPagesVersion: number = -1; private _activeBuffer: number = 0; private readonly _vertices: IVertices = { count: 0, @@ -218,8 +218,8 @@ export class GlyphRenderer extends Disposable { if (!this._atlas) { return true; } - if (this._atlas.pagesVersion !== this._lastAtlasPagesVersion) { - this._lastAtlasPagesVersion = this._atlas.pagesVersion; + if (this._atlas.pagesVersion !== this._lastSeenPagesVersion) { + this._lastSeenPagesVersion = this._atlas.pagesVersion; return true; } return false; @@ -383,7 +383,7 @@ export class GlyphRenderer extends Disposable { public setAtlas(atlas: ITextureAtlas): void { this._atlas = atlas; - this._lastAtlasPagesVersion = -1; + this._lastSeenPagesVersion = -1; this.invalidateAtlasTextures(); } diff --git a/addons/addon-webgl/src/Types.ts b/addons/addon-webgl/src/Types.ts index 3a85eb4d0..1c9a7f931 100644 --- a/addons/addon-webgl/src/Types.ts +++ b/addons/addon-webgl/src/Types.ts @@ -68,7 +68,9 @@ export interface ITextureAtlas extends IDisposable { warmUp(): void; /** - * Incremented whenever texture page indexes or coordinates cached by renderers may be stale. + * Incremented whenever cached glyph texture page indexes or coordinates may be stale. + * Each renderer tracks its own last-seen value so shared atlas changes are observed + * independently by every terminal. */ readonly pagesVersion: number; From 64ecce4f255c003cdb13d385453980d68bc5e479 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 9 Jul 2026 11:18:29 -0700 Subject: [PATCH 08/36] Do a better job with types in testing --- .../test/WebglSharedAtlasGarble.test.ts | 80 ++++++++++++++----- 1 file changed, 61 insertions(+), 19 deletions(-) diff --git a/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts b/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts index cfd7af785..b3066b0c9 100644 --- a/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts +++ b/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts @@ -5,9 +5,52 @@ import { IImage32, decodePng } from '@lunapaint/png-codec'; import test, { expect } from '@playwright/test'; +import type { Terminal, ITerminalInitOnlyOptions, ITerminalOptions } from '@xterm/xterm'; +import type { IWebglAddonOptions, WebglAddon } from '@xterm/addon-webgl'; import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils'; type CellSignature = number[]; +type TestTerminalConstructor = new (options?: ITerminalOptions & ITerminalInitOnlyOptions) => ITestTerminal; +type TestWebglAddonConstructor = new (options?: IWebglAddonOptions) => ITestWebglAddon; + +interface ITestTextureAtlasConstructor { + maxAtlasPages: number | undefined; +} + +interface ITestTextureAtlas { + constructor: ITestTextureAtlasConstructor; +} + +interface ITestRenderer { + _charAtlas?: ITestTextureAtlas; +} + +interface ITestRenderService { + _renderer?: { + value?: ITestRenderer; + }; +} + +interface ITestTerminal extends Terminal { + _core?: { + _renderService?: ITestRenderService; + }; +} + +interface ITestWebglAddon extends WebglAddon { + _renderer?: ITestRenderer; +} + +declare global { + interface Window { // eslint-disable-line @typescript-eslint/naming-convention + Terminal: TestTerminalConstructor; + WebglAddon: TestWebglAddonConstructor; + term: ITestTerminal; + termB?: ITestTerminal; + addon?: ITestWebglAddon; + addonB?: ITestWebglAddon; + } +} const HEADER = ' HEADER_REF_0123456789'; const HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; @@ -15,35 +58,35 @@ const TERM_B_SELECTOR = '#terminal-container-b .xterm-screen'; async function loadWebglStrict(ctx: ITestContext): Promise { await ctx.page.evaluate(() => { - const w = window as any; - w.addon = new w.WebglAddon({ preserveDrawingBuffer: true }); - w.term.loadAddon(w.addon); + window.addon = new window.WebglAddon({ preserveDrawingBuffer: true }); + window.term.loadAddon(window.addon); }); const isWebglRenderer = await ctx.page.evaluate(() => { - const w = window as any; - return !!w.addon && w.term?._core?._renderService?._renderer?.value === w.addon._renderer; + return !!window.addon && window.term?._core?._renderService?._renderer?.value === window.addon._renderer; }); expect(isWebglRenderer, 'WebGL renderer must be active').toBe(true); } async function createTerminalB(ctx: ITestContext): Promise { await ctx.page.evaluate(() => { - const w = window as any; - w.termB?.dispose(); + window.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); - w.addonB = new w.WebglAddon({ preserveDrawingBuffer: true }); - w.termB.loadAddon(w.addonB); + window.termB = new window.Terminal({ cols: 80, rows: 24, allowProposedApi: true }); + window.termB.open(el); + window.addonB = new window.WebglAddon({ preserveDrawingBuffer: true }); + window.termB.loadAddon(window.addonB); }); } async function writeToBAndWaitForRender(ctx: ITestContext, data: string): Promise { await ctx.page.evaluate(d => new Promise(resolve => { - const termB = (window as any).termB; + const termB = window.termB; + if (!termB) { + throw new Error('Terminal B must be created before writing to it'); + } const disposable = termB.onRender(() => { disposable.dispose(); resolve(); @@ -65,11 +108,11 @@ async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise { const applied = await ctx.page.evaluate(max => { - const atlas = (window as any).term?._core?._renderService?._renderer?.value?._charAtlas; + const atlas = window.term?._core?._renderService?._renderer?.value?._charAtlas; if (!atlas) { return false; } - (atlas.constructor as any).maxAtlasPages = max; + atlas.constructor.maxAtlasPages = max; return true; }, maxAtlasPages); expect(applied, 'should be able to set TextureAtlas.maxAtlasPages').toBe(true); @@ -77,9 +120,9 @@ async function setMaxAtlasPages(ctx: ITestContext, maxAtlasPages: number): Promi async function resetMaxAtlasPages(ctx: ITestContext): Promise { await ctx.page.evaluate(() => { - const atlas = (window as any).term?._core?._renderService?._renderer?.value?._charAtlas; + const atlas = window.term?._core?._renderService?._renderer?.value?._charAtlas; if (atlas) { - (atlas.constructor as any).maxAtlasPages = undefined; + atlas.constructor.maxAtlasPages = undefined; } }); } @@ -171,8 +214,7 @@ test.describe('shared-atlas garble across terminals (#6038)', () => { await createTerminalB(ctx); const atlasShared = await ctx.page.evaluate(() => { - const w = window as any; - return w.term._core._renderService._renderer.value._charAtlas === w.termB._core._renderService._renderer.value._charAtlas; + 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); @@ -186,7 +228,7 @@ test.describe('shared-atlas garble across terminals (#6038)', () => { } await writeToBAndWaitForRender(ctx, '\x1b[24;1Hx'); - const termBHeader = await ctx.page.evaluate(() => (window as any).termB.buffer.active.getLine(0)?.translateToString(true)); + 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); From 46556eb6cf953ff61ef5d4b02a2c192cdd5ee782 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 9 Jul 2026 11:32:23 -0700 Subject: [PATCH 09/36] Rename to pageLayoutVersion --- addons/addon-webgl/src/GlyphRenderer.ts | 8 ++++---- addons/addon-webgl/src/TextureAtlas.ts | 12 ++++++------ addons/addon-webgl/src/Types.ts | 6 +++--- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index 3c77c7cf5..dcba0b4b8 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -100,7 +100,7 @@ export class GlyphRenderer extends Disposable { private readonly _attributesBuffer: WebGLBuffer; private _atlas: ITextureAtlas | undefined; - private _lastSeenPagesVersion: number = -1; + private _lastSeenPageLayoutVersion: number = -1; private _activeBuffer: number = 0; private readonly _vertices: IVertices = { count: 0, @@ -218,8 +218,8 @@ export class GlyphRenderer extends Disposable { if (!this._atlas) { return true; } - if (this._atlas.pagesVersion !== this._lastSeenPagesVersion) { - this._lastSeenPagesVersion = this._atlas.pagesVersion; + if (this._atlas.pageLayoutVersion !== this._lastSeenPageLayoutVersion) { + this._lastSeenPageLayoutVersion = this._atlas.pageLayoutVersion; return true; } return false; @@ -383,7 +383,7 @@ export class GlyphRenderer extends Disposable { public setAtlas(atlas: ITextureAtlas): void { this._atlas = atlas; - this._lastSeenPagesVersion = -1; + this._lastSeenPageLayoutVersion = -1; this.invalidateAtlasTextures(); } diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index 73056b87e..5368c9b46 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -132,8 +132,8 @@ export class TextureAtlas implements ITextureAtlas { } } - private _pagesVersion = 0; - public get pagesVersion(): number { return this._pagesVersion; } + private _pageLayoutVersion = 0; + public get pageLayoutVersion(): number { return this._pageLayoutVersion; } public clearTexture(): void { if (this._pages[0].currentRow.x === 0 && this._pages[0].currentRow.y === 0) { @@ -202,8 +202,8 @@ export class TextureAtlas implements ITextureAtlas { // Add the new merged page to the end this.pages.push(mergedPage); - // Invalidate renderer models so all texture pages are refreshed. - this._pagesVersion++; + // Request the model to be cleared to refresh all texture pages. + this._pageLayoutVersion++; this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); } @@ -814,8 +814,8 @@ export class TextureAtlas implements ITextureAtlas { this._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize); this.pages.push(this._overflowSizePage); - // Invalidate renderer models so all texture pages are refreshed. - this._pagesVersion++; + // Request the model to be cleared to refresh all texture pages. + this._pageLayoutVersion++; this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas); } activePage = this._overflowSizePage; diff --git a/addons/addon-webgl/src/Types.ts b/addons/addon-webgl/src/Types.ts index 1c9a7f931..0a46a0071 100644 --- a/addons/addon-webgl/src/Types.ts +++ b/addons/addon-webgl/src/Types.ts @@ -68,11 +68,11 @@ export interface ITextureAtlas extends IDisposable { warmUp(): void; /** - * Incremented whenever cached glyph texture page indexes or coordinates may be stale. - * Each renderer tracks its own last-seen value so shared atlas changes are observed + * Incremented whenever atlas page indexes or coordinates cached by renderers may be stale. + * Each renderer tracks its own last-seen value so shared atlas layout changes are observed * independently by every terminal. */ - readonly pagesVersion: number; + readonly pageLayoutVersion: number; /** * Clear all glyphs from the texture atlas. From 9559005bbce80f7f7f10c20e9e9983e33c4e9198 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 9 Jul 2026 11:39:38 -0700 Subject: [PATCH 10/36] wroding --- addons/addon-webgl/src/GlyphRenderer.ts | 4 ++++ addons/addon-webgl/src/Types.ts | 7 ++++--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index dcba0b4b8..720427723 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -214,6 +214,10 @@ export class GlyphRenderer extends Disposable { this.handleResize(); } + /** + * Call when a frame is being drawn. Returns whether the full model must be rebuilt before + * rendering this frame because the atlas page layout changed since this renderer last drew. + */ public beginFrame(): boolean { if (!this._atlas) { return true; diff --git a/addons/addon-webgl/src/Types.ts b/addons/addon-webgl/src/Types.ts index 0a46a0071..bfd6fd76c 100644 --- a/addons/addon-webgl/src/Types.ts +++ b/addons/addon-webgl/src/Types.ts @@ -68,9 +68,10 @@ export interface ITextureAtlas extends IDisposable { warmUp(): void; /** - * Incremented whenever atlas page indexes or coordinates cached by renderers may be stale. - * Each renderer tracks its own last-seen value so shared atlas layout changes are observed - * independently by every terminal. + * Incremented whenever cached glyph texture page mappings may be stale, such as after atlas page + * merges or overflow page creation. Renderers compare this against their own last-seen value and + * rebuild their model when it changes; a shared atlas can have many renderers, so this must not + * be a consume-once flag. */ readonly pageLayoutVersion: number; From 8237004d5c948f2fbf8bbc659dca9b52109b6df3 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 9 Jul 2026 15:51:27 -0700 Subject: [PATCH 11/36] Add WebGL atlas page overflow repros --- .../test/WebglAtlasOverflow.test.ts | 486 ++++++++++++++++++ 1 file changed, 486 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..5ab1961bf --- /dev/null +++ b/addons/addon-webgl/test/WebglAtlasOverflow.test.ts @@ -0,0 +1,486 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IImage32, decodePng } from '@lunapaint/png-codec'; +import test, { expect } from '@playwright/test'; +import type { Terminal, ITerminalInitOnlyOptions, ITerminalOptions } from '@xterm/xterm'; +import type { IWebglAddonOptions, WebglAddon } from '@xterm/addon-webgl'; +import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils'; + +type CellSignature = number[]; +type TestTerminalConstructor = new (options?: ITerminalOptions & ITerminalInitOnlyOptions) => ITestTerminal; +type TestWebglAddonConstructor = new (options?: IWebglAddonOptions) => ITestWebglAddon; + +interface ITestTextureAtlasConstructor { + maxAtlasPages: number | undefined; + maxTextureSize: number | undefined; +} + +interface ITestAtlasPage { + canvas: HTMLCanvasElement; +} + +interface ITestTextureAtlas { + constructor: ITestTextureAtlasConstructor; + pages: ITestAtlasPage[]; + pageLayoutVersion: number; + _overflowSizePage?: ITestAtlasPage; + _textureSize: number; +} + +interface ITestGlyphRenderer { + _atlasTextures: unknown[]; +} + +interface ITestRenderer { + _charAtlas?: ITestTextureAtlas; + _glyphRenderer?: { + value?: ITestGlyphRenderer; + }; + dimensions: { + device: { + cell: { + width: number; + }; + }; + }; +} + +interface ITestRenderService { + _renderer?: { + value?: ITestRenderer; + }; +} + +interface ITestTerminal extends Terminal { + _core?: { + _renderService?: ITestRenderService; + }; +} + +interface ITestWebglAddon extends WebglAddon { + _renderer?: ITestRenderer; +} + +interface IAtlasEventStats { + additions: number; + removals: number; + maxPages: number; +} + +interface ITestWindow extends Window { + Terminal: TestTerminalConstructor; + WebglAddon: TestWebglAddonConstructor; + term: ITestTerminal; + termB?: ITestTerminal; + addon?: ITestWebglAddon; + addonB?: ITestWebglAddon; + atlasEventStats?: IAtlasEventStats; +} + +interface IAtlasLimits { + maxAtlasPages: number; + maxTextureSize: number; +} + +interface IAtlasState { + pages: number; + atlasTextures: number; + pageLayoutVersion: number; + overflowPageCreated: boolean; + additions: number; + removals: number; + maxPages: number; +} + +const NORMAL_HEADER = ' NORMAL_REF_0123456789'; +const POST_EVICTION_TEXT = ' AFTER_EVICTION_0123456789'; +const NORMAL_HEADER_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]; +const POST_EVICTION_COLS = [2, 3, 4, 5, 6, 7, 8, 9, 10, 11]; +const TERM_B_SELECTOR = '#terminal-container-b .xterm-screen'; +const TERM_SELECTOR = '#terminal-container .xterm-screen'; + +async function loadWebglStrict(ctx: ITestContext): Promise { + await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + w.addon = new w.WebglAddon({ preserveDrawingBuffer: true }); + w.term.loadAddon(w.addon); + }); + const isWebglRenderer = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + return !!w.addon && w.term._core?._renderService?._renderer?.value === w.addon._renderer; + }); + expect(isWebglRenderer, 'WebGL renderer must be active').toBe(true); +} + +async function createTerminalB(ctx: ITestContext, cols: number): Promise { + await ctx.page.evaluate(columnCount => { + const w = window as unknown as ITestWindow; + w.termB?.dispose(); + document.getElementById('terminal-container-b')?.remove(); + const element = document.createElement('div'); + element.id = 'terminal-container-b'; + document.body.appendChild(element); + w.termB = new w.Terminal({ cols: columnCount, rows: 24, allowProposedApi: true }); + w.termB.open(element); + w.addonB = new w.WebglAddon({ preserveDrawingBuffer: true }); + w.termB.loadAddon(w.addonB); + }, cols); +} + +async function writeToBAndWaitForRender(ctx: ITestContext, data: string): Promise { + await ctx.page.evaluate(value => new Promise(resolve => { + const w = window as unknown as ITestWindow; + if (!w.termB) { + throw new Error('Terminal B must be created before writing to it'); + } + const disposable = w.termB.onRender(() => { + disposable.dispose(); + resolve(); + }); + w.termB.write(value); + }), data); +} + +async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise { + const renderPromise = new Promise(resolve => { + const disposable = ctx.proxy.onRender(() => { + disposable.dispose(); + resolve(); + }); + }); + await ctx.proxy.write(data); + await renderPromise; +} + +async function refreshAndWaitForRender(ctx: ITestContext, terminal: 'term' | 'termB'): Promise { + await ctx.page.evaluate(name => new Promise(resolve => { + const w = window as unknown as ITestWindow; + const term = w[name]; + if (!term) { + throw new Error(`${name} must exist before refreshing it`); + } + const disposable = term.onRender(() => { + disposable.dispose(); + resolve(); + }); + term.refresh(0, term.rows - 1); + }), terminal); +} + +async function configureAtlasLimits(ctx: ITestContext): Promise { + const limits = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas; + if (!atlas || atlas.constructor.maxAtlasPages === undefined || atlas.constructor.maxTextureSize === undefined) { + return undefined; + } + const result = { + maxAtlasPages: atlas.constructor.maxAtlasPages, + maxTextureSize: atlas.constructor.maxTextureSize + }; + atlas.constructor.maxAtlasPages = 4; + atlas.constructor.maxTextureSize = 512; + return result; + }); + expect(limits, 'TextureAtlas limits must be initialized').toBeDefined(); + return limits!; +} + +async function restoreAtlasLimits(ctx: ITestContext, limits: IAtlasLimits | undefined): Promise { + if (!limits) { + return; + } + await ctx.page.evaluate(original => { + const w = window as unknown as ITestWindow; + const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas; + if (atlas) { + atlas.constructor.maxAtlasPages = original.maxAtlasPages; + atlas.constructor.maxTextureSize = original.maxTextureSize; + } + }, limits); +} + +async function installAtlasEventStats(ctx: ITestContext): Promise { + const installed = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas; + if (!atlas || !w.addon) { + return false; + } + const stats: IAtlasEventStats = { + additions: 0, + removals: 0, + maxPages: atlas.pages.length + }; + w.atlasEventStats = stats; + w.addon.onAddTextureAtlasCanvas(() => { + stats.additions++; + stats.maxPages = Math.max(stats.maxPages, atlas.pages.length); + }); + w.addon.onRemoveTextureAtlasCanvas(() => stats.removals++); + return true; + }); + expect(installed, 'atlas event tracking must be installed').toBe(true); +} + +async function getAtlasState(ctx: ITestContext): Promise { + const state = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + const renderer = w.term._core?._renderService?._renderer?.value; + const atlas = renderer?._charAtlas; + const glyphRenderer = renderer?._glyphRenderer?.value; + const stats = w.atlasEventStats; + if (!atlas || !glyphRenderer || !stats) { + return undefined; + } + return { + pages: atlas.pages.length, + atlasTextures: glyphRenderer._atlasTextures.length, + pageLayoutVersion: atlas.pageLayoutVersion, + overflowPageCreated: !!atlas._overflowSizePage, + additions: stats.additions, + removals: stats.removals, + maxPages: stats.maxPages + }; + }); + expect(state, 'atlas state must be available').toBeDefined(); + return state!; +} + +function generateUniqueGlyphFlood(count: number, cols: number, offset: number): string { + const base = 0x4E00; + const range = 0x9FFF - base; + const perRow = Math.max(1, Math.floor(cols / 2)); + let result = ''; + for (let i = 0; i < count; i++) { + result += String.fromCodePoint(base + ((offset + i) % range)); + if ((i + 1) % perRow === 0 && i + 1 < count) { + result += '\r\n'; + } + } + return result; +} + +async function captureRowSignatures(ctx: ITestContext, selector: string, cols: number, row: number, sampleCols: number[], debugName: string): Promise { + const screenshotOptions = process.env.DEBUG ? { path: `out-esbuild-test/playwright/${debugName}.png` } : undefined; + const buffer = await ctx.page.locator(selector).screenshot(screenshotOptions); + const frame = { + cols, + rows: 24, + decoded: (await decodePng(new Uint8Array(buffer), { force32: true })).image + }; + return sampleCols.map(col => cellSignature(frame, col, row)); +} + +function cellSignature(frame: { cols: number, rows: number, decoded: IImage32 }, col: number, row: number): CellSignature { + const grid = 4; + const data = frame.decoded.data; + const imageWidth = frame.decoded.width; + const cellWidth = frame.decoded.width / frame.cols; + const cellHeight = frame.decoded.height / frame.rows; + const x0 = (col - 1) * cellWidth; + const y0 = (row - 1) * cellHeight; + const sums = new Array(grid * grid * 4).fill(0); + const counts = new Array(grid * grid).fill(0); + const startX = Math.max(0, Math.floor(x0)); + const startY = Math.max(0, Math.floor(y0)); + const endX = Math.min(frame.decoded.width, Math.ceil(x0 + cellWidth)); + const endY = Math.min(frame.decoded.height, Math.ceil(y0 + cellHeight)); + + for (let y = startY; y < endY; y++) { + const gy = Math.min(grid - 1, Math.max(0, Math.floor(((y - y0) / cellHeight) * grid))); + for (let x = startX; x < endX; x++) { + const gx = Math.min(grid - 1, Math.max(0, Math.floor(((x - x0) / cellWidth) * grid))); + const sub = gy * grid + gx; + const i = (y * imageWidth + x) * 4; + sums[sub * 4] += data[i]; + sums[sub * 4 + 1] += data[i + 1]; + sums[sub * 4 + 2] += data[i + 2]; + sums[sub * 4 + 3] += data[i + 3]; + counts[sub]++; + } + } + + const signature = new Array(grid * grid * 4).fill(0); + for (let sub = 0; sub < counts.length; sub++) { + if (counts[sub] > 0) { + signature[sub * 4] = sums[sub * 4] / counts[sub]; + signature[sub * 4 + 1] = sums[sub * 4 + 1] / counts[sub]; + signature[sub * 4 + 2] = sums[sub * 4 + 2] / counts[sub]; + signature[sub * 4 + 3] = sums[sub * 4 + 3] / counts[sub]; + } + } + return signature; +} + +function signatureDiff(actual: CellSignature, reference: CellSignature): number { + let diff = 0; + for (let i = 0; i < actual.length; i++) { + diff = Math.max(diff, Math.abs(actual[i] - reference[i])); + } + return diff; +} + +function expectSignatureMatches(actual: CellSignature, reference: CellSignature, message: string): void { + const diff = signatureDiff(actual, reference); + expect(diff, `${message}; max channel diff ${diff}`).toBeLessThanOrEqual(14); +} + +test.describe('atlas page overflow (#6038)', () => { + test.skip(({ browserName }) => browserName !== 'chromium'); + test.describe.configure({ timeout: 90000 }); + + test('evicts normal pages when the page cap cannot be reduced by merging', async ({ browser }) => { + const ctx = await createTestContext(browser); + const errors: string[] = []; + const onError = (error: Error): void => { errors.push(error.message); }; + let limits: IAtlasLimits | undefined; + ctx.page.on('pageerror', onError); + try { + await openTerminal(ctx, { cols: 80, rows: 24 }); + await loadWebglStrict(ctx); + limits = await configureAtlasLimits(ctx); + + await openTerminal(ctx, { cols: 80, rows: 24 }); + await loadWebglStrict(ctx); + await createTerminalB(ctx, 80); + await installAtlasEventStats(ctx); + + const atlasShared = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + return w.term._core?._renderService?._renderer?.value?._charAtlas === w.termB?._core?._renderService?._renderer?.value?._charAtlas; + }); + expect(atlasShared, 'terminals with equal configs should share one texture atlas').toBe(true); + + await writeToBAndWaitForRender(ctx, `\x1b[H${NORMAL_HEADER}\x1b[2;1H${POST_EVICTION_TEXT}`); + const headerReference = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 1, NORMAL_HEADER_COLS, 'atlas-overflow-header-reference'); + const postReference = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 2, POST_EVICTION_COLS, 'atlas-overflow-post-reference'); + await writeToBAndWaitForRender(ctx, '\x1b[2;1H\x1b[2K'); + + const initialState = await getAtlasState(ctx); + const glyphsPerChunk = 23 * 40 - 1; + for (let chunk = 0; chunk < 24; chunk++) { + await ctx.proxy.write('\x1b[H\x1b[2J' + generateUniqueGlyphFlood(glyphsPerChunk, 80, chunk * glyphsPerChunk)); + await ctx.page.waitForTimeout(50); + const state = await getAtlasState(ctx); + if (state.removals > 0 || state.pages > state.atlasTextures || errors.length > 0) { + break; + } + } + + const evictionState = await getAtlasState(ctx); + expect(evictionState.removals, 'normal atlas pages must be evicted at the page cap').toBeGreaterThan(0); + expect(evictionState.pageLayoutVersion, 'eviction must invalidate renderer models').toBeGreaterThan(initialState.pageLayoutVersion); + expect(evictionState.maxPages, 'atlas pages must stay within renderer texture capacity').toBeLessThanOrEqual(evictionState.atlasTextures); + expect(errors, `renderer must not throw during atlas eviction: ${errors[0] ?? ''}`).toEqual([]); + + await writeToBAndWaitForRender(ctx, `\x1b[2;1H${POST_EVICTION_TEXT}`); + const termBHeader = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + return w.termB?.buffer.active.getLine(0)?.translateToString(true); + }); + expect(termBHeader, 'terminal B buffer must still contain the header').toBe(NORMAL_HEADER); + + const headerAfter = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 1, NORMAL_HEADER_COLS, 'atlas-overflow-header-after'); + const postAfter = await captureRowSignatures(ctx, TERM_B_SELECTOR, 80, 2, POST_EVICTION_COLS, 'atlas-overflow-post-after'); + for (let i = 0; i < NORMAL_HEADER_COLS.length; i++) { + expectSignatureMatches(headerAfter[i], headerReference[i], `existing header col ${NORMAL_HEADER_COLS[i]} changed after eviction`); + } + for (let i = 0; i < POST_EVICTION_COLS.length; i++) { + expectSignatureMatches(postAfter[i], postReference[i], `new text col ${POST_EVICTION_COLS[i]} changed after eviction`); + } + + const stableState = await getAtlasState(ctx); + await refreshAndWaitForRender(ctx, 'termB'); + const refreshedState = await getAtlasState(ctx); + expect(refreshedState.pageLayoutVersion, 'an unchanged full refresh must not evict again').toBe(stableState.pageLayoutVersion); + expect(refreshedState.removals, 'an unchanged full refresh must not remove more pages').toBe(stableState.removals); + } finally { + ctx.page.off('pageerror', onError); + await restoreAtlasLimits(ctx, limits).catch(() => {}); + await ctx.page.close(); + } + }); + + test('evicts before adding an oversized glyph page at the page cap', async ({ browser }) => { + const ctx = await createTestContext(browser); + const errors: string[] = []; + const onError = (error: Error): void => { errors.push(error.message); }; + let limits: IAtlasLimits | undefined; + ctx.page.on('pageerror', onError); + try { + await openTerminal(ctx, { cols: 160, rows: 24 }); + await loadWebglStrict(ctx); + limits = await configureAtlasLimits(ctx); + + await openTerminal(ctx, { cols: 160, rows: 24 }); + await loadWebglStrict(ctx); + await installAtlasEventStats(ctx); + + let state = await getAtlasState(ctx); + let offset = 0; + for (let chunk = 0; chunk < 120 && state.pages < state.atlasTextures; chunk++) { + const glyphCount = state.pages < state.atlasTextures - 1 ? 200 : 25; + await ctx.proxy.write('\x1b[H\x1b[2J' + generateUniqueGlyphFlood(glyphCount, 160, offset)); + await ctx.page.waitForTimeout(50); + offset += glyphCount; + state = await getAtlasState(ctx); + } + expect(state.pages, 'normal atlas pages must reach the texture capacity before the oversized glyph').toBe(state.atlasTextures); + expect(state.overflowPageCreated, 'normal glyph filling must not create the oversized page').toBe(false); + expect(errors, `renderer must not fail while filling normal pages: ${errors[0] ?? ''}`).toEqual([]); + + await writeAndWaitForRender(ctx, '\x1b[H\x1b[2K\x1b[24;1H'); + const sampleCols = [1, 8, 16, 24, 32]; + const blankSignatures = await captureRowSignatures(ctx, TERM_SELECTOR, 160, 1, sampleCols, 'atlas-overflow-wide-blank'); + const joinedLength = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + const renderer = w.term._core?._renderService?._renderer?.value; + const atlas = renderer?._charAtlas; + if (!renderer || !atlas) { + throw new Error('renderer and atlas must exist'); + } + const cellWidth = renderer.dimensions.device.cell.width; + const length = Math.min(w.term.cols - 1, Math.ceil(atlas._textureSize / cellWidth) + 32); + if (length * cellWidth <= atlas._textureSize) { + throw new Error('joined glyph must be wider than a normal atlas page'); + } + const marker = 'W'.repeat(length); + w.term.registerCharacterJoiner(text => text.startsWith(marker) ? [[0, length]] : []); + return length; + }); + + const beforeOversized = await getAtlasState(ctx); + await ctx.proxy.write(`\x1b[H${'W'.repeat(joinedLength)}`); + await ctx.page.waitForTimeout(50); + const oversizedState = await getAtlasState(ctx); + expect(oversizedState.overflowPageCreated, 'the joined glyph must use the oversized atlas page').toBe(true); + expect(oversizedState.removals, 'normal pages must be evicted before adding the oversized page').toBeGreaterThan(beforeOversized.removals); + expect(oversizedState.maxPages, 'oversized page creation must stay within renderer texture capacity').toBeLessThanOrEqual(oversizedState.atlasTextures); + expect(oversizedState.pageLayoutVersion, 'oversized eviction must invalidate renderer models').toBeGreaterThan(beforeOversized.pageLayoutVersion); + expect(errors, `renderer must not throw while adding the oversized page: ${errors[0] ?? ''}`).toEqual([]); + + const wideSignatures = await captureRowSignatures(ctx, TERM_SELECTOR, 160, 1, sampleCols, 'atlas-overflow-wide-after'); + for (let i = 0; i < sampleCols.length; i++) { + expect(signatureDiff(wideSignatures[i], blankSignatures[i]), `wide glyph col ${sampleCols[i]} must render visible pixels`).toBeGreaterThan(14); + } + + const stableState = await getAtlasState(ctx); + await refreshAndWaitForRender(ctx, 'term'); + const refreshedState = await getAtlasState(ctx); + expect(refreshedState.pageLayoutVersion, 'oversized glyph refresh must settle without another eviction').toBe(stableState.pageLayoutVersion); + expect(refreshedState.removals, 'oversized glyph refresh must not remove more pages').toBe(stableState.removals); + const wideAfterRefresh = await captureRowSignatures(ctx, TERM_SELECTOR, 160, 1, sampleCols, 'atlas-overflow-wide-refreshed'); + for (let i = 0; i < sampleCols.length; i++) { + expectSignatureMatches(wideAfterRefresh[i], wideSignatures[i], `wide glyph col ${sampleCols[i]} changed after refresh`); + } + } finally { + ctx.page.off('pageerror', onError); + await restoreAtlasLimits(ctx, limits).catch(() => {}); + await ctx.page.close(); + } + }); +}); From 2b982bc39c74cfec389d51084587a3c39235a0fb Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Thu, 9 Jul 2026 15:54:33 -0700 Subject: [PATCH 12/36] Prevent WebGL atlas page overflow --- addons/addon-webgl/src/GlyphRenderer.ts | 8 +++- addons/addon-webgl/src/TextureAtlas.ts | 63 +++++++++++++++---------- 2 files changed, 45 insertions(+), 26 deletions(-) diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index 720427723..c8ab2f344 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -101,6 +101,7 @@ export class GlyphRenderer extends Disposable { private _atlas: ITextureAtlas | undefined; private _lastSeenPageLayoutVersion: number = -1; + private _pageOverflowWarned: boolean = false; private _activeBuffer: number = 0; private readonly _vertices: IVertices = { count: 0, @@ -375,7 +376,12 @@ export class GlyphRenderer extends Disposable { // Bind the atlas page texture if they have changed. AtlasPage.version is globally // monotonic, so a page object swap at the same index (which happens after a page merge) // is detected by the same comparison. - for (let i = 0; i < this._atlas.pages.length; i++) { + const pageCount = Math.min(this._atlas.pages.length, this._atlasTextures.length); + if (this._atlas.pages.length > this._atlasTextures.length && !this._pageOverflowWarned) { + this._pageOverflowWarned = true; + this._logService.warn(`Atlas page count (${this._atlas.pages.length}) exceeds the renderer's texture capacity (${this._atlasTextures.length}), some glyphs will not render correctly`); + } + for (let i = 0; i < pageCount; i++) { if (this._atlas.pages[i].version !== this._atlasTextures[i].version) { this._bindAtlasPageTexture(gl, this._atlas, i); } diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index 5368c9b46..fdcb612de 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -152,7 +152,7 @@ export class TextureAtlas implements ITextureAtlas { // microtask to ensure it does not interrupt textures that will be rendered in the current // animation frame which would result in blank rendered areas. This is actually not that // expensive relative to drawing the glyphs, so there is no need to wait for an idle callback. - if (TextureAtlas.maxAtlasPages && this._pages.length >= Math.max(4, TextureAtlas.maxAtlasPages)) { + if (TextureAtlas.maxAtlasPages && this._pages.length >= TextureAtlas.maxAtlasPages) { // Find the set of the largest 4 images, below the maximum size, with the highest // percentages used const pagesBySize = this._pages.filter(e => { @@ -180,31 +180,27 @@ export class TextureAtlas implements ITextureAtlas { // Only proceed with merge if we have exactly 4 same-sized pages. If not, we cannot // effectively reduce page count and merging would cause issues. if (mergingPages.length < 4 || mergingPages.some(p => p.canvas.width !== mergingPages[0].canvas.width)) { - const newPage = new AtlasPage(this._document, this._textureSize); - this._pages.push(newPage); - this._activePages.push(newPage); - this._onAddTextureAtlasCanvas.fire(newPage.canvas); - return newPage; + this._evictAllPages(); + } else { + const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a - b); + const mergedPageIndex = this.pages.length - mergingPages.length; + + // Merge into the new page + const mergedPage = this._mergePages(mergingPages, mergedPageIndex); + mergedPage.version = ++AtlasPage.nextVersion; + + // Delete the pages, shifting glyph texture pages as needed + for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) { + this._deletePage(sortedMergingPagesIndexes[i]); + } + + // Add the new merged page to the end + this.pages.push(mergedPage); + + // Request the model to be cleared to refresh all texture pages. + this._pageLayoutVersion++; + this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); } - - const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a - b); - const mergedPageIndex = this.pages.length - mergingPages.length; - - // Merge into the new page - const mergedPage = this._mergePages(mergingPages, mergedPageIndex); - mergedPage.version = ++AtlasPage.nextVersion; - - // Delete the pages, shifting glyph texture pages as needed - for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) { - this._deletePage(sortedMergingPagesIndexes[i]); - } - - // Add the new merged page to the end - this.pages.push(mergedPage); - - // Request the model to be cleared to refresh all texture pages. - this._pageLayoutVersion++; - this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); } // All new atlas pages are created small as they are highly dynamic @@ -254,6 +250,20 @@ export class TextureAtlas implements ITextureAtlas { } } + private _evictAllPages(): void { + for (const page of this._pages) { + this._onRemoveTextureAtlasCanvas.fire(page.canvas); + page.canvas.remove(); + } + this._pages.length = 0; + this._activePages.length = 0; + this._overflowSizePage = undefined; + this._cacheMap.clear(); + this._cacheMapCombined.clear(); + this._didWarmUp = false; + this._pageLayoutVersion++; + } + public getRasterizedGlyphCombinedChar(chars: string, bg: number, fg: number, ext: number, restrictToCellHeight: boolean, domContainer: HTMLElement | undefined): IRasterizedGlyph { return this._getFromCacheMap(this._cacheMapCombined, chars, bg, fg, ext, restrictToCellHeight, domContainer); } @@ -811,6 +821,9 @@ export class TextureAtlas implements ITextureAtlas { // Create a new page for oversized glyphs as they come up if (rasterizedGlyph.size.x > this._textureSize) { if (!this._overflowSizePage) { + if (TextureAtlas.maxAtlasPages && this._pages.length >= TextureAtlas.maxAtlasPages) { + this._evictAllPages(); + } this._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize); this.pages.push(this._overflowSizePage); From 6791a0abea88187e13698abcb727205c53794cc9 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Mon, 13 Jul 2026 10:58:05 -0700 Subject: [PATCH 13/36] Log to measure eviction time --- addons/addon-webgl/src/TextureAtlas.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index fdcb612de..f15f3cec1 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -251,6 +251,8 @@ export class TextureAtlas implements ITextureAtlas { } private _evictAllPages(): void { + const startTime = performance.now(); + const pageCount = this._pages.length; for (const page of this._pages) { this._onRemoveTextureAtlasCanvas.fire(page.canvas); page.canvas.remove(); @@ -262,6 +264,7 @@ export class TextureAtlas implements ITextureAtlas { this._cacheMapCombined.clear(); this._didWarmUp = false; this._pageLayoutVersion++; + this._logService.debug(`Evicted ${pageCount} WebGL atlas pages in ${(performance.now() - startTime).toFixed(2)}ms`); } public getRasterizedGlyphCombinedChar(chars: string, bg: number, fg: number, ext: number, restrictToCellHeight: boolean, domContainer: HTMLElement | undefined): IRasterizedGlyph { From 06ce754fcc9d9cceaf8193ffb83979b874b4ebd3 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Mon, 13 Jul 2026 11:10:08 -0700 Subject: [PATCH 14/36] Don't mess with formatting --- addons/addon-webgl/src/TextureAtlas.ts | 45 ++++++++++++++------------ 1 file changed, 25 insertions(+), 20 deletions(-) diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index f15f3cec1..436aa553b 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -152,7 +152,7 @@ export class TextureAtlas implements ITextureAtlas { // microtask to ensure it does not interrupt textures that will be rendered in the current // animation frame which would result in blank rendered areas. This is actually not that // expensive relative to drawing the glyphs, so there is no need to wait for an idle callback. - if (TextureAtlas.maxAtlasPages && this._pages.length >= TextureAtlas.maxAtlasPages) { + if (TextureAtlas.maxAtlasPages && this._pages.length >= Math.max(4, TextureAtlas.maxAtlasPages)) { // Find the set of the largest 4 images, below the maximum size, with the highest // percentages used const pagesBySize = this._pages.filter(e => { @@ -181,26 +181,31 @@ export class TextureAtlas implements ITextureAtlas { // effectively reduce page count and merging would cause issues. if (mergingPages.length < 4 || mergingPages.some(p => p.canvas.width !== mergingPages[0].canvas.width)) { this._evictAllPages(); - } else { - const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a - b); - const mergedPageIndex = this.pages.length - mergingPages.length; - - // Merge into the new page - const mergedPage = this._mergePages(mergingPages, mergedPageIndex); - mergedPage.version = ++AtlasPage.nextVersion; - - // Delete the pages, shifting glyph texture pages as needed - for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) { - this._deletePage(sortedMergingPagesIndexes[i]); - } - - // Add the new merged page to the end - this.pages.push(mergedPage); - - // Request the model to be cleared to refresh all texture pages. - this._pageLayoutVersion++; - this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); + const newPage = new AtlasPage(this._document, this._textureSize); + this._pages.push(newPage); + this._activePages.push(newPage); + this._onAddTextureAtlasCanvas.fire(newPage.canvas); + return newPage; } + + const sortedMergingPagesIndexes = mergingPages.map(e => e.glyphs[0].texturePage).sort((a, b) => a - b); + const mergedPageIndex = this.pages.length - mergingPages.length; + + // Merge into the new page + const mergedPage = this._mergePages(mergingPages, mergedPageIndex); + mergedPage.version = ++AtlasPage.nextVersion; + + // Delete the pages, shifting glyph texture pages as needed + for (let i = sortedMergingPagesIndexes.length - 1; i >= 0; i--) { + this._deletePage(sortedMergingPagesIndexes[i]); + } + + // Add the new merged page to the end + this.pages.push(mergedPage); + + // Request the model to be cleared to refresh all texture pages. + this._pageLayoutVersion++; + this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); } // All new atlas pages are created small as they are highly dynamic From 2ae6e3a6031a3358fe355b316dcefa5a23179d2e Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Mon, 13 Jul 2026 11:23:39 -0700 Subject: [PATCH 15/36] Useful comments. --- addons/addon-webgl/src/GlyphRenderer.ts | 1 + addons/addon-webgl/src/TextureAtlas.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index c8ab2f344..742f0879f 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -376,6 +376,7 @@ export class GlyphRenderer extends Disposable { // Bind the atlas page texture if they have changed. AtlasPage.version is globally // monotonic, so a page object swap at the same index (which happens after a page merge) // is detected by the same comparison. + // Clamp defensively in case the atlas unexpectedly exceeds texture capacity. const pageCount = Math.min(this._atlas.pages.length, this._atlasTextures.length); if (this._atlas.pages.length > this._atlasTextures.length && !this._pageOverflowWarned) { this._pageOverflowWarned = true; diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index 436aa553b..40d82df49 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -180,6 +180,7 @@ export class TextureAtlas implements ITextureAtlas { // Only proceed with merge if we have exactly 4 same-sized pages. If not, we cannot // effectively reduce page count and merging would cause issues. if (mergingPages.length < 4 || mergingPages.some(p => p.canvas.width !== mergingPages[0].canvas.width)) { + // Evict instead of adding a page beyond the renderer's texture capacity. this._evictAllPages(); const newPage = new AtlasPage(this._document, this._textureSize); this._pages.push(newPage); @@ -829,6 +830,7 @@ export class TextureAtlas implements ITextureAtlas { // Create a new page for oversized glyphs as they come up if (rasterizedGlyph.size.x > this._textureSize) { if (!this._overflowSizePage) { + // Make room for the oversized page without exceeding texture capacity. if (TextureAtlas.maxAtlasPages && this._pages.length >= TextureAtlas.maxAtlasPages) { this._evictAllPages(); } From 2ea67f9ea8e050f59f25773fdbe14633bdd73801 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Mon, 13 Jul 2026 11:49:25 -0700 Subject: [PATCH 16/36] Clean up WebGL atlas overflow coverage --- addons/addon-webgl/src/TextureAtlas.ts | 4 ++-- addons/addon-webgl/test/WebglAtlasOverflow.test.ts | 5 ----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index 40d82df49..dba1ad474 100644 --- a/addons/addon-webgl/src/TextureAtlas.ts +++ b/addons/addon-webgl/src/TextureAtlas.ts @@ -204,7 +204,7 @@ export class TextureAtlas implements ITextureAtlas { // Add the new merged page to the end this.pages.push(mergedPage); - // Request the model to be cleared to refresh all texture pages. + // Invalidate renderer models so all texture pages are refreshed. this._pageLayoutVersion++; this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); } @@ -837,7 +837,7 @@ export class TextureAtlas implements ITextureAtlas { this._overflowSizePage = new AtlasPage(this._document, this._config.deviceMaxTextureSize); this.pages.push(this._overflowSizePage); - // Request the model to be cleared to refresh all texture pages. + // Invalidate renderer models so all texture pages are refreshed. this._pageLayoutVersion++; this._onAddTextureAtlasCanvas.fire(this._overflowSizePage.canvas); } diff --git a/addons/addon-webgl/test/WebglAtlasOverflow.test.ts b/addons/addon-webgl/test/WebglAtlasOverflow.test.ts index 5ab1961bf..1252c81cf 100644 --- a/addons/addon-webgl/test/WebglAtlasOverflow.test.ts +++ b/addons/addon-webgl/test/WebglAtlasOverflow.test.ts @@ -65,7 +65,6 @@ interface ITestWebglAddon extends WebglAddon { } interface IAtlasEventStats { - additions: number; removals: number; maxPages: number; } @@ -90,7 +89,6 @@ interface IAtlasState { atlasTextures: number; pageLayoutVersion: number; overflowPageCreated: boolean; - additions: number; removals: number; maxPages: number; } @@ -211,13 +209,11 @@ async function installAtlasEventStats(ctx: ITestContext): Promise { return false; } const stats: IAtlasEventStats = { - additions: 0, removals: 0, maxPages: atlas.pages.length }; w.atlasEventStats = stats; w.addon.onAddTextureAtlasCanvas(() => { - stats.additions++; stats.maxPages = Math.max(stats.maxPages, atlas.pages.length); }); w.addon.onRemoveTextureAtlasCanvas(() => stats.removals++); @@ -241,7 +237,6 @@ async function getAtlasState(ctx: ITestContext): Promise { atlasTextures: glyphRenderer._atlasTextures.length, pageLayoutVersion: atlas.pageLayoutVersion, overflowPageCreated: !!atlas._overflowSizePage, - additions: stats.additions, removals: stats.removals, maxPages: stats.maxPages }; From 0b1c0b5c5a9e159a41d326eedbc3bfe0282783d4 Mon Sep 17 00:00:00 2001 From: Enes Uysal Date: Thu, 16 Jul 2026 18:38:24 +0300 Subject: [PATCH 17/36] 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(); + } + }); }); From 5b00193876c00272a22b9f29bd2eb76f4b33d616 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Fri, 17 Jul 2026 11:17:30 -0700 Subject: [PATCH 18/36] adjust button style --- .../addon-webgl/test/WebglAtlasStress.test.ts | 288 ++++++++++++++++++ demo/client/components/window/webglWindow.ts | 14 +- 2 files changed, 296 insertions(+), 6 deletions(-) create mode 100644 addons/addon-webgl/test/WebglAtlasStress.test.ts diff --git a/addons/addon-webgl/test/WebglAtlasStress.test.ts b/addons/addon-webgl/test/WebglAtlasStress.test.ts new file mode 100644 index 000000000..789635614 --- /dev/null +++ b/addons/addon-webgl/test/WebglAtlasStress.test.ts @@ -0,0 +1,288 @@ +/** + * Copyright (c) 2025 The xterm.js authors. All rights reserved. + * @license MIT + */ + +import { IImage32, decodePng } from '@lunapaint/png-codec'; +import test, { expect } from '@playwright/test'; +import type { Terminal, ITerminalInitOnlyOptions, ITerminalOptions } from '@xterm/xterm'; +import type { IWebglAddonOptions, WebglAddon } from '@xterm/addon-webgl'; +import { ITestContext, createTestContext, openTerminal } from '../../../test/playwright/TestUtils'; + +type CellSignature = number[]; +type TestTerminalConstructor = new (options?: ITerminalOptions & ITerminalInitOnlyOptions) => ITestTerminal; +type TestWebglAddonConstructor = new (options?: IWebglAddonOptions) => ITestWebglAddon; + +interface ITestTextureAtlas { + pages: { canvas: HTMLCanvasElement }[]; +} + +interface ITestGlyphRenderer { + _atlasTextures: unknown[]; +} + +interface ITestRenderer { + _charAtlas?: ITestTextureAtlas; + _glyphRenderer?: { + value?: ITestGlyphRenderer; + }; +} + +interface ITestRenderService { + _renderer?: { + value?: ITestRenderer; + }; +} + +interface ITestTerminal extends Terminal { + _core?: { + _renderService?: ITestRenderService; + }; +} + +interface ITestWebglAddon extends WebglAddon { + _renderer?: ITestRenderer; +} + +interface IAtlasEventStats { + additions: number; + maxPages: number; +} + +interface ITestWindow { + WebglAddon: TestWebglAddonConstructor; + term: ITestTerminal; + addon?: ITestWebglAddon; + atlasEventStats?: IAtlasEventStats; +} + +interface IAtlasState { + additions: number; + atlasTextures: number; + maxPages: number; +} + +const REFERENCE_LINE = 'Ref0123456789ABCDEF'; +const REFERENCE_COLS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]; +const TERM_SELECTOR = '#terminal-container .xterm-screen'; + +async function loadWebglStrict(ctx: ITestContext): Promise { + await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + w.addon = new w.WebglAddon({ preserveDrawingBuffer: true }); + w.term.loadAddon(w.addon); + }); + const isWebglRenderer = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + return !!w.addon && w.term._core?._renderService?._renderer?.value === w.addon._renderer; + }); + expect(isWebglRenderer, 'WebGL renderer must be active').toBe(true); +} + +async function installAtlasEventStats(ctx: ITestContext): Promise { + const installed = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + const atlas = w.term._core?._renderService?._renderer?.value?._charAtlas; + if (!atlas || !w.addon) { + return false; + } + const stats: IAtlasEventStats = { + additions: 0, + maxPages: atlas.pages.length + }; + w.atlasEventStats = stats; + w.addon.onAddTextureAtlasCanvas(() => { + stats.additions++; + stats.maxPages = Math.max(stats.maxPages, atlas.pages.length); + }); + return true; + }); + expect(installed, 'atlas event tracking must be installed').toBe(true); +} + +async function getAtlasState(ctx: ITestContext): Promise { + const state = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + const renderer = w.term._core?._renderService?._renderer?.value; + const glyphRenderer = renderer?._glyphRenderer?.value; + const stats = w.atlasEventStats; + if (!glyphRenderer || !stats) { + return undefined; + } + return { + additions: stats.additions, + atlasTextures: glyphRenderer._atlasTextures.length, + maxPages: stats.maxPages + }; + }); + expect(state, 'atlas state must be available').toBeDefined(); + return state!; +} + +async function writeAndWaitForRender(ctx: ITestContext, data: string): Promise { + const renderPromise = new Promise(resolve => { + const disposable = ctx.proxy.onRender(() => { + disposable.dispose(); + resolve(); + }); + }); + await ctx.proxy.write(data); + await renderPromise; +} + +async function refreshAndWaitForRender(ctx: ITestContext): Promise { + const renderPromise = new Promise(resolve => { + const disposable = ctx.proxy.onRender(() => { + disposable.dispose(); + resolve(); + }); + }); + await ctx.proxy.refresh(0, (await ctx.proxy.rows) - 1); + await renderPromise; +} + +function generateSyntheticTuiFrames(frameCount: number, cols: number, rows: number): string[] { + const spinner = ['|', '/', '-', '\\']; + const words = ['reading', 'rendering', 'atlas', 'terminal', 'buffer', 'tokens', 'diff', 'patch']; + const frames: string[] = []; + for (let frameIndex = 0; frameIndex < frameCount; frameIndex++) { + const lines = [ + `+${'-'.repeat(cols - 2)}+`, + `|${(` ${spinner[frameIndex % spinner.length]} Copilot is working`).padEnd(cols - 2).slice(0, cols - 2)}|`, + `+${'-'.repeat(cols - 2)}+` + ]; + for (let row = 3; row < rows - 1; row++) { + const foreground = 31 + ((frameIndex + row) % 6); + const text = `${words[(frameIndex + row) % words.length]} ${frameIndex}:${row}`; + lines.push(`\x1b[${foreground}m${text}\x1b[0m`); + } + lines.push(`\x1b[2mtokens ${1000 + frameIndex * 37}\x1b[0m`); + frames.push(`\x1b[2J\x1b[H${lines.join('\r\n')}`); + } + return frames; +} + +function generateUniqueGlyphFlood(count: number, cols: number, offset: number): string { + const base = 0x4E00; + const range = 0x9FFF - base; + const perRow = Math.max(1, Math.floor(cols / 2)); + let result = ''; + for (let i = 0; i < count; i++) { + result += String.fromCodePoint(base + ((offset + i) % range)); + if ((i + 1) % perRow === 0 && i + 1 < count) { + result += '\r\n'; + } + } + return result; +} + +async function captureRowSignatures(ctx: ITestContext, row: number, cols: number[], debugName: string): Promise { + const screenshotOptions = process.env.DEBUG ? { path: `out-esbuild-test/playwright/${debugName}.png` } : undefined; + const buffer = await ctx.page.locator(TERM_SELECTOR).screenshot(screenshotOptions); + const frame = { + cols: await ctx.proxy.cols, + rows: await ctx.proxy.rows, + decoded: (await decodePng(new Uint8Array(buffer), { force32: true })).image + }; + return cols.map(col => cellSignature(frame, col, row)); +} + +function cellSignature(frame: { cols: number, rows: number, decoded: IImage32 }, col: number, row: number): CellSignature { + const grid = 4; + const data = frame.decoded.data; + const imageWidth = frame.decoded.width; + const cellWidth = frame.decoded.width / frame.cols; + const cellHeight = frame.decoded.height / frame.rows; + const x0 = (col - 1) * cellWidth; + const y0 = (row - 1) * cellHeight; + const sums = new Array(grid * grid * 4).fill(0); + const counts = new Array(grid * grid).fill(0); + const startX = Math.max(0, Math.floor(x0)); + const startY = Math.max(0, Math.floor(y0)); + const endX = Math.min(frame.decoded.width, Math.ceil(x0 + cellWidth)); + const endY = Math.min(frame.decoded.height, Math.ceil(y0 + cellHeight)); + + for (let y = startY; y < endY; y++) { + const gy = Math.min(grid - 1, Math.max(0, Math.floor(((y - y0) / cellHeight) * grid))); + for (let x = startX; x < endX; x++) { + const gx = Math.min(grid - 1, Math.max(0, Math.floor(((x - x0) / cellWidth) * grid))); + const sub = gy * grid + gx; + const i = (y * imageWidth + x) * 4; + sums[sub * 4] += data[i]; + sums[sub * 4 + 1] += data[i + 1]; + sums[sub * 4 + 2] += data[i + 2]; + sums[sub * 4 + 3] += data[i + 3]; + counts[sub]++; + } + } + + const signature = new Array(grid * grid * 4).fill(0); + for (let sub = 0; sub < counts.length; sub++) { + if (counts[sub] > 0) { + signature[sub * 4] = sums[sub * 4] / counts[sub]; + signature[sub * 4 + 1] = sums[sub * 4 + 1] / counts[sub]; + signature[sub * 4 + 2] = sums[sub * 4 + 2] / counts[sub]; + signature[sub * 4 + 3] = sums[sub * 4 + 3] / counts[sub]; + } + } + return signature; +} + +function expectSignatureMatches(actual: CellSignature, reference: CellSignature, message: string): void { + let diff = 0; + for (let i = 0; i < actual.length; i++) { + diff = Math.max(diff, Math.abs(actual[i] - reference[i])); + } + expect(diff, `${message}; max channel diff ${diff}`).toBeLessThanOrEqual(14); +} + +test.describe('WebGL atlas TUI stress (#6038)', () => { + test.skip(({ browserName }) => browserName !== 'chromium', 'WebGL atlas tests run on Chromium/SwiftShader only'); + test.describe.configure({ timeout: 90000 }); + + test('primary-buffer glyphs survive alternate-buffer redraws and atlas page churn', async ({ browser }) => { + const ctx = await createTestContext(browser); + const errors: string[] = []; + const onError = (error: Error): void => { errors.push(error.message); }; + ctx.page.on('pageerror', onError); + try { + await openTerminal(ctx, { cols: 80, rows: 24, scrollback: 5000 }); + await loadWebglStrict(ctx); + await installAtlasEventStats(ctx); + + await writeAndWaitForRender(ctx, REFERENCE_LINE); + const reference = await captureRowSignatures(ctx, 1, REFERENCE_COLS, 'atlas-stress-reference'); + + await writeAndWaitForRender(ctx, '\x1b[?1049h'); + for (const frame of generateSyntheticTuiFrames(30, 80, 24)) { + await writeAndWaitForRender(ctx, frame); + } + const glyphsPerViewport = 23 * 40 - 1; + for (let chunk = 0; chunk < 4; chunk++) { + await writeAndWaitForRender(ctx, `\x1b[2J\x1b[H${generateUniqueGlyphFlood(glyphsPerViewport, 80, chunk * glyphsPerViewport)}`); + } + await writeAndWaitForRender(ctx, '\x1b[?1049l'); + await refreshAndWaitForRender(ctx); + + const state = await getAtlasState(ctx); + expect(state.additions, 'stress workload must add atlas pages').toBeGreaterThan(0); + expect(state.maxPages, 'stress workload must exercise multiple atlas pages').toBeGreaterThan(1); + expect(state.maxPages, 'atlas pages must remain within renderer texture capacity').toBeLessThanOrEqual(state.atlasTextures); + expect(errors, `renderer must not throw during TUI stress: ${errors[0] ?? ''}`).toEqual([]); + + const primaryText = await ctx.page.evaluate(() => { + const w = window as unknown as ITestWindow; + return w.term.buffer.active.getLine(0)?.translateToString(true); + }); + expect(primaryText, 'primary buffer must still contain the reference line').toBe(REFERENCE_LINE); + + const after = await captureRowSignatures(ctx, 1, REFERENCE_COLS, 'atlas-stress-after'); + for (let i = 0; i < REFERENCE_COLS.length; i++) { + expectSignatureMatches(after[i], reference[i], `primary-buffer glyph at col ${REFERENCE_COLS[i]} changed after TUI stress`); + } + } finally { + ctx.page.off('pageerror', onError); + await ctx.page.close(); + } + }); +}); diff --git a/demo/client/components/window/webglWindow.ts b/demo/client/components/window/webglWindow.ts index d7c175c45..90634a105 100644 --- a/demo/client/components/window/webglWindow.ts +++ b/demo/client/components/window/webglWindow.ts @@ -15,28 +15,30 @@ export class WebglWindow extends BaseWindow implements IControlWindow { private _stressScrolling: HTMLInputElement | undefined; public build(container: HTMLElement): void { - const stressDiv = document.createElement('div'); + const stressButtons = document.createElement('div'); const stressStart = document.createElement('button'); stressStart.id = 'stress-start'; stressStart.textContent = 'atlas stress start'; stressStart.addEventListener('click', () => this._stress()); - stressDiv.appendChild(stressStart); + stressButtons.appendChild(stressStart); const stressStop = document.createElement('button'); stressStop.id = 'stress-stop'; stressStop.textContent = 'atlas stress stop'; stressStop.addEventListener('click', () => this._stressRunning = false); - stressDiv.appendChild(stressStop); + stressButtons.appendChild(stressStop); + container.appendChild(stressButtons); + const stressOptions = document.createElement('div'); this._stressScrolling = document.createElement('input'); this._stressScrolling.type = 'checkbox'; this._stressScrolling.id = 'stress-scrolling'; - stressDiv.appendChild(this._stressScrolling); + stressOptions.appendChild(this._stressScrolling); const stressScrollingLabel = document.createElement('label'); stressScrollingLabel.htmlFor = 'stress-scrolling'; stressScrollingLabel.textContent = 'stress scrolling'; - stressDiv.appendChild(stressScrollingLabel); - container.appendChild(stressDiv); + stressOptions.appendChild(stressScrollingLabel); + container.appendChild(stressOptions); const zoomCheckbox = document.createElement('input'); zoomCheckbox.type = 'checkbox'; From 4d93815c36c164ede94c2abda5cc3a7a77558c56 Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Fri, 17 Jul 2026 11:31:12 -0700 Subject: [PATCH 19/36] Improve types in webglatlasstress.test.ts --- addons/addon-webgl/test/WebglAtlasStress.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/addons/addon-webgl/test/WebglAtlasStress.test.ts b/addons/addon-webgl/test/WebglAtlasStress.test.ts index 789635614..e0fc1c906 100644 --- a/addons/addon-webgl/test/WebglAtlasStress.test.ts +++ b/addons/addon-webgl/test/WebglAtlasStress.test.ts @@ -3,7 +3,7 @@ * @license MIT */ -import { IImage32, decodePng } from '@lunapaint/png-codec'; +import { decodePng, type IImage32 } from '@lunapaint/png-codec'; import test, { expect } from '@playwright/test'; import type { Terminal, ITerminalInitOnlyOptions, ITerminalOptions } from '@xterm/xterm'; import type { IWebglAddonOptions, WebglAddon } from '@xterm/addon-webgl'; @@ -14,11 +14,11 @@ type TestTerminalConstructor = new (options?: ITerminalOptions & ITerminalInitOn type TestWebglAddonConstructor = new (options?: IWebglAddonOptions) => ITestWebglAddon; interface ITestTextureAtlas { - pages: { canvas: HTMLCanvasElement }[]; + readonly pages: ReadonlyArray<{ readonly canvas: HTMLCanvasElement }>; } interface ITestGlyphRenderer { - _atlasTextures: unknown[]; + readonly _atlasTextures: ReadonlyArray; } interface ITestRenderer { From abce6f1c62d5702c9b18cf6a022d4d6cb0e65fde Mon Sep 17 00:00:00 2001 From: Anthony Kim Date: Fri, 17 Jul 2026 11:36:15 -0700 Subject: [PATCH 20/36] call scroll output --- demo/client/components/window/webglWindow.ts | 22 ++++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/demo/client/components/window/webglWindow.ts b/demo/client/components/window/webglWindow.ts index 90634a105..12cf07a62 100644 --- a/demo/client/components/window/webglWindow.ts +++ b/demo/client/components/window/webglWindow.ts @@ -12,7 +12,7 @@ export class WebglWindow extends BaseWindow implements IControlWindow { private _textureAtlasContainer!: HTMLElement; private _stressRunning = false; - private _stressScrolling: HTMLInputElement | undefined; + private _scrollOutput: HTMLInputElement | undefined; public build(container: HTMLElement): void { const stressButtons = document.createElement('div'); @@ -30,14 +30,14 @@ export class WebglWindow extends BaseWindow implements IControlWindow { container.appendChild(stressButtons); const stressOptions = document.createElement('div'); - this._stressScrolling = document.createElement('input'); - this._stressScrolling.type = 'checkbox'; - this._stressScrolling.id = 'stress-scrolling'; - stressOptions.appendChild(this._stressScrolling); - const stressScrollingLabel = document.createElement('label'); - stressScrollingLabel.htmlFor = 'stress-scrolling'; - stressScrollingLabel.textContent = 'stress scrolling'; - stressOptions.appendChild(stressScrollingLabel); + this._scrollOutput = document.createElement('input'); + this._scrollOutput.type = 'checkbox'; + this._scrollOutput.id = 'scroll-output'; + stressOptions.appendChild(this._scrollOutput); + const scrollOutputLabel = document.createElement('label'); + scrollOutputLabel.htmlFor = 'scroll-output'; + scrollOutputLabel.textContent = 'Scroll output'; + stressOptions.appendChild(scrollOutputLabel); container.appendChild(stressOptions); const zoomCheckbox = document.createElement('input'); @@ -82,7 +82,7 @@ export class WebglWindow extends BaseWindow implements IControlWindow { return; } this._stressRunning = true; - const scrolling = !!(this._stressScrolling?.checked); + const scrollOutput = !!(this._scrollOutput?.checked); for (let r = 0; r < 256; r += 2) { for (let g = 0; g < 256; g += 2) { let s: string[] = []; @@ -93,7 +93,7 @@ export class WebglWindow extends BaseWindow implements IControlWindow { const rbg = `RGB: ${[r, g, b]}`; s.push(`\r\x1b[38;2;${r};${g};${b}m${rbg.padEnd(18, ' ')}${TEXT}`); if (s.length >= 16) { - if (scrolling) { + if (scrollOutput) { await new Promise(r => this._terminal.write(s.join('\r\n') + '\r\n', r)); } else { await new Promise(r => this._terminal.write('\x1b[H' + s.join('\r\n'), r)); From 330eaaf2050acc08fab8d6465741548ba3567686 Mon Sep 17 00:00:00 2001 From: KindaBrazy <143181745+KindaBrazy@users.noreply.github.com> Date: Sun, 9 Aug 2026 16:19:33 +0330 Subject: [PATCH 21/36] add "LynxHub" to Real-world uses. (#6077) --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 320327c26..0a26a2dac 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,7 @@ Xterm.js is used in many world-class applications to provide great terminal expe - [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders. - [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer. - [**Cate**](https://github.com/0-AI-UG/cate): Open source desktop IDE on an infinite zoomable canvas. Editor, browser, and terminal panels float in spatial space; the terminals are xterm.js with the WebGL addon, backed by node-pty. +- [**LynxHub**](https://github.com/KindaBrazy/LynxHub): Cross-platform, extensible terminal and browser for AI management. - [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D) Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list. From 9f39dc2bf9b499f3472be4d5a0fb2c6da14a6522 Mon Sep 17 00:00:00 2001 From: Ark0N Date: Sun, 9 Aug 2026 14:50:08 +0200 Subject: [PATCH 22/36] Add Codeman to real-world uses (#6082) Co-authored-by: Daniel Imms <2193314+Tyriar@users.noreply.github.com> --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 0a26a2dac..fd12da73a 100644 --- a/README.md +++ b/README.md @@ -256,6 +256,7 @@ Xterm.js is used in many world-class applications to provide great terminal expe - [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer. - [**Cate**](https://github.com/0-AI-UG/cate): Open source desktop IDE on an infinite zoomable canvas. Editor, browser, and terminal panels float in spatial space; the terminals are xterm.js with the WebGL addon, backed by node-pty. - [**LynxHub**](https://github.com/KindaBrazy/LynxHub): Cross-platform, extensible terminal and browser for AI management. +- [**Codeman**](https://github.com/Ark0N/Codeman): Self-hosted mission control for AI coding agents, streaming live tmux sessions to browser and phone. - [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D) Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list. From 7aea5197d809f073f23413d0aba2f2933aa30b62 Mon Sep 17 00:00:00 2001 From: Gevik Babakhani Date: Sun, 9 Aug 2026 14:50:48 +0200 Subject: [PATCH 23/36] Add JSVision to real-world uses (#6085) Co-authored-by: Daniel Imms <2193314+Tyriar@users.noreply.github.com> --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index fd12da73a..9c1e075ca 100644 --- a/README.md +++ b/README.md @@ -257,6 +257,7 @@ Xterm.js is used in many world-class applications to provide great terminal expe - [**Cate**](https://github.com/0-AI-UG/cate): Open source desktop IDE on an infinite zoomable canvas. Editor, browser, and terminal panels float in spatial space; the terminals are xterm.js with the WebGL addon, backed by node-pty. - [**LynxHub**](https://github.com/KindaBrazy/LynxHub): Cross-platform, extensible terminal and browser for AI management. - [**Codeman**](https://github.com/Ark0N/Codeman): Self-hosted mission control for AI coding agents, streaming live tmux sessions to browser and phone. +- [**JSVIsion**](https://github.com/blendsdk/jsvision): A Turbo Vision-Style TUI SDK for TypeScript. We use xterm.js in our docs to show live JSVision examples running in a web terminal. - [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D) Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list. From 44527735631396c608336c0c0f8abdba05cae517 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sun, 9 Aug 2026 20:52:24 +0800 Subject: [PATCH 24/36] Add BitFun to real-world uses (#6092) Co-authored-by: Daniel Imms <2193314+Tyriar@users.noreply.github.com> --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 9c1e075ca..31ef59b1b 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ Xterm.js is used in many world-class applications to provide great terminal expe - [**LynxHub**](https://github.com/KindaBrazy/LynxHub): Cross-platform, extensible terminal and browser for AI management. - [**Codeman**](https://github.com/Ark0N/Codeman): Self-hosted mission control for AI coding agents, streaming live tmux sessions to browser and phone. - [**JSVIsion**](https://github.com/blendsdk/jsvision): A Turbo Vision-Style TUI SDK for TypeScript. We use xterm.js in our docs to show live JSVision examples running in a web terminal. +- [**BitFun**](https://github.com/GCWing/BitFun): Open-source desktop AI agent whose integrated terminals and read-only terminal output views use xterm.js with the Fit, Web Links, and WebGL addons. - [And much more...](https://github.com/xtermjs/xterm.js/network/dependents?package_id=UGFja2FnZS0xNjYzMjc4OQ%3D%3D) Do you use xterm.js in your application as well? Please [open a Pull Request](https://github.com/sourcelair/xterm.js/pulls) to include it here. We would love to have it on our list. Please add any new contributions to the end of the list. From a4e4e28f975f574034325e5649a8e1b4ea869d51 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:52:51 -0700 Subject: [PATCH 25/36] build(deps-dev): Bump undici from 7.25.0 to 7.29.0 (#6088) Bumps [undici](https://github.com/nodejs/undici) from 7.25.0 to 7.29.0. - [Release notes](https://github.com/nodejs/undici/releases) - [Commits](https://github.com/nodejs/undici/compare/v7.25.0...v7.29.0) --- updated-dependencies: - dependency-name: undici dependency-version: 7.29.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 836806ec4..26d228697 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7975,9 +7975,9 @@ } }, "node_modules/undici": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", - "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "dev": true, "license": "MIT", "engines": { From c793c317fd55c7dcc04ddcbb4935c5faa60c0332 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:52:59 -0700 Subject: [PATCH 26/36] build(deps-dev): Bump fast-uri from 3.1.2 to 3.1.5 (#6091) Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.2 to 3.1.5. - [Release notes](https://github.com/fastify/fast-uri/releases) - [Commits](https://github.com/fastify/fast-uri/compare/v3.1.2...v3.1.5) --- updated-dependencies: - dependency-name: fast-uri dependency-version: 3.1.5 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 26d228697..426252fa2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4351,9 +4351,9 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", - "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", "dev": true, "funding": [ { From eef62138a44521760aa6cd68e33599f673ffc089 Mon Sep 17 00:00:00 2001 From: kkd927 Date: Sun, 9 Aug 2026 21:54:04 +0900 Subject: [PATCH 27/36] Add kmux to real-world uses (#6031) Co-authored-by: Daniel Imms <2193314+Tyriar@users.noreply.github.com> --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 31ef59b1b..374b42dbd 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,7 @@ Xterm.js is used in many world-class applications to provide great terminal expe - [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders. - [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer. - [**Cate**](https://github.com/0-AI-UG/cate): Open source desktop IDE on an infinite zoomable canvas. Editor, browser, and terminal panels float in spatial space; the terminals are xterm.js with the WebGL addon, backed by node-pty. +- [**kmux**](https://github.com/kkd927/kmux): A keyboard-centric terminal workspace for running parallel AI coding agents side by side on macOS and Linux. - [**LynxHub**](https://github.com/KindaBrazy/LynxHub): Cross-platform, extensible terminal and browser for AI management. - [**Codeman**](https://github.com/Ark0N/Codeman): Self-hosted mission control for AI coding agents, streaming live tmux sessions to browser and phone. - [**JSVIsion**](https://github.com/blendsdk/jsvision): A Turbo Vision-Style TUI SDK for TypeScript. We use xterm.js in our docs to show live JSVision examples running in a web terminal. From 576181d93d2d8eafe8755177e57165ff39348e73 Mon Sep 17 00:00:00 2001 From: Graciliano Monteiro Passos Date: Sun, 9 Aug 2026 09:54:39 -0300 Subject: [PATCH 28/36] Add OmnyShell PWA to the list of tools (#6034) Co-authored-by: Daniel Imms <2193314+Tyriar@users.noreply.github.com> --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 374b42dbd..738fa428b 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,7 @@ Xterm.js is used in many world-class applications to provide great terminal expe - [**Orca**](https://github.com/stablyai/orca): Agentic development environment for 100x builders. - [**4bit**](https://ciembor.github.io/4bit): Terminal Color Scheme Designer. - [**Cate**](https://github.com/0-AI-UG/cate): Open source desktop IDE on an infinite zoomable canvas. Editor, browser, and terminal panels float in spatial space; the terminals are xterm.js with the WebGL addon, backed by node-pty. +- [**OmnyShell PWA**](https://github.com/OmnyGrid/omnyshell_web): Web client for [**OmnyShell**](https://github.com/OmnyGrid/omnyshell), featuring remote terminals, a built-in IDE, AI-assisted workflows, and detachable/resumable sessions for seamless browser-based access to nodes. - [**kmux**](https://github.com/kkd927/kmux): A keyboard-centric terminal workspace for running parallel AI coding agents side by side on macOS and Linux. - [**LynxHub**](https://github.com/KindaBrazy/LynxHub): Cross-platform, extensible terminal and browser for AI management. - [**Codeman**](https://github.com/Ark0N/Codeman): Self-hosted mission control for AI coding agents, streaming live tmux sessions to browser and phone. From 8961a72d2d936bac273763e09136b5303bb8a7e9 Mon Sep 17 00:00:00 2001 From: rufatkul <24218176+rufatkul@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:56:52 -0700 Subject: [PATCH 29/36] Set autocomplete="off" on the helper textarea (#6057) Without an autocomplete hint, focusing a terminal on iOS/iPadOS Safari pops the AutoFill accessory (passwords / credit cards / contacts) for the hidden textarea. The sibling hints (autocorrect, autocapitalize, spellcheck) are already set; this adds the missing one. Co-authored-by: Claude Fable 5 --- src/browser/CoreBrowserTerminal.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/browser/CoreBrowserTerminal.ts b/src/browser/CoreBrowserTerminal.ts index 4557e1652..67893b796 100644 --- a/src/browser/CoreBrowserTerminal.ts +++ b/src/browser/CoreBrowserTerminal.ts @@ -492,6 +492,7 @@ export class CoreBrowserTerminal extends CoreTerminal implements ITerminal { // https://issuetracker.google.com/issues/260170397 this.textarea.setAttribute('aria-multiline', 'false'); } + this.textarea.setAttribute('autocomplete', 'off'); this.textarea.setAttribute('autocorrect', 'off'); this.textarea.setAttribute('autocapitalize', 'off'); this.textarea.setAttribute('spellcheck', 'false'); From 1b822cb8142a15008c6cba8bfcb4f20366dd17bb Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:01:11 -0700 Subject: [PATCH 30/36] build(deps): Bump shell-quote and concurrently (#6076) Bumps [shell-quote](https://github.com/ljharb/shell-quote) to 1.9.0 and updates ancestor dependency [concurrently](https://github.com/open-cli-tools/concurrently). These dependencies need to be updated together. Updates `shell-quote` from 1.8.3 to 1.9.0 - [Changelog](https://github.com/ljharb/shell-quote/blob/main/CHANGELOG.md) - [Commits](https://github.com/ljharb/shell-quote/compare/v1.8.3...v1.9.0) Updates `concurrently` from 9.2.1 to 9.2.4 - [Release notes](https://github.com/open-cli-tools/concurrently/releases) - [Commits](https://github.com/open-cli-tools/concurrently/compare/v9.2.1...v9.2.4) --- updated-dependencies: - dependency-name: shell-quote dependency-version: 1.9.0 dependency-type: indirect - dependency-name: concurrently dependency-version: 9.2.4 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/package-lock.json b/package-lock.json index 426252fa2..88eabbb75 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3519,15 +3519,15 @@ } }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "9.2.4", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.4.tgz", + "integrity": "sha512-TZ0CEhyzvFjgtAvHTusDMgj7wNdihCh7LLLrzdUOXIhdlnL2JBBGA9eJxR24rtqgmdjh3OA3hrN1rCHj6HM8qA==", "dev": true, "license": "MIT", "dependencies": { "chalk": "4.1.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", + "shell-quote": "1.9.0", "supports-color": "8.1.1", "tree-kill": "1.2.2", "yargs": "17.7.2" @@ -7127,9 +7127,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.9.0.tgz", + "integrity": "sha512-Iov+JwFv/2HcTpcwNMKd8+IWNb8tboQJNQTkAY/LLVK7gGH9jy+LGkVqPxfekHl+yMmiqXszdGWXgkfml7hjqA==", "dev": true, "license": "MIT", "engines": { From e2ff222c4f283103ec824b802e4cc37c8940e3e4 Mon Sep 17 00:00:00 2001 From: Simon Lamon <32477463+silamon@users.noreply.github.com> Date: Sun, 9 Aug 2026 15:01:58 +0200 Subject: [PATCH 31/36] Apply autofixes from Zizmor (#6021) --- .github/workflows/ci.yml | 46 ++++++++++++++--------- .github/workflows/codeql-analysis.yml | 10 +++-- .github/workflows/copilot-setup-steps.yml | 6 ++- .github/workflows/release.yml | 6 ++- 4 files changed, 43 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f8385894..8ee387965 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,9 +11,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - name: Use Node.js 22.x - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 22.x cache: 'npm' @@ -69,7 +71,7 @@ jobs: ./addons/addon-webgl/out/* \ ./addons/addon-webgl/out-*st/* - name: Upload artifacts - uses: actions/upload-artifact@v6 + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: build-artifacts path: compressed-build.zip @@ -79,9 +81,11 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - name: Use Node.js 22.x - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 22.x cache: 'npm' @@ -100,16 +104,18 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 10 steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - name: Use Node.js 22.x - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 22.x cache: 'npm' - name: Install dependencies run: | npm ci - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: build-artifacts - name: Unzip artifacts @@ -137,16 +143,18 @@ jobs: runs-on: [ubuntu, macos, windows] runs-on: ${{ matrix.runs-on }}-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - name: Use Node.js ${{ matrix.node-version }}.x - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: ${{ matrix.node-version }}.x cache: 'npm' - name: Install dependencies run: | npm ci - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: build-artifacts - name: Unzip artifacts @@ -171,9 +179,11 @@ jobs: browser: [chromium, firefox, webkit] runs-on: ${{ matrix.runs-on }} steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - name: Use Node.js ${{ matrix.node-version }}.x - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: ${{ matrix.node-version }}.x cache: 'npm' @@ -182,7 +192,7 @@ jobs: npm ci - name: Install playwright run: npx playwright install --with-deps ${{ matrix.browser }} - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: build-artifacts - name: Unzip artifacts @@ -232,9 +242,11 @@ jobs: matrix: node-version: [22] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - name: Use Node.js ${{ matrix.node-version }}.x - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: ${{ matrix.node-version }}.x cache: 'npm' @@ -243,7 +255,7 @@ jobs: npm ci - name: Install playwright run: npx playwright install - - uses: actions/download-artifact@v7 + - uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 with: name: build-artifacts - name: Unzip artifacts diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f0d27975c..3d7d26519 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -24,12 +24,14 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - name: Initialize CodeQL - uses: github/codeql-action/init@v4 + uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 with: languages: ${{ matrix.language }} - name: Autobuild - uses: github/codeql-action/autobuild@v4 + uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 + uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4 diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 79e0fc306..1afefc1c1 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -18,9 +18,11 @@ jobs: steps: - name: Checkout code - uses: actions/checkout@v5 + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - name: Use Node.js 22.x - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 22.x cache: 'npm' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3dacee1d6..cfc7ce02d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,9 +10,11 @@ jobs: release: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5 + with: + persist-credentials: false - name: Use Node.js 22.x - uses: actions/setup-node@v5 + uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5 with: node-version: 22.x cache: 'npm' From b5222f8010ed5b9a4e23cf2773d4d4596358bcaa Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:02:19 -0700 Subject: [PATCH 32/36] build(deps): Bump brace-expansion (#6093) Bumps and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together. Updates `brace-expansion` from 5.0.6 to 5.0.9 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.9) Updates `brace-expansion` from 2.1.0 to 2.1.4 - [Release notes](https://github.com/juliangruber/brace-expansion/releases) - [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.6...v5.0.9) --- updated-dependencies: - dependency-name: brace-expansion dependency-version: 5.0.9 dependency-type: indirect - dependency-name: brace-expansion dependency-version: 2.1.4 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/package-lock.json b/package-lock.json index 88eabbb75..af8841c28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3061,16 +3061,16 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.6", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", - "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" }, "engines": { - "node": "18 || 20 || >=22" + "node": "20 || >=22" } }, "node_modules/braces": { @@ -4749,9 +4749,9 @@ "license": "MIT" }, "node_modules/glob/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { @@ -5795,9 +5795,9 @@ "license": "MIT" }, "node_modules/mocha/node_modules/brace-expansion": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.0.tgz", - "integrity": "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w==", + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "dev": true, "license": "MIT", "dependencies": { From 6ccafd97791ed8c6bf05662708a0745b1d085023 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 9 Aug 2026 06:16:25 -0700 Subject: [PATCH 33/36] build(deps): Bump ws (#6094) Bumps [ws](https://github.com/websockets/ws) to 7.5.13 and updates ancestor dependency . These dependencies need to be updated together. Updates `ws` from 7.5.10 to 7.5.13 - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/7.5.10...7.5.13) Updates `ws` from 8.20.1 to 8.21.3 - [Release notes](https://github.com/websockets/ws/releases) - [Commits](https://github.com/websockets/ws/compare/7.5.10...7.5.13) --- updated-dependencies: - dependency-name: ws dependency-version: 7.5.13 dependency-type: indirect - dependency-name: ws dependency-version: 8.21.3 dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index af8841c28..1062d3363 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4308,9 +4308,9 @@ } }, "node_modules/express-ws/node_modules/ws": { - "version": "7.5.10", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", - "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", + "version": "7.5.13", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.13.tgz", + "integrity": "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==", "dev": true, "license": "MIT", "engines": { @@ -8452,9 +8452,9 @@ "license": "ISC" }, "node_modules/ws": { - "version": "8.20.1", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", - "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", "dev": true, "license": "MIT", "engines": { From 8c9b9fdb9ba7b72b677173225f69c2a47f807600 Mon Sep 17 00:00:00 2001 From: Daniel Imms <2193314+Tyriar@users.noreply.github.com> Date: Mon, 10 Aug 2026 04:34:45 -0700 Subject: [PATCH 34/36] ci: restrict release workflow permissions (#6095) Co-authored-by: Cursor Agent Co-authored-by: Daniel Imms --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cfc7ce02d..415012089 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,6 +6,9 @@ on: # without running tests to save time to publish branches: [ "master" ] +permissions: + contents: read + jobs: release: runs-on: ubuntu-latest From d6cfe3db5dfdbfd2a08c3f9bcfcde8a283aa479d Mon Sep 17 00:00:00 2001 From: Taran <156827032+tarann26@users.noreply.github.com> Date: Mon, 10 Aug 2026 07:38:58 -0400 Subject: [PATCH 35/36] Fix viewport staying pinned to the top after ESC[3J clears the scrollback (#6081) --- src/common/InputHandler.test.ts | 48 +++++++++++++++++++++++++++++++++ src/common/InputHandler.ts | 5 ++++ 2 files changed, 53 insertions(+) diff --git a/src/common/InputHandler.test.ts b/src/common/InputHandler.test.ts index 9eab8cfa2..3534ff5ab 100644 --- a/src/common/InputHandler.test.ts +++ b/src/common/InputHandler.test.ts @@ -2394,6 +2394,54 @@ describe('InputHandler', () => { }); }); + describe('ED3 - erase saved lines', () => { + beforeEach(() => { + bufferService.resize(10, 5); + }); + it('should stop locking the viewport when the scrollback the user scrolled into is erased', async () => { + for (let i = 0; i < 20; ++i) { + await inputHandler.parseP(`old ${i}\r\n`); + } + bufferService.scrollLines(-5); + assert.strictEqual(bufferService.isUserScrolling, true); + + await inputHandler.parseP('\x1b[3J'); + + assert.strictEqual(bufferService.isUserScrolling, false); + assert.strictEqual(bufferService.buffer.ybase, 0); + assert.strictEqual(bufferService.buffer.ydisp, 0); + + for (let i = 0; i < 20; ++i) { + await inputHandler.parseP(`new ${i}\r\n`); + } + + assert.strictEqual(bufferService.buffer.ydisp, bufferService.buffer.ybase); + }); + it('should not reset the scroll position when erasing scrollback on a resized alt buffer', async () => { + bufferService.resize(10, 50); + bufferService.resize(10, 5); + for (let i = 0; i < 20; ++i) { + await inputHandler.parseP(`old ${i}\r\n`); + } + bufferService.scrollLines(-5); + const ydisp = bufferService.buffer.ydisp; + + await inputHandler.parseP('\x1b[?1049h'); + for (let i = 0; i < 20; ++i) { + await inputHandler.parseP(`new ${i}\r\n`); + } + await inputHandler.parseP('\x1b[3J\x1b[?1049l'); + + assert.strictEqual(bufferService.isUserScrolling, true); + assert.strictEqual(bufferService.buffer.ydisp, ydisp); + + await inputHandler.parseP('more\r\n'); + + assert.strictEqual(bufferService.isUserScrolling, true); + assert.strictEqual(bufferService.buffer.ydisp, ydisp); + }); + }); + describe('DECSCA and DECSED/DECSEL', () => { it('default is unprotected', async () => { await inputHandler.parseP('some text'); diff --git a/src/common/InputHandler.ts b/src/common/InputHandler.ts index 0b12dd8e1..995fe7670 100644 --- a/src/common/InputHandler.ts +++ b/src/common/InputHandler.ts @@ -1286,6 +1286,11 @@ export class InputHandler extends Disposable implements IInputHandler { this._activeBuffer.lines.trimStart(scrollBackSize); this._activeBuffer.ybase = Math.max(this._activeBuffer.ybase - scrollBackSize, 0); this._activeBuffer.ydisp = Math.max(this._activeBuffer.ydisp - scrollBackSize, 0); + // isUserScrolling tracks the normal buffer's viewport, so ED3 on the alt + // screen must not touch it + if (this._activeBuffer === this._bufferService.buffers.normal) { + this._bufferService.isUserScrolling = false; + } // Force a scroll event to refresh viewport this._onScroll.fire(0); } From 29a738423349b75d40732f4cd12a5a0326e03fed Mon Sep 17 00:00:00 2001 From: Ankur <114933665+AKjangid99@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:09:43 +0530 Subject: [PATCH 36/36] ci: scope GitHub Actions permissions to least privilege (#6028) Co-authored-by: Ankur Jangid Co-authored-by: Daniel Imms <2193314+Tyriar@users.noreply.github.com> --- .github/workflows/ci.yml | 3 +++ .github/workflows/codeql-analysis.yml | 5 +++++ .github/workflows/copilot-setup-steps.yml | 3 +++ 3 files changed, 11 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8ee387965..04b5c2fd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,9 @@ on: pull_request: branches: [ "master" ] +permissions: + contents: read + jobs: build: runs-on: ubuntu-latest diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 3d7d26519..d8b336fe9 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -8,6 +8,11 @@ on: schedule: - cron: '41 17 * * 0' +permissions: + actions: read + contents: read + security-events: write + jobs: analyze: name: Analyze diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml index 1afefc1c1..b2b69533b 100644 --- a/.github/workflows/copilot-setup-steps.yml +++ b/.github/workflows/copilot-setup-steps.yml @@ -9,6 +9,9 @@ on: paths: - .github/workflows/copilot-setup-steps.yml +permissions: + contents: read + jobs: copilot-setup-steps: runs-on: ubuntu-latest