mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
fix(terminal): retire overwritten OSC hyperlink metadata
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
)
|
||||
)
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user