fix(terminal): retire overwritten OSC hyperlink metadata

This commit is contained in:
m4air
2026-09-15 21:39:08 -07:00
parent 357c9780f8
commit ca4acd73c2
13 changed files with 977 additions and 7 deletions
+73
View File
@@ -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.5920.93 MB | 1.651.72 MB |
| Renderer | 20.5220.76 MB | 1.661.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.
@@ -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
)
)
+433
View File
@@ -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
}
}
@@ -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 }
`,
@@ -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) }
})
@@ -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);
@@ -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)
})
@@ -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<string, unknown> {
return typeof value === 'object' && value !== null
}
function coreFor(emulator: unknown): Record<string, unknown> {
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()
}
})
})
+7 -7
View File
@@ -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
}
@@ -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 {
@@ -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
@@ -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<string, unknown> {
return typeof value === 'object' && value !== null
}
function linkRegistry(terminal: unknown): Map<unknown, unknown> {
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<void> {
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()
}
})
})
+115
View File
@@ -0,0 +1,115 @@
import type { IBuffer, Terminal } from '@xterm/headless'
type OscLinkMarker = { dispose(): void }
type OscLinkEntry = { id: number; lines: OscLinkMarker[] }
type TerminalBuffers = Pick<Terminal, 'buffer'>
/** 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<string, unknown> {
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<number>): 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<number>): 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<unknown, unknown> = service._dataByLinkId
if (entries.size < previousSize) {
nextSweepSize = entries.size + SWEEP_GROWTH
}
previousSize = entries.size
if (entries.size < nextSweepSize) {
return 0
}
const live = new Set<number>()
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<Terminal, 'buffer' | 'onWriteParsed'>): void {
this.subscription = terminal.onWriteParsed(createTerminalOscLinkRetirement(terminal))
}
dispose(): void {
this.subscription?.dispose()
this.subscription = undefined
}
}