diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0f8385894..04b5c2fd6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,14 +6,19 @@ on: pull_request: branches: [ "master" ] +permissions: + contents: read + jobs: build: 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 +74,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 +84,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 +107,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 +146,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 +182,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 +195,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 +245,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 +258,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..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 @@ -24,12 +29,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..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 @@ -18,9 +21,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..415012089 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -6,13 +6,18 @@ on: # without running tests to save time to publish branches: [ "master" ] +permissions: + contents: read + 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' diff --git a/README.md b/README.md index 320327c26..738fa428b 100644 --- a/README.md +++ b/README.md @@ -255,6 +255,12 @@ 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. +- [**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. diff --git a/addons/addon-webgl/src/GlyphRenderer.ts b/addons/addon-webgl/src/GlyphRenderer.ts index be1990c2a..742f0879f 100644 --- a/addons/addon-webgl/src/GlyphRenderer.ts +++ b/addons/addon-webgl/src/GlyphRenderer.ts @@ -100,6 +100,8 @@ export class GlyphRenderer extends Disposable { private readonly _attributesBuffer: WebGLBuffer; private _atlas: ITextureAtlas | undefined; + private _lastSeenPageLayoutVersion: number = -1; + private _pageOverflowWarned: boolean = false; private _activeBuffer: number = 0; private readonly _vertices: IVertices = { count: 0, @@ -213,8 +215,19 @@ 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 { - return this._atlas ? this._atlas.beginFrame() : true; + if (!this._atlas) { + return true; + } + if (this._atlas.pageLayoutVersion !== this._lastSeenPageLayoutVersion) { + this._lastSeenPageLayoutVersion = this._atlas.pageLayoutVersion; + 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 { @@ -363,7 +376,13 @@ 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++) { + // 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; + 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); } @@ -375,6 +394,7 @@ export class GlyphRenderer extends Disposable { public setAtlas(atlas: ITextureAtlas): void { this._atlas = atlas; + this._lastSeenPageLayoutVersion = -1; this.invalidateAtlasTextures(); } diff --git a/addons/addon-webgl/src/TextureAtlas.ts b/addons/addon-webgl/src/TextureAtlas.ts index 1c3e08a1c..4977ad741 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 _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) { @@ -149,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 { @@ -184,6 +185,8 @@ 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); this._activePages.push(newPage); @@ -206,8 +209,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._pageLayoutVersion++; this._onAddTextureAtlasCanvas.fire(mergedPage.canvas); } @@ -258,6 +261,23 @@ 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(); + } + this._pages.length = 0; + this._activePages.length = 0; + this._overflowSizePage = undefined; + this._cacheMap.clear(); + 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 { return this._getFromCacheMap(this._cacheMapCombined, chars, bg, fg, ext, restrictToCellHeight, domContainer); } @@ -815,11 +835,15 @@ 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(); + } 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._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 294582e37..bfd6fd76c 100644 --- a/addons/addon-webgl/src/Types.ts +++ b/addons/addon-webgl/src/Types.ts @@ -68,10 +68,12 @@ 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 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. */ - beginFrame(): boolean; + readonly pageLayoutVersion: number; /** * Clear all glyphs from the texture atlas. diff --git a/addons/addon-webgl/test/WebglAtlasOverflow.test.ts b/addons/addon-webgl/test/WebglAtlasOverflow.test.ts new file mode 100644 index 000000000..1252c81cf --- /dev/null +++ b/addons/addon-webgl/test/WebglAtlasOverflow.test.ts @@ -0,0 +1,481 @@ +/** + * 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 { + 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; + 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 = { + removals: 0, + maxPages: atlas.pages.length + }; + w.atlasEventStats = stats; + w.addon.onAddTextureAtlasCanvas(() => { + 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, + 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(); + } + }); +}); diff --git a/addons/addon-webgl/test/WebglAtlasStress.test.ts b/addons/addon-webgl/test/WebglAtlasStress.test.ts new file mode 100644 index 000000000..e0fc1c906 --- /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 { 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'; +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 { + readonly pages: ReadonlyArray<{ readonly canvas: HTMLCanvasElement }>; +} + +interface ITestGlyphRenderer { + readonly _atlasTextures: ReadonlyArray; +} + +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/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts b/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts new file mode 100644 index 000000000..3e85bcc30 --- /dev/null +++ b/addons/addon-webgl/test/WebglSharedAtlasGarble.test.ts @@ -0,0 +1,282 @@ +/** + * 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; +} + +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]; +const TERM_B_SELECTOR = '#terminal-container-b .xterm-screen'; + +async function loadWebglStrict(ctx: ITestContext): Promise { + await ctx.page.evaluate(() => { + window.addon = new window.WebglAddon({ preserveDrawingBuffer: true }); + window.term.loadAddon(window.addon); + }); + const isWebglRenderer = await ctx.page.evaluate(() => { + 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(() => { + window.termB?.dispose(); + document.getElementById('terminal-container-b')?.remove(); + const el = document.createElement('div'); + el.id = 'terminal-container-b'; + document.body.appendChild(el); + 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.termB; + if (!termB) { + throw new Error('Terminal B must be created before writing to it'); + } + 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.term?._core?._renderService?._renderer?.value?._charAtlas; + if (!atlas) { + return false; + } + atlas.constructor.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.term?._core?._renderService?._renderer?.value?._charAtlas; + if (atlas) { + atlas.constructor.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(() => { + 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 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.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(); + } + }); + + 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(); + } + }); +}); diff --git a/demo/client/components/window/webglWindow.ts b/demo/client/components/window/webglWindow.ts index d7c175c45..12cf07a62 100644 --- a/demo/client/components/window/webglWindow.ts +++ b/demo/client/components/window/webglWindow.ts @@ -12,31 +12,33 @@ 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 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); - this._stressScrolling = document.createElement('input'); - this._stressScrolling.type = 'checkbox'; - this._stressScrolling.id = 'stress-scrolling'; - stressDiv.appendChild(this._stressScrolling); - const stressScrollingLabel = document.createElement('label'); - stressScrollingLabel.htmlFor = 'stress-scrolling'; - stressScrollingLabel.textContent = 'stress scrolling'; - stressDiv.appendChild(stressScrollingLabel); - container.appendChild(stressDiv); + const stressOptions = document.createElement('div'); + 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'); zoomCheckbox.type = 'checkbox'; @@ -80,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[] = []; @@ -91,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)); diff --git a/package-lock.json b/package-lock.json index 836806ec4..1062d3363 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": { @@ -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" @@ -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": { @@ -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": [ { @@ -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": { @@ -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": { @@ -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": { @@ -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": { 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'); 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..353e0581d 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,6 +21,8 @@ interface IMouseBindContext { readonly target: IMouseServiceTarget; readonly focus: () => void; readonly requestedEvents: RequestedMouseEvents; + readonly mouseupListener: MutableDisposable; + readonly mousedragListener: MutableDisposable; } export class MouseService implements IMouseService { @@ -61,7 +63,11 @@ export class MouseService implements IMouseService { mousedrag: null, mousemove: null }; - const ctx: IMouseBindContext = { target, focus, requestedEvents }; + 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), @@ -85,16 +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(() => { - if (requestedEvents.mouseup) { - document.removeEventListener('mouseup', requestedEvents.mouseup); - } - if (requestedEvents.mousedrag) { - document.removeEventListener('mousemove', requestedEvents.mousedrag); - } - })); - /** * "Always on" event listeners. */ @@ -199,12 +195,8 @@ export class MouseService implements IMouseService { this._sendEvent(ctx, ev); if (!ev.buttons) { // if no other button is held remove global handlers - if (ctx.requestedEvents.mouseup) { - ctx.target.document.removeEventListener('mouseup', ctx.requestedEvents.mouseup); - } - if (ctx.requestedEvents.mousedrag) { - ctx.target.document.removeEventListener('mousemove', ctx.requestedEvents.mousedrag); - } + ctx.mouseupListener.clear(); + ctx.mousedragListener.clear(); } } @@ -246,11 +238,14 @@ 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 { element, document: targetDocument } = ctx.target; + const listenerDocument = element.ownerDocument ?? targetDocument; if (ctx.requestedEvents.mouseup) { - ctx.target.document.addEventListener('mouseup', ctx.requestedEvents.mouseup); + ctx.mouseupListener.value = addDisposableListener(listenerDocument, 'mouseup', ctx.requestedEvents.mouseup); } if (ctx.requestedEvents.mousedrag) { - ctx.target.document.addEventListener('mousemove', ctx.requestedEvents.mousedrag); + ctx.mousedragListener.value = addDisposableListener(listenerDocument, 'mousemove', ctx.requestedEvents.mousedrag); } } @@ -398,7 +393,7 @@ 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; // apply global changes on events if (events) { @@ -433,18 +428,14 @@ export class MouseService implements IMouseService { } if (!(events & CoreMouseEventType.UP)) { - if (requestedEvents.mouseup) { - document.removeEventListener('mouseup', requestedEvents.mouseup); - } + ctx.mouseupListener.clear(); requestedEvents.mouseup = null; } else { requestedEvents.mouseup ??= eventListeners.mouseup; } if (!(events & CoreMouseEventType.DRAG)) { - if (requestedEvents.mousedrag) { - document.removeEventListener('mousemove', requestedEvents.mousedrag); - } + ctx.mousedragListener.clear(); requestedEvents.mousedrag = null; } else { requestedEvents.mousedrag ??= eventListeners.mousedrag; 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); }