diff --git a/docs/audits/terminal-mode-tail-retention/README.md b/docs/audits/terminal-mode-tail-retention/README.md new file mode 100644 index 00000000000..df5635a6f3a --- /dev/null +++ b/docs/audits/terminal-mode-tail-retention/README.md @@ -0,0 +1,122 @@ +# Retained terminal mode scan tails + +The kitty keyboard tracker and daemon mouse-mode mirror retain an incomplete +escape-sequence tail of at most 4,096 UTF-16 code units. A V8 sliced string can +keep the entire consumed input alive through that small tail. An ordinary +split grouped mode sequence, `ESC[?1049;2004;1000;`, is enough: its 18-character +tail retains each input backing string while its parser stays idle. + +The correction copies only accepted incomplete tails through the existing +`ownRetainedString` helper. Empty/rejected tails and short ESC/CSI prefixes keep +their existing behavior; the helper leaves strings shorter than 13 code units +alone. Parser state, live/replay semantics, stack caps, mode flags, and wire +content are unchanged. These are additional boundaries in +[#20960](https://github.com/stablyai/orca/pull/20960), alongside the +[PTY detector carries](../pty-detector-retention/README.md). + +## Reproduce + +```sh +ORCA_BACKGROUND_LAUNCH=1 node --expose-gc --max-old-space-size=192 docs/audits/terminal-mode-tail-retention/reproduce.cjs +``` + +Run the same script with the installed Electron executable, setting +`ELECTRON_RUN_AS_NODE=1` and `ORCA_BACKGROUND_LAUNCH=1`, and passing the same +Node flags. This launches no app window or native PTY. Each run has a 30-second +deadline and writes either [Node results](./node-results.json) or +[Electron results](./electron-results.json). + +The loader reads the actual five source modules, verifies their fixed hashes, +reverses only the three copy calls/imports in memory for the baseline, and +verifies the resulting baseline hashes. All evaluated module and bundle hashes +are recorded. It needs no Git history, absolute development paths, or ignored +notes. CRLF source text is normalized before hashing. The parsers and flag +parser match `v1.4.198`; all five modules match the pre-extension topic +`8d599520e44654a5c28e9930e3070c00d6499931`, except for these copy calls. This +tests current dependencies and the selected source modules, not a historical +application binary. See [source versions](./source-versions.json). + +Each runtime checks 42 bounded heap cases: baseline, fixed Buffer copier, and +fixed Bufferless copier; kitty live/replay, mouse live; 32 distinct 64-Ki-character +inputs and eight 4-Mi-character inputs; short, complete, oversized, and C1-CSI +tail controls. Completion must reconstruct the correct modes and release the +large backing strings. Additional controls preserve replay push idempotence, +the 16-frame live stack cap, alternate-screen state, snapshot unknownness, +mouse encodings, and RIS with a trailing partial sequence. + +Both runtimes pass all 42 cases. Representative live-path heap deltas in bytes: + +| Runtime | Parser | Input × owners | Baseline | Buffer copy | Bufferless copy | +| --------------------- | ------ | -------------- | ---------: | ----------: | --------------: | +| Node 26 | Kitty | 64 Ki × 32 | 2,120,952 | 18,376 | 9,480 | +| Node 26 | Mouse | 64 Ki × 32 | 2,111,984 | 15,112 | 9,336 | +| Node 26 | Kitty | 4 Mi × 8 | 33,557,944 | 3,448 | 3,448 | +| Node 26 | Mouse | 4 Mi × 8 | 33,557,072 | 1,312 | 1,312 | +| Electron 43 / Node 24 | Kitty | 64 Ki × 32 | 2,111,864 | 12,244 | 5,192 | +| Electron 43 / Node 24 | Mouse | 64 Ki × 32 | 2,103,444 | 14,432 | 8,004 | +| Electron 43 / Node 24 | Kitty | 4 Mi × 8 | 33,556,316 | 1,884 | 2,604 | +| Electron 43 / Node 24 | Mouse | 4 Mi × 8 | 33,556,172 | 776 | 752 | + +Heap readings include owner overhead and follow forced GC. The harness clears +V8's last successful regexp input identically in baseline and fixed cases to +isolate per-owner storage. That independent process-wide regexp reference can +keep a most-recent input alive until another successful match; this change does +not eliminate it. The Bufferless selection is memoized while Buffer is absent, +then Buffer is restored before measuring; it exercises the actual renderer +fallback without running a browser renderer. + +Two permanent kitty heap regressions fail before the correction at 33,560,832 +and 33,575,040 retained bytes against a 2-MiB limit. They also verify that the +retained prefix completes correctly and that replay/pop/snapshot state remains +valid. Existing parser and copier tests provide the wider protocol controls. +The two mouse regressions likewise fail before the correction at 33,559,240 and +33,573,200 bytes, and pass afterward with both CSI encodings. The five-suite +run passes 98 tests, including actual headless-emulator mode snapshots; Node +and renderer TypeScript checks pass. + +## Callers and lifetime + +- Kitty renderer panes create or reuse one tracker per pane in + `connect-pane-pty.ts:160`. `write-pty-output-to-xterm.ts:23` feeds application + output; `apply-reattach-payload.ts` and `hidden-output-seq-and-skip.ts` feed + replay. Fresh spawn and exit reset it, and + `terminal-pane-pane-closed.ts:69` deletes the map entry. Dashboard previews + own another tracker per effect (`AgentTerminalPreview.tsx:116`); cleanup + removes its listeners and disposes its terminal. +- Main's `orca-runtime-capture-provider-terminal-buffer.ts:23` registers + temporary live scanners during provider snapshot acquisition and removes + them in `finally`. It creates a persistent tracker only after observing an + alternate-screen transition (`:48–57`). `orca-runtime-on-pty-data.ts:30` + feeds those trackers before later output processing. Exit, floating PTY + liveness cleanup, and provider generation reset delete the persistent entry. +- The daemon does not directly instantiate the kitty tracker, despite its + old class comment: its kitty flags come from xterm. No mobile bundle imports + this class. Mobile can exercise main-side snapshot acquisition; SSH output + can reach main and renderer trackers through the existing provider routes. +- Mouse mirrors are owned by `HeadlessEmulator` (`headless-emulator.ts:59`). + Async writes scan after xterm parses the data (`:190`); synchronous live and + cold-restore writes scan at `:224`. Both daemon sessions and main's headless + projections use this emulator. It therefore also covers local/remote host + emulators serving mobile clients. Emulator disposal stops future writes; + eventual owner release removes the mirror. Completing/replacing its tail + also releases the old backing string. No ownership or shutdown rule changes. + +## Scope and limits + +This is a per-owner last-input cost. It does not grow indefinitely with a fixed +set of parsers and bounded input chunks, and further output often completes or +replaces the tail. Multiple readers of the same input can share its backing +storage; do not sum their measurements as independent process memory. + +Ordinary daemon bulk frames delivered to main are at most 64 Ki characters +(`daemon-stream-data-batcher.ts:35`), and ordinary relay output slices are +16 Ki characters (`relay/pty-handler.ts:343`). Mouse scanning inside the daemon +happens before outgoing stream framing. The 64-Ki cases demonstrate the issue +at a normal main-input bound; the 4-Mi cases amplify the mechanism, not a claim +that ordinary native reads or daemon frames have that size. Replay inputs and +transformed streams follow their own existing limits. No network, application +renderer, operating-system PTY, or incident heap was used in this proof. + +This reduces retained output in local and SSH paths without changing published +terminal content. It neither establishes the trigger in #19831/#19768 nor +explains a reported sustained growth rate or multi-gigabyte incident by itself. diff --git a/docs/audits/terminal-mode-tail-retention/electron-results.json b/docs/audits/terminal-mode-tail-retention/electron-results.json new file mode 100644 index 00000000000..e81d9abc27c --- /dev/null +++ b/docs/audits/terminal-mode-tail-retention/electron-results.json @@ -0,0 +1,467 @@ +{ + "node": "v24.21.0", + "electron": "43.7.0", + "v8": "15.0.245.31-electron.0", + "platform": "darwin", + "runnerSha256": "b78dc54a08345732235d4fcc1f72ca1534b00f89280981beda00667d22b8f291", + "loaderSha256": "730da9091abba3997432657aef4a2e6e7a3dd0e9218ea78f1838806b6c0fa789", + "pendingTailCodeUnits": 18, + "clearedRegexStatics": true, + "bundles": { + "baseline": { + "bundleSha256": "6023f5d6868f5db601f6883acda5d56990d204084a6a3c812799aff1e533a5b0", + "evaluatedSources": { + "src/shared/own-retained-string.ts": "addea88bc11c725192be2ff617b91315a875113844c95de03e5529cf3d7ef2e2", + "src/shared/terminal-kitty-keyboard-mode-tracker.ts": "2ee0bad47d4575046f71c16e0334a8ae8be531f0cfc67133abc287f8ae5aa403", + "src/main/daemon/terminal-mouse-mode-mirror.ts": "5bf8a94ad7ec3fdd151ba7559f49a229b2156818aba88f7b562bef258048acf1", + "src/shared/owned-utf16-suffix.ts": "1ca0bb35dfff2d9f3c02caee3d05b0e0c97378df71c878622acf703524d2c475", + "src/shared/terminal-kitty-keyboard-flags.ts": "ee8ca63afdca8356fa12878e74973ff92d807aa8d99750b2415b42f4bccb0225" + }, + "sourceVersionsSha256": "29cde88cb882b2d7726a2d4a077484217076090acc009f47a805e368f597e51a" + }, + "fixed-buffer": { + "bundleSha256": "bbae10735fc999eea9dafffcd0e1fc4c47e1a8f1daf0045ed074b08cf3a93d45", + "evaluatedSources": { + "src/shared/own-retained-string.ts": "addea88bc11c725192be2ff617b91315a875113844c95de03e5529cf3d7ef2e2", + "src/main/daemon/terminal-mouse-mode-mirror.ts": "e8f4888dabee5c4cc50b0f15584b3b1b5e2da4db1ba25695dd5762ef9c4a6fb3", + "src/shared/terminal-kitty-keyboard-mode-tracker.ts": "a8da704564b9ab97aa344f380940021d3fa98f80af817751824d14271acb8a84", + "src/shared/owned-utf16-suffix.ts": "1ca0bb35dfff2d9f3c02caee3d05b0e0c97378df71c878622acf703524d2c475", + "src/shared/terminal-kitty-keyboard-flags.ts": "ee8ca63afdca8356fa12878e74973ff92d807aa8d99750b2415b42f4bccb0225" + }, + "sourceVersionsSha256": "29cde88cb882b2d7726a2d4a077484217076090acc009f47a805e368f597e51a" + }, + "fixed-fallback": { + "bundleSha256": "bbae10735fc999eea9dafffcd0e1fc4c47e1a8f1daf0045ed074b08cf3a93d45", + "evaluatedSources": { + "src/shared/own-retained-string.ts": "addea88bc11c725192be2ff617b91315a875113844c95de03e5529cf3d7ef2e2", + "src/main/daemon/terminal-mouse-mode-mirror.ts": "e8f4888dabee5c4cc50b0f15584b3b1b5e2da4db1ba25695dd5762ef9c4a6fb3", + "src/shared/terminal-kitty-keyboard-mode-tracker.ts": "a8da704564b9ab97aa344f380940021d3fa98f80af817751824d14271acb8a84", + "src/shared/owned-utf16-suffix.ts": "1ca0bb35dfff2d9f3c02caee3d05b0e0c97378df71c878622acf703524d2c475", + "src/shared/terminal-kitty-keyboard-flags.ts": "ee8ca63afdca8356fa12878e74973ff92d807aa8d99750b2415b42f4bccb0225" + }, + "sourceVersionsSha256": "29cde88cb882b2d7726a2d4a077484217076090acc009f47a805e368f597e51a" + } + }, + "reports": [ + { + "variant": "baseline", + "kind": "kitty", + "method": "scan", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 2111864, + "afterCompletionDelta": 29832 + }, + { + "variant": "baseline", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 33556316, + "afterCompletionDelta": 9244 + }, + { + "variant": "baseline", + "kind": "kitty", + "method": "scanReplay", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 2102416, + "afterCompletionDelta": 4916 + }, + { + "variant": "baseline", + "kind": "kitty", + "method": "scanReplay", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 33556304, + "afterCompletionDelta": 1968 + }, + { + "variant": "baseline", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 2, + "heapDelta": 1756, + "afterCompletionDelta": 1712 + }, + { + "variant": "baseline", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 1652, + "afterCompletionDelta": 2880 + }, + { + "variant": "baseline", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 1652, + "afterCompletionDelta": 3024 + }, + { + "variant": "baseline", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 17, + "heapDelta": 33556316, + "afterCompletionDelta": 1692 + }, + { + "variant": "baseline", + "kind": "mouse", + "method": "scan", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 2103444, + "afterCompletionDelta": 5584 + }, + { + "variant": "baseline", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 33556172, + "afterCompletionDelta": 1496 + }, + { + "variant": "baseline", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 2, + "heapDelta": 672, + "afterCompletionDelta": 644 + }, + { + "variant": "baseline", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 520, + "afterCompletionDelta": 532 + }, + { + "variant": "baseline", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 520, + "afterCompletionDelta": 532 + }, + { + "variant": "baseline", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 17, + "heapDelta": 33555224, + "afterCompletionDelta": 560 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scan", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 12244, + "afterCompletionDelta": 11632 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 1884, + "afterCompletionDelta": 1640 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scanReplay", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 5148, + "afterCompletionDelta": 4176 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scanReplay", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 1884, + "afterCompletionDelta": 1640 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 2, + "heapDelta": 1756, + "afterCompletionDelta": 1688 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 1652, + "afterCompletionDelta": 2808 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 1628, + "afterCompletionDelta": 1640 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 17, + "heapDelta": 1884, + "afterCompletionDelta": 1652 + }, + { + "variant": "fixed-buffer", + "kind": "mouse", + "method": "scan", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 14432, + "afterCompletionDelta": 13692 + }, + { + "variant": "fixed-buffer", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 776, + "afterCompletionDelta": 532 + }, + { + "variant": "fixed-buffer", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 2, + "heapDelta": 672, + "afterCompletionDelta": 628 + }, + { + "variant": "fixed-buffer", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 520, + "afterCompletionDelta": 532 + }, + { + "variant": "fixed-buffer", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 520, + "afterCompletionDelta": 532 + }, + { + "variant": "fixed-buffer", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 17, + "heapDelta": 792, + "afterCompletionDelta": 560 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scan", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 5192, + "afterCompletionDelta": 4180 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 2604, + "afterCompletionDelta": 2360 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scanReplay", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 5176, + "afterCompletionDelta": 7104 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scanReplay", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 1884, + "afterCompletionDelta": 1640 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 2, + "heapDelta": 2744, + "afterCompletionDelta": 2676 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 1652, + "afterCompletionDelta": 1664 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": -3344, + "afterCompletionDelta": -3332 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 17, + "heapDelta": 1884, + "afterCompletionDelta": 1652 + }, + { + "variant": "fixed-fallback", + "kind": "mouse", + "method": "scan", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 8004, + "afterCompletionDelta": 6992 + }, + { + "variant": "fixed-fallback", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 752, + "afterCompletionDelta": 508 + }, + { + "variant": "fixed-fallback", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 2, + "heapDelta": 672, + "afterCompletionDelta": 1464 + }, + { + "variant": "fixed-fallback", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 520, + "afterCompletionDelta": 532 + }, + { + "variant": "fixed-fallback", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 1548, + "afterCompletionDelta": 1560 + }, + { + "variant": "fixed-fallback", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 17, + "heapDelta": 776, + "afterCompletionDelta": 544 + } + ] +} diff --git a/docs/audits/terminal-mode-tail-retention/load-source.cjs b/docs/audits/terminal-mode-tail-retention/load-source.cjs new file mode 100644 index 00000000000..fdc39d2b3c6 --- /dev/null +++ b/docs/audits/terminal-mode-tail-retention/load-source.cjs @@ -0,0 +1,68 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const Module = require('node:module') +const { createHash } = require('node:crypto') +const { build } = require('esbuild') + +const root = path.resolve(__dirname, '../../..') +const sha = (value) => createHash('sha256').update(value).digest('hex') +const read = (file) => fs.readFileSync(file, 'utf8').replaceAll('\r\n', '\n') +const versionsText = read(path.join(__dirname, 'source-versions.json')) +const versions = JSON.parse(versionsText) + +async function loadSource(fixed) { + const evaluatedSources = {} + const built = await build({ + stdin: { + contents: [ + "export { TerminalKittyKeyboardModeTracker } from './src/shared/terminal-kitty-keyboard-mode-tracker'", + "export { TerminalMouseModeMirror } from './src/main/daemon/terminal-mouse-mode-mirror'", + "export { ownRetainedString, resetOwnRetainedStringCopier } from './src/shared/own-retained-string'" + ].join('\n'), + resolveDir: root + }, + bundle: true, + platform: 'node', + format: 'cjs', + write: false, + plugins: [ + { + name: 'hash-fenced-retained-mode-tails', + setup(builder) { + builder.onLoad({ filter: /\.ts$/ }, ({ path: filename }) => { + const relative = path.relative(root, filename).split(path.sep).join('/') + const version = versions.sources[relative] + assert.ok(version, `Unreviewed source: ${relative}`) + let contents = read(filename) + assert.equal(sha(contents), version.fixedSha256, `Fixed source changed: ${relative}`) + if (!fixed && version.reverse) { + for (const { from, to, count } of version.reverse) { + assert.equal(contents.split(from).length - 1, count) + contents = contents.replaceAll(from, to) + } + } + const expected = fixed ? version.fixedSha256 : version.baselineSha256 + assert.equal(sha(contents), expected, `Evaluated source changed: ${relative}`) + evaluatedSources[relative] = sha(contents) + return { contents, loader: 'ts' } + }) + } + } + ] + }) + assert.deepEqual(Object.keys(evaluatedSources).sort(), Object.keys(versions.sources).sort()) + const filename = path.join(__dirname, fixed ? 'fixed-bundle.cjs' : 'baseline-bundle.cjs') + const loaded = new Module(filename, module) + loaded.filename = filename + loaded.paths = Module._nodeModulePaths(root) + loaded._compile(built.outputFiles[0].text, filename) + return { + ...loaded.exports, + evaluatedSources, + bundleSha256: sha(built.outputFiles[0].text), + sourceVersionsSha256: sha(versionsText) + } +} + +module.exports = { loadSource, sha, read } diff --git a/docs/audits/terminal-mode-tail-retention/node-results.json b/docs/audits/terminal-mode-tail-retention/node-results.json new file mode 100644 index 00000000000..e43d49c58df --- /dev/null +++ b/docs/audits/terminal-mode-tail-retention/node-results.json @@ -0,0 +1,467 @@ +{ + "node": "v26.6.0", + "electron": null, + "v8": "14.6.202.34-node.26", + "platform": "darwin", + "runnerSha256": "b78dc54a08345732235d4fcc1f72ca1534b00f89280981beda00667d22b8f291", + "loaderSha256": "730da9091abba3997432657aef4a2e6e7a3dd0e9218ea78f1838806b6c0fa789", + "pendingTailCodeUnits": 18, + "clearedRegexStatics": true, + "bundles": { + "baseline": { + "bundleSha256": "6023f5d6868f5db601f6883acda5d56990d204084a6a3c812799aff1e533a5b0", + "evaluatedSources": { + "src/shared/own-retained-string.ts": "addea88bc11c725192be2ff617b91315a875113844c95de03e5529cf3d7ef2e2", + "src/shared/terminal-kitty-keyboard-mode-tracker.ts": "2ee0bad47d4575046f71c16e0334a8ae8be531f0cfc67133abc287f8ae5aa403", + "src/main/daemon/terminal-mouse-mode-mirror.ts": "5bf8a94ad7ec3fdd151ba7559f49a229b2156818aba88f7b562bef258048acf1", + "src/shared/owned-utf16-suffix.ts": "1ca0bb35dfff2d9f3c02caee3d05b0e0c97378df71c878622acf703524d2c475", + "src/shared/terminal-kitty-keyboard-flags.ts": "ee8ca63afdca8356fa12878e74973ff92d807aa8d99750b2415b42f4bccb0225" + }, + "sourceVersionsSha256": "29cde88cb882b2d7726a2d4a077484217076090acc009f47a805e368f597e51a" + }, + "fixed-buffer": { + "bundleSha256": "bbae10735fc999eea9dafffcd0e1fc4c47e1a8f1daf0045ed074b08cf3a93d45", + "evaluatedSources": { + "src/shared/own-retained-string.ts": "addea88bc11c725192be2ff617b91315a875113844c95de03e5529cf3d7ef2e2", + "src/shared/terminal-kitty-keyboard-mode-tracker.ts": "a8da704564b9ab97aa344f380940021d3fa98f80af817751824d14271acb8a84", + "src/main/daemon/terminal-mouse-mode-mirror.ts": "e8f4888dabee5c4cc50b0f15584b3b1b5e2da4db1ba25695dd5762ef9c4a6fb3", + "src/shared/owned-utf16-suffix.ts": "1ca0bb35dfff2d9f3c02caee3d05b0e0c97378df71c878622acf703524d2c475", + "src/shared/terminal-kitty-keyboard-flags.ts": "ee8ca63afdca8356fa12878e74973ff92d807aa8d99750b2415b42f4bccb0225" + }, + "sourceVersionsSha256": "29cde88cb882b2d7726a2d4a077484217076090acc009f47a805e368f597e51a" + }, + "fixed-fallback": { + "bundleSha256": "bbae10735fc999eea9dafffcd0e1fc4c47e1a8f1daf0045ed074b08cf3a93d45", + "evaluatedSources": { + "src/shared/own-retained-string.ts": "addea88bc11c725192be2ff617b91315a875113844c95de03e5529cf3d7ef2e2", + "src/main/daemon/terminal-mouse-mode-mirror.ts": "e8f4888dabee5c4cc50b0f15584b3b1b5e2da4db1ba25695dd5762ef9c4a6fb3", + "src/shared/terminal-kitty-keyboard-mode-tracker.ts": "a8da704564b9ab97aa344f380940021d3fa98f80af817751824d14271acb8a84", + "src/shared/owned-utf16-suffix.ts": "1ca0bb35dfff2d9f3c02caee3d05b0e0c97378df71c878622acf703524d2c475", + "src/shared/terminal-kitty-keyboard-flags.ts": "ee8ca63afdca8356fa12878e74973ff92d807aa8d99750b2415b42f4bccb0225" + }, + "sourceVersionsSha256": "29cde88cb882b2d7726a2d4a077484217076090acc009f47a805e368f597e51a" + } + }, + "reports": [ + { + "variant": "baseline", + "kind": "kitty", + "method": "scan", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 2120952, + "afterCompletionDelta": 31112 + }, + { + "variant": "baseline", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 33557944, + "afterCompletionDelta": 3840 + }, + { + "variant": "baseline", + "kind": "kitty", + "method": "scanReplay", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 2107000, + "afterCompletionDelta": 9336 + }, + { + "variant": "baseline", + "kind": "kitty", + "method": "scanReplay", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 33557928, + "afterCompletionDelta": 3752 + }, + { + "variant": "baseline", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 2, + "heapDelta": 3320, + "afterCompletionDelta": 3288 + }, + { + "variant": "baseline", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 3176, + "afterCompletionDelta": 4792 + }, + { + "variant": "baseline", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 3176, + "afterCompletionDelta": 4904 + }, + { + "variant": "baseline", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 17, + "heapDelta": 33557944, + "afterCompletionDelta": 3232 + }, + { + "variant": "baseline", + "kind": "mouse", + "method": "scan", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 2111984, + "afterCompletionDelta": 13904 + }, + { + "variant": "baseline", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 33557072, + "afterCompletionDelta": 2272 + }, + { + "variant": "baseline", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 2, + "heapDelta": 1232, + "afterCompletionDelta": 1232 + }, + { + "variant": "baseline", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 992, + "afterCompletionDelta": 1008 + }, + { + "variant": "baseline", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 992, + "afterCompletionDelta": 1008 + }, + { + "variant": "baseline", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 17, + "heapDelta": 33555840, + "afterCompletionDelta": 1048 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scan", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 18376, + "afterCompletionDelta": 25472 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 3448, + "afterCompletionDelta": 3144 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scanReplay", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 9400, + "afterCompletionDelta": 8216 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scanReplay", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 3696, + "afterCompletionDelta": 3392 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 2, + "heapDelta": 3320, + "afterCompletionDelta": 3240 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 3176, + "afterCompletionDelta": 4648 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 3128, + "afterCompletionDelta": 3144 + }, + { + "variant": "fixed-buffer", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 17, + "heapDelta": 3448, + "afterCompletionDelta": 3152 + }, + { + "variant": "fixed-buffer", + "kind": "mouse", + "method": "scan", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 15112, + "afterCompletionDelta": 14376 + }, + { + "variant": "fixed-buffer", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 1312, + "afterCompletionDelta": 1008 + }, + { + "variant": "fixed-buffer", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 2, + "heapDelta": 1232, + "afterCompletionDelta": 1200 + }, + { + "variant": "fixed-buffer", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 992, + "afterCompletionDelta": 1008 + }, + { + "variant": "fixed-buffer", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 992, + "afterCompletionDelta": 1008 + }, + { + "variant": "fixed-buffer", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 17, + "heapDelta": 1344, + "afterCompletionDelta": 1048 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scan", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 9480, + "afterCompletionDelta": 8216 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 3448, + "afterCompletionDelta": 4048 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scanReplay", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 13216, + "afterCompletionDelta": 11952 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scanReplay", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 3448, + "afterCompletionDelta": 3144 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 2, + "heapDelta": 3320, + "afterCompletionDelta": 3240 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 3176, + "afterCompletionDelta": 3192 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 3912, + "afterCompletionDelta": 3928 + }, + { + "variant": "fixed-fallback", + "kind": "kitty", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 17, + "heapDelta": 3448, + "afterCompletionDelta": 3152 + }, + { + "variant": "fixed-fallback", + "kind": "mouse", + "method": "scan", + "inputChars": 65536, + "count": 32, + "expectedTailLength": 18, + "heapDelta": 9336, + "afterCompletionDelta": 8072 + }, + { + "variant": "fixed-fallback", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 18, + "heapDelta": 1312, + "afterCompletionDelta": 1008 + }, + { + "variant": "fixed-fallback", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 2, + "heapDelta": 1232, + "afterCompletionDelta": 8184 + }, + { + "variant": "fixed-fallback", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 992, + "afterCompletionDelta": 1008 + }, + { + "variant": "fixed-fallback", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 0, + "heapDelta": 2280, + "afterCompletionDelta": 2296 + }, + { + "variant": "fixed-fallback", + "kind": "mouse", + "method": "scan", + "inputChars": 4194304, + "count": 8, + "expectedTailLength": 17, + "heapDelta": 1312, + "afterCompletionDelta": 1016 + } + ] +} diff --git a/docs/audits/terminal-mode-tail-retention/reproduce.cjs b/docs/audits/terminal-mode-tail-retention/reproduce.cjs new file mode 100644 index 00000000000..61801919b7d --- /dev/null +++ b/docs/audits/terminal-mode-tail-retention/reproduce.cjs @@ -0,0 +1,205 @@ +const assert = require('node:assert/strict') +const fs = require('node:fs') +const path = require('node:path') +const { loadSource, sha, read } = require('./load-source.cjs') + +assert.equal(process.env.ORCA_BACKGROUND_LAUNCH, '1') +assert.equal(typeof global.gc, 'function', 'Run with --expose-gc') +const pending = '\x1b[?1049;2004;1000;' +const sizes = [ + [64 * 1024, 32], + [4 * 1024 * 1024, 8] +] + +async function heap() { + // Isolate owner storage from V8's process-wide last successful regexp input. + ;/reset/.test('reset') + for (let round = 0; round < 4; round++) { + await new Promise((resolve) => setImmediate(resolve)) + global.gc() + } + return process.memoryUsage().heapUsed +} + +function createOwner(Owner, method, chars, index, suffix) { + const prefix = `${index}:` + const data = `${prefix}${'x'.repeat(chars - prefix.length - suffix.length)}${suffix}` + const owner = new Owner() + owner[method](data) + return owner +} + +async function measure(Owner, variant, kind, method, inputChars, count, suffix = pending) { + const beforeHeap = await heap() + const owners = Array.from({ length: count }, (_, index) => + createOwner(Owner, method, inputChars, index, suffix) + ) + const heapDelta = (await heap()) - beforeHeap + const expectedTailLength = suffix.length > 4096 || suffix.endsWith('h') ? 0 : suffix.length + assert.ok(owners.every((owner) => owner.scanTail.length === expectedTailLength)) + const retainsParent = variant === 'baseline' && expectedTailLength >= 13 + assert.ok( + retainsParent ? heapDelta > inputChars * count * 0.75 : heapDelta < 768 * 1024, + JSON.stringify({ variant, kind, method, inputChars, count, expectedTailLength, heapDelta }) + ) + + for (const owner of owners) { + owner[method]('1006h') + if (kind === 'kitty') { + if (suffix === pending) { + assert.equal(owner.isAlternateScreen, true) + } + owner[method]('\x1b[>3u') + assert.equal(owner.flags, 3) + owner.scan('\x1b[= 13) { + assert.equal(owner.mouseTrackingMode, 'vt200') + assert.equal(owner.sgrMouseMode, true) + } + assert.equal(owner.scanTail, '') + } + const afterCompletionDelta = (await heap()) - beforeHeap + assert.ok( + afterCompletionDelta < 768 * 1024, + JSON.stringify({ variant, kind, method, afterCompletionDelta }) + ) + for (const owner of owners) { + if (kind === 'kitty') { + owner.resetForSnapshot() + assert.equal(owner.snapshotFlags, undefined) + } else { + owner.scan('\x1bc') + assert.equal(owner.mouseTrackingMode, 'none') + assert.equal(owner.sgrMouseMode, false) + } + } + return { + variant, + kind, + method, + inputChars, + count, + expectedTailLength, + heapDelta, + afterCompletionDelta + } +} + +function configureCopier(api, fallback) { + api.resetOwnRetainedStringCopier() + const originalBuffer = globalThis.Buffer + try { + if (fallback) { + globalThis.Buffer = undefined + } + assert.equal(api.ownRetainedString(pending), pending) + } finally { + globalThis.Buffer = originalBuffer + } +} + +function behavior(Tracker, Mirror) { + const replay = new Tracker() + for (let index = 0; index < 70; index++) { + replay.scanReplay('\x1b[>3u') + } + assert.equal(replay.mainStack.length, 0) + assert.equal(replay.flags, 3) + replay.scan('\x1b[3u') + } + assert.equal(live.mainStack.length, 16) + live.scan('\x1b[?1049h\x1b[>5u') + assert.equal(live.altStack.length, 1) + live.scan('\x1b[?1049l') + assert.equal(live.flags, 3) + live.scan(`\x1bc${pending}`) + assert.equal(live.flags, 0) + assert.equal(live.scanTail, pending) + live.scan('1006h') + assert.equal(live.isAlternateScreen, true) + live.reset() + assert.equal(live.scanTail, '') + assert.equal(live.snapshotFlags, 0) + + const mouse = new Mirror() + mouse.scan('\x1b[?1003;1016h') + assert.equal(mouse.mouseTrackingMode, 'any') + assert.equal(mouse.sgrMousePixelsMode, true) + mouse.scan('\x9b?1002;1006h') + assert.equal(mouse.mouseTrackingMode, 'drag') + assert.equal(mouse.sgrMouseMode, true) + assert.equal(mouse.sgrMousePixelsMode, false) + mouse.scan(`\x1bc${pending}`) + assert.equal(mouse.mouseTrackingMode, 'none') + assert.equal(mouse.scanTail, pending) + mouse.scan('1006h') + assert.equal(mouse.mouseTrackingMode, 'vt200') + assert.equal(mouse.sgrMouseMode, true) +} + +async function main() { + const reports = [] + const bundles = {} + for (const variant of ['baseline', 'fixed-buffer', 'fixed-fallback']) { + const api = await loadSource(variant !== 'baseline') + const { TerminalKittyKeyboardModeTracker: Tracker, TerminalMouseModeMirror: Mirror } = api + bundles[variant] = { + bundleSha256: api.bundleSha256, + evaluatedSources: api.evaluatedSources, + sourceVersionsSha256: api.sourceVersionsSha256 + } + configureCopier(api, variant === 'fixed-fallback') + behavior(Tracker, Mirror) + for (const [kind, Owner, methods] of [ + ['kitty', Tracker, ['scan', 'scanReplay']], + ['mouse', Mirror, ['scan']] + ]) { + for (const method of methods) { + for (const [chars, count] of sizes) { + reports.push(await measure(Owner, variant, kind, method, chars, count)) + } + } + for (const suffix of [ + '\x1b[', + '\x1b[?1049;2004;1000;1006h', + `\x1b[${'1'.repeat(4095)}`, + pending.replace('\x1b[', '\x9b') + ]) { + reports.push(await measure(Owner, variant, kind, 'scan', 4 * 1024 * 1024, 8, suffix)) + } + } + } + const report = { + node: process.version, + electron: process.versions.electron ?? null, + v8: process.versions.v8, + platform: process.platform, + runnerSha256: sha(read(__filename)), + loaderSha256: sha(read(path.join(__dirname, 'load-source.cjs'))), + pendingTailCodeUnits: pending.length, + clearedRegexStatics: true, + bundles, + reports + } + const output = path.join( + __dirname, + `${process.versions.electron ? 'electron' : 'node'}-results.json` + ) + fs.writeFileSync(output, `${JSON.stringify(report, null, 2)}\n`) + console.log( + JSON.stringify({ output, passed: reports.length, node: report.node, electron: report.electron }) + ) +} +main().catch((error) => { + console.error(error) + process.exitCode = 1 +}) +setTimeout(() => { + console.error('fixture deadline') + process.exit(2) +}, 30000).unref() diff --git a/docs/audits/terminal-mode-tail-retention/source-versions.json b/docs/audits/terminal-mode-tail-retention/source-versions.json new file mode 100644 index 00000000000..fe29f805765 --- /dev/null +++ b/docs/audits/terminal-mode-tail-retention/source-versions.json @@ -0,0 +1,44 @@ +{ + "publicationTopic": "np-oom-scan-retained-text-slices", + "publicationBaseCommit": "8d599520e44654a5c28e9930e3070c00d6499931", + "historicalParserRef": "v1.4.198", + "historicalScope": "Both parser modules and kitty flag parser match this ref; owned-string helpers come from the publication topic. This is not a historical app binary.", + "sources": { + "src/shared/terminal-kitty-keyboard-mode-tracker.ts": { + "baselineSha256": "2ee0bad47d4575046f71c16e0334a8ae8be531f0cfc67133abc287f8ae5aa403", + "fixedSha256": "a8da704564b9ab97aa344f380940021d3fa98f80af817751824d14271acb8a84", + "reverse": [ + { + "from": "import { ownRetainedString } from './own-retained-string'\n", + "to": "", + "count": 1 + }, + { "from": "? ownRetainedString(tail) : ''", "to": "? tail : ''", "count": 1 } + ] + }, + "src/main/daemon/terminal-mouse-mode-mirror.ts": { + "baselineSha256": "5bf8a94ad7ec3fdd151ba7559f49a229b2156818aba88f7b562bef258048acf1", + "fixedSha256": "e8f4888dabee5c4cc50b0f15584b3b1b5e2da4db1ba25695dd5762ef9c4a6fb3", + "reverse": [ + { + "from": "import { ownRetainedString } from '../../shared/own-retained-string'\n", + "to": "", + "count": 1 + }, + { "from": "? ownRetainedString(tail) : ''", "to": "? tail : ''", "count": 2 } + ] + }, + "src/shared/own-retained-string.ts": { + "baselineSha256": "addea88bc11c725192be2ff617b91315a875113844c95de03e5529cf3d7ef2e2", + "fixedSha256": "addea88bc11c725192be2ff617b91315a875113844c95de03e5529cf3d7ef2e2" + }, + "src/shared/owned-utf16-suffix.ts": { + "baselineSha256": "1ca0bb35dfff2d9f3c02caee3d05b0e0c97378df71c878622acf703524d2c475", + "fixedSha256": "1ca0bb35dfff2d9f3c02caee3d05b0e0c97378df71c878622acf703524d2c475" + }, + "src/shared/terminal-kitty-keyboard-flags.ts": { + "baselineSha256": "ee8ca63afdca8356fa12878e74973ff92d807aa8d99750b2415b42f4bccb0225", + "fixedSha256": "ee8ca63afdca8356fa12878e74973ff92d807aa8d99750b2415b42f4bccb0225" + } + } +} diff --git a/src/main/daemon/terminal-mouse-mode-mirror.ts b/src/main/daemon/terminal-mouse-mode-mirror.ts index 8f8b284f7f4..7e2ea703cac 100644 --- a/src/main/daemon/terminal-mouse-mode-mirror.ts +++ b/src/main/daemon/terminal-mouse-mode-mirror.ts @@ -1,3 +1,4 @@ +import { ownRetainedString } from '../../shared/own-retained-string' import type { TerminalModes } from './types' type MouseTrackingMode = NonNullable @@ -105,10 +106,10 @@ export class TerminalMouseModeMirror { return tail } if (tail.startsWith('\x1b[?')) { - return this.isIncompleteParams(tail.slice(3)) ? tail : '' + return this.isIncompleteParams(tail.slice(3)) ? ownRetainedString(tail) : '' } if (tail.startsWith('\x9b?')) { - return this.isIncompleteParams(tail.slice(2)) ? tail : '' + return this.isIncompleteParams(tail.slice(2)) ? ownRetainedString(tail) : '' } return '' } diff --git a/src/main/daemon/terminal-mouse-tail-retention.test.ts b/src/main/daemon/terminal-mouse-tail-retention.test.ts new file mode 100644 index 00000000000..7a266b4b23d --- /dev/null +++ b/src/main/daemon/terminal-mouse-tail-retention.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' + +function heapAfterGc(): number { + if (!('gc' in globalThis) || typeof globalThis.gc !== 'function') { + throw new Error('The test runner must enable --expose-gc') + } + // Isolate mirror ownership from V8's process-wide last successful regexp input. + void /reset/.test('reset') + globalThis.gc() + globalThis.gc() + return process.memoryUsage().heapUsed +} + +describe('mouse mode scan tail retention', () => { + it.each(['\x1b[', '\x9b'])( + 'retains a split %j mode sequence without retaining consumed output', + (introducer) => { + const before = heapAfterGc() + const mirrors = Array.from({ length: 8 }, (_value, index) => { + const mirror = new TerminalMouseModeMirror() + mirror.scan(`${index}:${'x'.repeat(4 * 1024 * 1024)}${introducer}?1049;2004;1000;`) + return mirror + }) + + expect(heapAfterGc() - before).toBeLessThan(2 * 1024 * 1024) + for (const mirror of mirrors) { + expect(mirror.mouseTrackingMode).toBe('none') + mirror.scan('1006h') + expect(mirror.mouseTrackingMode).toBe('vt200') + expect(mirror.sgrMouseMode).toBe(true) + mirror.scan('\x1b[?1016h') + expect(mirror.sgrMouseMode).toBe(false) + expect(mirror.sgrMousePixelsMode).toBe(true) + mirror.scan('\x1bc') + expect(mirror.mouseTrackingMode).toBe('none') + expect(mirror.sgrMousePixelsMode).toBe(false) + } + } + ) +}) diff --git a/src/shared/terminal-kitty-keyboard-mode-tracker.ts b/src/shared/terminal-kitty-keyboard-mode-tracker.ts index cae87067973..72c9d5b91f3 100644 --- a/src/shared/terminal-kitty-keyboard-mode-tracker.ts +++ b/src/shared/terminal-kitty-keyboard-mode-tracker.ts @@ -1,3 +1,4 @@ +import { ownRetainedString } from './own-retained-string' import { parseTerminalKittyKeyboardFlags } from './terminal-kitty-keyboard-flags' // Why: PTY/SSH chunks can split an escape sequence before its final byte. @@ -308,7 +309,7 @@ export class TerminalKittyKeyboardModeTracker { if (body === null) { return '' } - return this.isIncompleteSequenceBody(body) ? tail : '' + return this.isIncompleteSequenceBody(body) ? ownRetainedString(tail) : '' } private isIncompleteSequenceBody(body: string): boolean { diff --git a/src/shared/terminal-kitty-keyboard-tail-retention.test.ts b/src/shared/terminal-kitty-keyboard-tail-retention.test.ts new file mode 100644 index 00000000000..4c1b92acffd --- /dev/null +++ b/src/shared/terminal-kitty-keyboard-tail-retention.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { TerminalKittyKeyboardModeTracker } from './terminal-kitty-keyboard-mode-tracker' + +const INCOMPLETE_MODE = '\x1b[?1049;2004;1000;' + +function heapAfterGc(): number { + if (!('gc' in globalThis) || typeof globalThis.gc !== 'function') { + throw new Error('The test runner must enable --expose-gc') + } + // Isolate tracker ownership from V8's process-wide last successful regexp input. + void /reset/.test('reset') + globalThis.gc() + globalThis.gc() + return process.memoryUsage().heapUsed +} + +describe('kitty keyboard scan tail retention', () => { + it.each(['scan', 'scanReplay'] as const)( + '%s retains a split mode sequence without retaining consumed output', + (method) => { + const before = heapAfterGc() + const trackers = Array.from({ length: 8 }, (_value, index) => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker[method](`${index}:${'x'.repeat(4 * 1024 * 1024)}${INCOMPLETE_MODE}`) + return tracker + }) + + expect(heapAfterGc() - before).toBeLessThan(2 * 1024 * 1024) + for (const tracker of trackers) { + expect(tracker.isAlternateScreen).toBe(false) + tracker[method]('1006h\x1b[>3u') + expect(tracker.isAlternateScreen).toBe(true) + expect(tracker.flags).toBe(3) + tracker.scan('\x1b[