diff --git a/docs/audits/osc-link-retention/README.md b/docs/audits/osc-link-retention/README.md new file mode 100644 index 00000000000..abc01aedc3a --- /dev/null +++ b/docs/audits/osc-link-retention/README.md @@ -0,0 +1,73 @@ +# OSC 8 hyperlink retention reproduction + +The installed xterm builds retain hyperlink metadata after a TUI overwrites its +linked text. Each anonymous OSC 8 open creates a registry entry and a line marker; +overwriting or erasing that line's cells does not dispose the marker. Repainting +one linked character therefore grows memory indefinitely even with 24 buffer rows. + +This reproduces in `@xterm/headless@6.1.0-beta.302` and +`@xterm/xterm@6.1.0-beta.303`, the versions also shipped in `v1.4.198`. +`OscLinkService.registerLink`, `Buffer.addMarker`, and marker-disposal callbacks +form the retaining path. Explicit `id=` values reuse entries only while both the +ID and URI stay identical; fresh IDs or URIs can accumulate the same way. + +## Run + +From the repository root, with dependencies installed: + +```sh +ORCA_BACKGROUND_LAUNCH=1 node --expose-gc docs/audits/osc-link-retention/reproduce.mjs +``` + +The script bundles the current collector, records its SHA-256, and compares the +same installed xterm with and without collection. It uses real parsers and forced +GC without opening a window. Every case performs 10,000 redraws, sampling every +2,500; modes cover ordinary overwrite, erase-line, and alternate-screen redraws. + +## Recorded result + +See [results.json](./results.json), captured on macOS with Node v26.6.0. Values +below are heap growth after GC, in decimal MB; these are isolated reproductions, +not affected-host measurements. + +| Terminal | Before, across three modes | After, across three modes | +| --- | ---: | ---: | +| Headless | 20.59–20.93 MB | 1.65–1.72 MB | +| Renderer | 20.52–20.76 MB | 1.66–1.71 MB | + +All baseline cases retained 10,000 entries/markers with only 24 rows. All fixed +cases retained 785 entries/markers at the final sample. A separate sweep of a +5,024-row, 160-column buffer removed 1,023 obsolete entries in 9.99 ms. Sweep cost +scales with configured buffer size; this is one timing sample, not a latency bound. + +## Fix and safeguards + +After 1,024 additional registry entries, the collector scans both normal and +alternate buffers and preserves every referenced URL ID, the currently open link, +and saved-cursor attributes. It disposes only markers belonging to entries with +no remaining reference. Existing xterm callbacks remove both registry indexes; +unrelated markers are untouched. The check runs after headless parsing and through +`onWriteParsed` for desktop panes, dashboard previews, and the mobile WebView. + +The scan deliberately does not rely solely on marker rows: wrapping can put live +linked cells on a row other than the initial marker. Regression tests exercise +real headless/renderer libraries, the generated mobile engine, scrollback, +alternate-screen transitions, partial overwrite/reflow, explicit ID reuse, +split writes, unrelated markers, and both production headless write paths. + +The collector depends on private xterm registry/attribute fields, as Orca's +existing snapshot hyperlink extraction does. Shape checks skip unsupported core +layouts, and tests against the pinned libraries must accompany upgrades. Cleanup +occurs between parsed batches; it does not cap a single batch's allocation, live +hyperlink bytes, or ordinary scrollback. Without further registry growth, a final +tail of obsolete entries can remain until terminal disposal. + +## Issue correlation + +This mechanism can retain memory in Electron main's terminal mirrors, terminal +daemons, and rendered terminals. It needs neither SSH nor headless automation and +is compatible with prolonged TUI redraws. It is therefore a concrete candidate +for [#19831](https://github.com/stablyai/orca/issues/19831) and the main-process +growth in [#19768](https://github.com/stablyai/orca/issues/19768). Neither report +contains the relevant output transcript or allocation trace; this reproduction +does not establish either incident's root cause or measured growth rate. diff --git a/docs/audits/osc-link-retention/reproduce.mjs b/docs/audits/osc-link-retention/reproduce.mjs new file mode 100644 index 00000000000..ddd7d7957b8 --- /dev/null +++ b/docs/audits/osc-link-retention/reproduce.mjs @@ -0,0 +1,107 @@ +import { createHash } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { resolve } from 'node:path' +import { performance } from 'node:perf_hooks' +import { build } from 'esbuild' +import headless from '@xterm/headless' +import rendered from '@xterm/xterm' + +if (process.env.ORCA_BACKGROUND_LAUNCH !== '1' || typeof global.gc !== 'function') { + throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1 node --expose-gc') +} +const root = fileURLToPath(new URL('../../../', import.meta.url)) +const built = await build({ + entryPoints: [resolve(root, 'src/shared/terminal-osc-link-retirement.ts')], + bundle: true, + platform: 'node', + format: 'esm', + write: false +}) +const bundle = built.outputFiles[0].text +const { createTerminalOscLinkRetirement } = await import( + `data:text/javascript;base64,${Buffer.from(bundle).toString('base64')}` +) +const results = [] +for (const [kind, { Terminal }] of [ + ['headless', headless], + ['renderer', rendered] +]) { + for (const mode of ['overwrite', 'erase-line', 'alternate']) { + for (const fixed of [false, true]) { + const terminal = new Terminal({ + cols: 80, + rows: 24, + scrollback: 5000, + allowProposedApi: true, + logLevel: 'off' + }) + const retirement = createTerminalOscLinkRetirement(terminal) + const core = terminal._core + if (mode === 'alternate') { + core.writeSync('\x1b[?1049h') + } + global.gc() + const before = process.memoryUsage().heapUsed + const samples = [] + try { + for (let index = 1; index <= 10000; index++) { + core.writeSync( + `${mode === 'erase-line' ? '\x1b[2K' : ''}\r\x1b]8;;https://example.test/path\x1b\\x\x1b]8;;\x1b\\` + ) + if (fixed) { + retirement() + } + if (index % 2500 === 0) { + global.gc() + samples.push({ + updates: index, + links: core._oscLinkService._dataByLinkId.size, + markers: terminal.markers.length, + rows: terminal.buffer.active.length, + retainedHeapDelta: process.memoryUsage().heapUsed - before + }) + } + } + results.push({ kind, mode, fixed, samples }) + } finally { + terminal.dispose() + } + } + } +} +const terminal = new headless.Terminal({ + cols: 160, + rows: 24, + scrollback: 5000, + allowProposedApi: true, + logLevel: 'off' +}) +let sweepCost +try { + terminal._core.writeSync('x\r\n'.repeat(6000)) + terminal._core.writeSync('\r\x1b]8;;https://example.test/path\x1b\\x\x1b]8;;\x1b\\'.repeat(1024)) + const retirement = createTerminalOscLinkRetirement(terminal) + const started = performance.now() + const removed = retirement() + sweepCost = { + rows: terminal.buffer.normal.length, + columns: terminal.cols, + removed, + milliseconds: performance.now() - started + } +} finally { + terminal.dispose() +} +console.log( + JSON.stringify( + { + node: process.version, + platform: process.platform, + bundleSha256: createHash('sha256').update(bundle).digest('hex'), + results, + sweepCost + }, + null, + 2 + ) +) diff --git a/docs/audits/osc-link-retention/results.json b/docs/audits/osc-link-retention/results.json new file mode 100644 index 00000000000..95cd6edb473 --- /dev/null +++ b/docs/audits/osc-link-retention/results.json @@ -0,0 +1,433 @@ +{ + "node": "v26.6.0", + "platform": "darwin", + "bundleSha256": "e1a9a778efea3d746792ba009f0a37d6277dfc6a5c15dff953455c678f05dfe2", + "results": [ + { + "kind": "headless", + "mode": "overwrite", + "fixed": false, + "samples": [ + { + "updates": 2500, + "links": 2500, + "markers": 2500, + "rows": 24, + "retainedHeapDelta": 5470328 + }, + { + "updates": 5000, + "links": 5000, + "markers": 5000, + "rows": 24, + "retainedHeapDelta": 10623216 + }, + { + "updates": 7500, + "links": 7500, + "markers": 7500, + "rows": 24, + "retainedHeapDelta": 15659960 + }, + { + "updates": 10000, + "links": 10000, + "markers": 10000, + "rows": 24, + "retainedHeapDelta": 20930000 + } + ] + }, + { + "kind": "headless", + "mode": "overwrite", + "fixed": true, + "samples": [ + { + "updates": 2500, + "links": 453, + "markers": 453, + "rows": 24, + "retainedHeapDelta": 1027056 + }, + { + "updates": 5000, + "links": 905, + "markers": 905, + "rows": 24, + "retainedHeapDelta": 1954312 + }, + { + "updates": 7500, + "links": 333, + "markers": 333, + "rows": 24, + "retainedHeapDelta": 794592 + }, + { + "updates": 10000, + "links": 785, + "markers": 785, + "rows": 24, + "retainedHeapDelta": 1717232 + } + ] + }, + { + "kind": "headless", + "mode": "erase-line", + "fixed": false, + "samples": [ + { + "updates": 2500, + "links": 2500, + "markers": 2500, + "rows": 24, + "retainedHeapDelta": 5148080 + }, + { + "updates": 5000, + "links": 5000, + "markers": 5000, + "rows": 24, + "retainedHeapDelta": 10301032 + }, + { + "updates": 7500, + "links": 7500, + "markers": 7500, + "rows": 24, + "retainedHeapDelta": 15352184 + }, + { + "updates": 10000, + "links": 10000, + "markers": 10000, + "rows": 24, + "retainedHeapDelta": 20591968 + } + ] + }, + { + "kind": "headless", + "mode": "erase-line", + "fixed": true, + "samples": [ + { + "updates": 2500, + "links": 453, + "markers": 453, + "rows": 24, + "retainedHeapDelta": 981072 + }, + { + "updates": 5000, + "links": 905, + "markers": 905, + "rows": 24, + "retainedHeapDelta": 1907184 + }, + { + "updates": 7500, + "links": 333, + "markers": 333, + "rows": 24, + "retainedHeapDelta": 751440 + }, + { + "updates": 10000, + "links": 785, + "markers": 785, + "rows": 24, + "retainedHeapDelta": 1696272 + } + ] + }, + { + "kind": "headless", + "mode": "alternate", + "fixed": false, + "samples": [ + { + "updates": 2500, + "links": 2500, + "markers": 2500, + "rows": 24, + "retainedHeapDelta": 5179480 + }, + { + "updates": 5000, + "links": 5000, + "markers": 5000, + "rows": 24, + "retainedHeapDelta": 10335264 + }, + { + "updates": 7500, + "links": 7500, + "markers": 7500, + "rows": 24, + "retainedHeapDelta": 15382584 + }, + { + "updates": 10000, + "links": 10000, + "markers": 10000, + "rows": 24, + "retainedHeapDelta": 20628776 + } + ] + }, + { + "kind": "headless", + "mode": "alternate", + "fixed": true, + "samples": [ + { + "updates": 2500, + "links": 453, + "markers": 453, + "rows": 24, + "retainedHeapDelta": 960872 + }, + { + "updates": 5000, + "links": 905, + "markers": 905, + "rows": 24, + "retainedHeapDelta": 1888584 + }, + { + "updates": 7500, + "links": 333, + "markers": 333, + "rows": 24, + "retainedHeapDelta": 725992 + }, + { + "updates": 10000, + "links": 785, + "markers": 785, + "rows": 24, + "retainedHeapDelta": 1647440 + } + ] + }, + { + "kind": "renderer", + "mode": "overwrite", + "fixed": false, + "samples": [ + { + "updates": 2500, + "links": 2500, + "markers": 2500, + "rows": 24, + "retainedHeapDelta": 5296144 + }, + { + "updates": 5000, + "links": 5000, + "markers": 5000, + "rows": 24, + "retainedHeapDelta": 10448952 + }, + { + "updates": 7500, + "links": 7500, + "markers": 7500, + "rows": 24, + "retainedHeapDelta": 15485728 + }, + { + "updates": 10000, + "links": 10000, + "markers": 10000, + "rows": 24, + "retainedHeapDelta": 20755144 + } + ] + }, + { + "kind": "renderer", + "mode": "overwrite", + "fixed": true, + "samples": [ + { + "updates": 2500, + "links": 453, + "markers": 453, + "rows": 24, + "retainedHeapDelta": 969392 + }, + { + "updates": 5000, + "links": 905, + "markers": 905, + "rows": 24, + "retainedHeapDelta": 1894808 + }, + { + "updates": 7500, + "links": 333, + "markers": 333, + "rows": 24, + "retainedHeapDelta": 764824 + }, + { + "updates": 10000, + "links": 785, + "markers": 785, + "rows": 24, + "retainedHeapDelta": 1711672 + } + ] + }, + { + "kind": "renderer", + "mode": "erase-line", + "fixed": false, + "samples": [ + { + "updates": 2500, + "links": 2500, + "markers": 2500, + "rows": 24, + "retainedHeapDelta": 5170624 + }, + { + "updates": 5000, + "links": 5000, + "markers": 5000, + "rows": 24, + "retainedHeapDelta": 10326080 + }, + { + "updates": 7500, + "links": 7500, + "markers": 7500, + "rows": 24, + "retainedHeapDelta": 15370224 + }, + { + "updates": 10000, + "links": 10000, + "markers": 10000, + "rows": 24, + "retainedHeapDelta": 20609792 + } + ] + }, + { + "kind": "renderer", + "mode": "erase-line", + "fixed": true, + "samples": [ + { + "updates": 2500, + "links": 453, + "markers": 453, + "rows": 24, + "retainedHeapDelta": 960568 + }, + { + "updates": 5000, + "links": 905, + "markers": 905, + "rows": 24, + "retainedHeapDelta": 1881152 + }, + { + "updates": 7500, + "links": 333, + "markers": 333, + "rows": 24, + "retainedHeapDelta": 732576 + }, + { + "updates": 10000, + "links": 785, + "markers": 785, + "rows": 24, + "retainedHeapDelta": 1658192 + } + ] + }, + { + "kind": "renderer", + "mode": "alternate", + "fixed": false, + "samples": [ + { + "updates": 2500, + "links": 2500, + "markers": 2500, + "rows": 24, + "retainedHeapDelta": 5076776 + }, + { + "updates": 5000, + "links": 5000, + "markers": 5000, + "rows": 24, + "retainedHeapDelta": 10236792 + }, + { + "updates": 7500, + "links": 7500, + "markers": 7500, + "rows": 24, + "retainedHeapDelta": 15273760 + }, + { + "updates": 10000, + "links": 10000, + "markers": 10000, + "rows": 24, + "retainedHeapDelta": 20519904 + } + ] + }, + { + "kind": "renderer", + "mode": "alternate", + "fixed": true, + "samples": [ + { + "updates": 2500, + "links": 453, + "markers": 453, + "rows": 24, + "retainedHeapDelta": 964296 + }, + { + "updates": 5000, + "links": 905, + "markers": 905, + "rows": 24, + "retainedHeapDelta": 1886304 + }, + { + "updates": 7500, + "links": 333, + "markers": 333, + "rows": 24, + "retainedHeapDelta": 741152 + }, + { + "updates": 10000, + "links": 785, + "markers": 785, + "rows": 24, + "retainedHeapDelta": 1671072 + } + ] + } + ], + "sweepCost": { + "rows": 5024, + "columns": 160, + "removed": 1023, + "milliseconds": 9.98633400000017 + } +} diff --git a/mobile/scripts/build-terminal-webview-engine.mjs b/mobile/scripts/build-terminal-webview-engine.mjs index ef6bf0dc1b7..9c5c6fdcb38 100644 --- a/mobile/scripts/build-terminal-webview-engine.mjs +++ b/mobile/scripts/build-terminal-webview-engine.mjs @@ -30,6 +30,7 @@ async function buildEngineJs() { import { Terminal } from '@xterm/xterm' import { Unicode11Addon } from '@xterm/addon-unicode11' import { WebglAddon } from '@xterm/addon-webgl' + import { TerminalOscLinkRetirementAddon } from '../src/shared/terminal-osc-link-retirement' // Why: xterm reaches for these runtime APIs on the terminal-bringup path, // and esbuild lowers syntax but not runtime APIs. Guarded shims let the @@ -60,6 +61,7 @@ async function buildEngineJs() { } window.Terminal = Terminal + window.TerminalOscLinkRetirementAddon = TerminalOscLinkRetirementAddon window.Unicode11Addon = { Unicode11Addon } window.WebglAddon = { WebglAddon } `, diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts index ac415922851..d9fd0c3990c 100644 --- a/mobile/src/terminal/terminal-webview-engine.test.ts +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -128,6 +128,7 @@ describe('terminal WebView bundled engine', () => { expect(window).toMatchObject({ Terminal: expect.any(Function), + TerminalOscLinkRetirementAddon: expect.any(Function), Unicode11Addon: { Unicode11Addon: expect.any(Function) }, WebglAddon: { WebglAddon: expect.any(Function) } }) diff --git a/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts b/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts index 90dba7cbdbc..e39f19a54e1 100644 --- a/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts +++ b/mobile/src/terminal/terminal-webview-html/terminal-init-and-write.ts @@ -71,6 +71,7 @@ export const TERMINAL_HTML_INIT_AND_WRITE = `${TERMINAL_WEBGL_RECOVERY_JS} convertEol: false, allowProposedApi: true }); + if (window.TerminalOscLinkRetirementAddon) term.loadAddon(new window.TerminalOscLinkRetirementAddon()); var nextTerm = term; pendingTerm = nextTerm; term.open(surface); diff --git a/mobile/src/terminal/terminal-webview-osc-link-retirement.test.ts b/mobile/src/terminal/terminal-webview-osc-link-retirement.test.ts new file mode 100644 index 00000000000..620b39f2d90 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-osc-link-retirement.test.ts @@ -0,0 +1,49 @@ +import { Script } from 'node:vm' +import { expect, it } from 'vitest' +import { XTERM_ENGINE_JS } from './terminal-webview-engine.generated' + +it('retires overwritten hyperlinks in the bundled WebView engine while preserving the live link', async () => { + const result: unknown = await new Script(` + var window = globalThis; + var self = globalThis; + ${XTERM_ENGINE_JS} + (async function () { + var term = new Terminal({ cols: 80, rows: 24, scrollback: 5000, allowProposedApi: true }); + term.loadAddon(new TerminalOscLinkRetirementAddon()); + var redraw = '\\r\\x1b[2K\\x1b]8;;https://example.test/link\\x1b\\\\x\\x1b]8;;\\x1b\\\\'; + try { + for (var batch = 0; batch < 16; batch++) { + await new Promise(function (resolve) { term.write(redraw.repeat(256), resolve); }); + } + var registry = term._core._oscLinkService._dataByLinkId; + var cell = term.buffer.normal.getLine(0).getCell(0); + return { + links: registry.size, + rows: term.buffer.normal.length, + uri: registry.get(cell.extended.urlId).data.uri + }; + } finally { + term.dispose(); + } + })(); + `).runInNewContext({ + document: {}, + navigator: { platform: 'Linux armv8l', userAgent: 'Mozilla/5.0 Chrome/74.0.3729.157' }, + console, + setTimeout, + clearTimeout, + queueMicrotask, + performance, + URL + }) + + expect(result).toEqual({ + links: expect.any(Number), + rows: 24, + uri: 'https://example.test/link' + }) + if (typeof result !== 'object' || result === null || !('links' in result)) { + throw new Error('WebView engine did not return its link count') + } + expect(result.links).toBeLessThanOrEqual(1024) +}) diff --git a/src/main/daemon/headless-emulator-osc-link-retirement.test.ts b/src/main/daemon/headless-emulator-osc-link-retirement.test.ts new file mode 100644 index 00000000000..0f1fcbc8e0d --- /dev/null +++ b/src/main/daemon/headless-emulator-osc-link-retirement.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import { HeadlessEmulator } from './headless-emulator' + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function coreFor(emulator: unknown): Record { + if ( + !record(emulator) || + !(emulator.terminal instanceof Terminal) || + !('_core' in emulator.terminal) || + !record(emulator.terminal._core) + ) { + throw new Error('Installed headless terminal shape changed') + } + return emulator.terminal._core +} + +describe.each(['synchronous', 'asynchronous'] as const)('headless %s OSC link cleanup', (mode) => { + it('retires overwritten links through the production write path', async () => { + const emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + const core = coreFor(emulator) + if (mode === 'asynchronous') { + Object.defineProperty(core, 'writeSync', { value: undefined }) + } + const service = core._oscLinkService + if (!record(service) || !(service._dataByLinkId instanceof Map)) { + throw new Error('Installed headless link registry changed') + } + try { + const redraw = '\r\x1b]8;;https://example.test/live\x1b\\x\x1b]8;;\x1b\\' + for (let batch = 0; batch < 16; batch++) { + await emulator.write(redraw.repeat(256)) + } + expect(service._dataByLinkId.size).toBeLessThanOrEqual(1024) + expect(emulator.getSnapshot().oscLinks).toEqual([ + { row: 0, startCol: 0, endCol: 1, uri: 'https://example.test/live' } + ]) + } finally { + emulator.dispose() + } + }) +}) diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 842e65f2018..39efac40d7c 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -3,6 +3,7 @@ import { Terminal } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' import { Unicode11Addon } from '@xterm/addon-unicode11' import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider' +import { createTerminalOscLinkRetirement } from '../../shared/terminal-osc-link-retirement' import { readSavedCursorRegister, serializeWithAbsoluteCursor @@ -45,19 +46,14 @@ export type HeadlessEmulatorWriteOptions = { type TerminalWithSynchronousWrite = Terminal & { _core?: { writeSync?: (data: string) => void - // Why: kitty keyboard flags aren't on the public IModes; read the core service the CSI u handlers mutate. - coreService?: { - kittyKeyboard?: { flags?: number } - } } } const DEFAULT_SCROLLBACK = 5000 -// Keep in sync with the renderer twin terminal-capability-replies.ts (main must not import renderer modules). -const CONPTY_DA1_RESPONSE = '\x1b[?61;4c' export class HeadlessEmulator { private terminal: Terminal + private readonly oscLinkRetirement: () => number private serializer: SerializeAddon private oscText: TerminalOscCwdTitleScanner private mouseModes = new TerminalMouseModeMirror() @@ -90,6 +86,7 @@ export class HeadlessEmulator { // Why: parse CSI =/>/< u pushes so CSI ? u answers with the flags the hidden app pushed (renderer parity). vtExtensions: { kittyKeyboard: true } }) + this.oscLinkRetirement = createTerminalOscLinkRetirement(this.terminal) this.serializer = new SerializeAddon() this.terminal.loadAddon(this.serializer) @@ -114,7 +111,8 @@ export class HeadlessEmulator { this.conptyDa1OverrideInstalled = true installDeviceAttributesResponder({ parser: this.terminal.parser, - response: CONPTY_DA1_RESPONSE, + // Keep in sync with renderer terminal-capability-replies.ts. + response: '\x1b[?61;4c', reply: (data) => this.emitQueryReply(data) }) } @@ -191,6 +189,7 @@ export class HeadlessEmulator { // Why: commit the mouse-mode mirror only after xterm has parsed the same bytes (snapshots combine both). this.mouseModes.scan(data) this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) + this.oscLinkRetirement() resolve() }) }) @@ -224,6 +223,7 @@ export class HeadlessEmulator { } this.mouseModes.scan(data) this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) + this.oscLinkRetirement() return true } diff --git a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx index f0360d2b795..0166eeba0d0 100644 --- a/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx +++ b/src/renderer/src/components/dashboard-popout/AgentTerminalPreview.tsx @@ -6,6 +6,7 @@ import { subscribeToTerminalUserInput } from '@/components/terminal-pane/termina import { composeActiveTerminalTheme } from '@/components/terminal-pane/terminal-appearance' import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark' import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-kitty-keyboard-mode-tracker' +import { TerminalOscLinkRetirementAddon } from '../../../../shared/terminal-osc-link-retirement' import { replayPreviewConnectionSnapshot } from './preview-terminal-snapshot-replay' import { useEffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/use-effective-mac-option-as-alt' import { @@ -273,6 +274,7 @@ export function AgentTerminalPreview({ scrollback: PREVIEW_SCROLLBACK_BUFFER_ROWS }) ) + terminal.loadAddon(new TerminalOscLinkRetirementAddon()) try { terminal.open(container) } catch { diff --git a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts index d3b6b2b6ffd..da3b56ddfe4 100644 --- a/src/renderer/src/lib/pane-manager/pane-dom-creation.ts +++ b/src/renderer/src/lib/pane-manager/pane-dom-creation.ts @@ -6,6 +6,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links' import { Terminal } from '@xterm/xterm' import type { ITerminalOptions } from '@xterm/xterm' import type { TerminalLeafId } from '../../../../shared/stable-pane-id' +import { TerminalOscLinkRetirementAddon } from '../../../../shared/terminal-osc-link-retirement' import type { DragReorderCallbacks, DragReorderState } from './pane-drag-reorder' import { attachPaneDrag } from './pane-drag-pointer' import type { ManagedPaneInternal, PaneManagerOptions } from './pane-manager-types' @@ -45,6 +46,7 @@ export function createPaneDOM( } const terminal = new Terminal(terminalOpts) + terminal.loadAddon(new TerminalOscLinkRetirementAddon()) // Why: a synchronous throw inside any link provider's provideLinks (notably // xterm web-links' LinkComputer raising RangeError on a pathological wrapped // line) escapes to window.onerror and gets the renderer killed. Guard every diff --git a/src/shared/terminal-osc-link-retirement.test.ts b/src/shared/terminal-osc-link-retirement.test.ts new file mode 100644 index 00000000000..e27a98412d4 --- /dev/null +++ b/src/shared/terminal-osc-link-retirement.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import { Terminal as HeadlessTerminal } from '@xterm/headless' +import { Terminal as RendererTerminal } from '@xterm/xterm' +import { + createTerminalOscLinkRetirement, + TerminalOscLinkRetirementAddon +} from './terminal-osc-link-retirement' + +const URL = 'https://example.test/link' +const OPEN = `\x1b]8;;${URL}\x1b\\` +const CLOSE = '\x1b]8;;\x1b\\' +const REDRAW = `\r\x1b[2K${OPEN}x${CLOSE}` + +function record(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function linkRegistry(terminal: unknown): Map { + if ( + !record(terminal) || + !record(terminal._core) || + !record(terminal._core._oscLinkService) || + !(terminal._core._oscLinkService._dataByLinkId instanceof Map) + ) { + throw new Error('Installed xterm link registry changed') + } + return terminal._core._oscLinkService._dataByLinkId +} + +function cellUri( + terminal: HeadlessTerminal | RendererTerminal, + buffer: 'normal' | 'alternate', + row: number, + column: number +): unknown { + const cell = terminal.buffer[buffer].getLine(row)?.getCell(column) + if (!record(cell) || !record(cell.extended)) { + return undefined + } + const entry = linkRegistry(terminal).get(cell.extended.urlId) + return record(entry) && record(entry.data) ? entry.data.uri : undefined +} + +function write(terminal: HeadlessTerminal | RendererTerminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +describe.each([ + ['headless', HeadlessTerminal], + ['renderer', RendererTerminal] +] as const)('%s OSC link retirement', (_kind, Terminal) => { + function createTerminal(): HeadlessTerminal | RendererTerminal { + return new Terminal({ + cols: 80, + rows: 24, + scrollback: 1500, + allowProposedApi: true, + logLevel: 'off' + }) + } + + it.each(['overwrite', 'erase-line', 'alternate'] as const)( + 'bounds %s redraws without losing the live link or unrelated markers', + async (mode) => { + const terminal = createTerminal() + const retirement = createTerminalOscLinkRetirement(terminal) + try { + if (mode === 'alternate') { + await write(terminal, '\x1b[?1049h') + } + const marker = terminal.registerMarker(0) + const redraw = mode === 'overwrite' ? `\r${OPEN}x${CLOSE}` : REDRAW + for (let batch = 0; batch < 16; batch++) { + await write(terminal, redraw.repeat(256)) + retirement() + } + expect(terminal.buffer.active.length).toBe(24) + expect(linkRegistry(terminal).size).toBeLessThanOrEqual(1024) + expect(terminal.markers.length).toBeLessThanOrEqual(1025) + expect(marker?.isDisposed).toBe(false) + expect(cellUri(terminal, mode === 'alternate' ? 'alternate' : 'normal', 0, 0)).toBe(URL) + } finally { + terminal.dispose() + } + } + ) + + it('keeps every scrollback link while the alternate screen is repainted', async () => { + const terminal = createTerminal() + terminal.loadAddon(new TerminalOscLinkRetirementAddon()) + try { + for (let row = 0; row < 1100; row++) { + await write(terminal, `\x1b]8;id=row-${row};https://example.test/${row}\x1b\\x${CLOSE}\r\n`) + } + await write(terminal, '\x1b[?1049h\x1b[H') + for (let batch = 0; batch < 16; batch++) { + await write(terminal, REDRAW.repeat(256)) + } + expect(linkRegistry(terminal).size).toBeLessThanOrEqual(1100 + 1024) + for (let row = 0; row < 1100; row++) { + expect(cellUri(terminal, 'normal', row, 0)).toBe(`https://example.test/${row}`) + } + expect(cellUri(terminal, 'alternate', 0, 0)).toBe(URL) + } finally { + terminal.dispose() + } + }) + + it('keeps an open link whose text arrives in a later write', async () => { + const terminal = createTerminal() + const retirement = createTerminalOscLinkRetirement(terminal) + try { + await write(terminal, `${REDRAW.repeat(1024)}\r\x1b[2K\x1b]8;;${URL}/pending\x1b\\`) + expect(retirement()).toBeGreaterThan(1000) + await write(terminal, `pending${CLOSE}`) + expect(cellUri(terminal, 'normal', 0, 0)).toBe(`${URL}/pending`) + } finally { + terminal.dispose() + } + }) + + it('preserves partially overwritten and reflowed links, then permits reuse of a retired explicit id', async () => { + const terminal = createTerminal() + const retirement = createTerminalOscLinkRetirement(terminal) + try { + await write(terminal, `\x1b]8;id=stable;${URL}/kept\x1b\\${'x'.repeat(160)}${CLOSE}\r `) + terminal.resize(40, 24) + await write(terminal, `\x1b[10;1H${REDRAW.repeat(1024)}`) + retirement() + expect(cellUri(terminal, 'normal', 0, 0)).toBe(`${URL}/kept`) + await write(terminal, `\x1b[10;1H\x1b]8;id=retired;${URL}/old\x1b\\x${CLOSE}`) + await write(terminal, REDRAW.repeat(2048)) + retirement() + await write(terminal, `\r\x1b]8;id=retired;${URL}/old\x1b\\x${CLOSE}`) + expect(cellUri(terminal, 'normal', 9, 0)).toBe(`${URL}/old`) + } finally { + terminal.dispose() + } + }) +}) diff --git a/src/shared/terminal-osc-link-retirement.ts b/src/shared/terminal-osc-link-retirement.ts new file mode 100644 index 00000000000..8350620c006 --- /dev/null +++ b/src/shared/terminal-osc-link-retirement.ts @@ -0,0 +1,115 @@ +import type { IBuffer, Terminal } from '@xterm/headless' + +type OscLinkMarker = { dispose(): void } +type OscLinkEntry = { id: number; lines: OscLinkMarker[] } +type TerminalBuffers = Pick + +/** xterm's line markers outlive overwritten hyperlinks, including redraws without scrollback. */ +export function createTerminalOscLinkRetirement(terminal: TerminalBuffers): () => number { + const SWEEP_GROWTH = 1024 + let nextSweepSize = SWEEP_GROWTH + let previousSize = 0 + + function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null + } + + function isLinkEntry(value: unknown): value is OscLinkEntry { + return ( + isRecord(value) && + typeof value.id === 'number' && + Array.isArray(value.lines) && + value.lines.every((line: unknown) => isRecord(line) && typeof line.dispose === 'function') + ) + } + + function addAttributeLink(attributes: unknown, links: Set): void { + if ( + isRecord(attributes) && + isRecord(attributes.extended) && + typeof attributes.extended.urlId === 'number' && + attributes.extended.urlId !== 0 + ) { + links.add(attributes.extended.urlId) + } + } + + function collectBufferLinks(buffer: IBuffer, links: Set): void { + const cell = buffer.getNullCell() + for (let row = 0; row < buffer.length; row++) { + const line = buffer.getLine(row) + if (!line) { + continue + } + for (let column = 0; column < line.length; column++) { + addAttributeLink(line.getCell(column, cell), links) + } + } + } + + return (): number => { + if (!('_core' in terminal) || !isRecord(terminal._core)) { + return 0 + } + const core = terminal._core + const service = core._oscLinkService + const input = core._inputHandler + if ( + !isRecord(service) || + !(service._dataByLinkId instanceof Map) || + !isRecord(input) || + typeof input.getAttrData !== 'function' + ) { + return 0 + } + const entries: Map = service._dataByLinkId + if (entries.size < previousSize) { + nextSweepSize = entries.size + SWEEP_GROWTH + } + previousSize = entries.size + if (entries.size < nextSweepSize) { + return 0 + } + + const live = new Set() + collectBufferLinks(terminal.buffer.normal, live) + collectBufferLinks(terminal.buffer.alternate, live) + // An OSC 8 open can finish one write before its linked text arrives in the next. + addAttributeLink(input.getAttrData(), live) + const bufferService = core._bufferService + if (isRecord(bufferService) && isRecord(bufferService.buffers)) { + for (const buffer of [bufferService.buffers.normal, bufferService.buffers.alt]) { + if (isRecord(buffer)) { + addAttributeLink(buffer.savedCurAttrData, live) + } + } + } + + const before = entries.size + for (const entry of entries.values()) { + if (isLinkEntry(entry) && !live.has(entry.id)) { + // Dispose only link-owned markers; xterm removes both registry indexes itself. + const markers = entry.lines.slice() + for (const marker of markers) { + marker.dispose() + } + } + } + previousSize = entries.size + nextSweepSize = entries.size + SWEEP_GROWTH + return before - entries.size + } +} + +export class TerminalOscLinkRetirementAddon { + declare private subscription: { dispose(): void } | undefined + + activate(terminal: Pick): void { + this.subscription = terminal.onWriteParsed(createTerminalOscLinkRetirement(terminal)) + } + + dispose(): void { + this.subscription?.dispose() + this.subscription = undefined + } +}