From 1457a4a090a586c82e398c8edaa673f5b1daa199 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Wed, 29 Jul 2026 01:17:11 -0700 Subject: [PATCH] perf(terminal): detach retained PTY slice strings from their source chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `String.prototype.slice` on a long string yields a V8 SlicedString that keeps a pointer to its parent. Extraction boundaries that park a short result in long-lived state — an OSC title in pane/tab state, a scan tail in a per-PTY map, a carry in a per-pane scanner closure — therefore pinned the entire source PTY chunk for as long as that short string stayed reachable. Add `detachString` (src/shared/detached-string.ts), which forces V8 to flatten via `` ` ${value}`.slice(1) ``, and apply it at 22 extraction boundaries across shared, main, and renderer. This is memory hygiene, not a crash fix. Production splits renderer-bound PTY writes at PTY_BATCH_FLUSH_CHUNK_CHARS (16 KiB) and `setRuntimePaneTitle` writes `[paneId]: title`, an overwrite that releases the previous parent, so live retention is O(panes) and measures ~0.01 MiB today. It is bounded by that overwrite, unbounded in principle, and cheap to make robust. Retention guards resolve a real `gc` binding via `v8.setFlagsFromString` instead of requiring `--expose-gc`, so they actually run under the plain `vitest run` CI uses rather than silently skipping. Neutering `detachString` to `return value` fails all 23 guards. --- src/main/daemon/osc7-uri-extraction.test.ts | 26 +++++++ src/main/daemon/osc7-uri-extraction.ts | 8 ++- .../daemon/terminal-mouse-mode-mirror.test.ts | 63 ++++++++++++++++ src/main/daemon/terminal-mouse-mode-mirror.ts | 7 +- src/main/runtime/orca-runtime.ts | 5 +- .../setup-completion-signal.test.ts | 34 +++++++++ .../orchestration/setup-completion-signal.ts | 5 +- src/main/stats/agent-detector.test.ts | 29 ++++++++ src/main/stats/agent-detector.ts | 7 +- .../terminal-pane/pty-connection.ts | 5 +- .../terminal-pane/pty-title-retention.test.ts | 69 ++++++++++++++++++ .../terminal-bracketed-paste.test.ts | 34 +++++++++ .../terminal-pane/terminal-bracketed-paste.ts | 8 ++- .../terminal-complex-script.test.ts | 45 ++++++++++++ .../pane-manager/terminal-complex-script.ts | 7 +- .../agent-status-field-normalization.ts | 11 ++- src/shared/agent-status-osc.test.ts | 26 +++++++ src/shared/agent-status-osc.ts | 5 +- src/shared/agent-status-types.test.ts | 52 ++++++++++++++ src/shared/codex-subagent-transcript.test.ts | 34 +++++++++ src/shared/codex-subagent-transcript.ts | 6 +- src/shared/command-code-output-status.test.ts | 25 +++++++ src/shared/command-code-output-status.ts | 9 ++- src/shared/detached-string.test.ts | 59 +++++++++++++++ src/shared/detached-string.ts | 18 +++++ src/shared/draft-paste-ready-scanner.test.ts | 30 ++++++++ src/shared/draft-paste-ready-scanner.ts | 11 ++- src/shared/forced-gc-for-retention-tests.ts | 37 ++++++++++ .../osc-title-extraction.retention.test.ts | 59 +++++++++++++++ src/shared/osc-title-extraction.ts | 11 ++- src/shared/osc-title-scan-tail.test.ts | 29 ++++++++ src/shared/osc-title-scan-tail.ts | 10 ++- .../terminal-color-scheme-protocol.test.ts | 24 +++++++ src/shared/terminal-color-scheme-protocol.ts | 7 +- .../terminal-github-pr-link-detector.test.ts | 33 +++++++++ .../terminal-github-pr-link-detector.ts | 9 ++- ...rminal-kitty-keyboard-mode-tracker.test.ts | 56 +++++++++++++++ .../terminal-kitty-keyboard-mode-tracker.ts | 8 ++- .../terminal-osc133-command-finished.test.ts | 63 ++++++++++++++++ .../terminal-osc133-command-finished.ts | 14 ++-- .../terminal-partial-escape-tail.test.ts | 26 +++++++ src/shared/terminal-partial-escape-tail.ts | 8 ++- .../terminal-reply-query-extraction.test.ts | 71 +++++++++++++++++++ src/shared/terminal-reply-query-extraction.ts | 18 ++--- src/shared/terminal-reply-query-scan.test.ts | 51 +++++++++++++ src/shared/terminal-reply-query-scan.ts | 6 +- 46 files changed, 1135 insertions(+), 43 deletions(-) create mode 100644 src/main/daemon/terminal-mouse-mode-mirror.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-title-retention.test.ts create mode 100644 src/shared/detached-string.test.ts create mode 100644 src/shared/detached-string.ts create mode 100644 src/shared/forced-gc-for-retention-tests.ts create mode 100644 src/shared/osc-title-extraction.retention.test.ts create mode 100644 src/shared/terminal-osc133-command-finished.test.ts create mode 100644 src/shared/terminal-reply-query-extraction.test.ts diff --git a/src/main/daemon/osc7-uri-extraction.test.ts b/src/main/daemon/osc7-uri-extraction.test.ts index 872ac44b811..0f64c1c806d 100644 --- a/src/main/daemon/osc7-uri-extraction.test.ts +++ b/src/main/daemon/osc7-uri-extraction.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { createForceGc, resolveForcedGc } from '../../shared/forced-gc-for-retention-tests' import { extractLastOsc7Uri, extractOscScanTail } from './osc7-uri-extraction' afterEach(() => { @@ -31,4 +32,29 @@ describe('OSC-7 URI extraction', () => { expect(extractLastOsc7Uri(data)).toBe('file:///repo') expect(execSpy).not.toHaveBeenCalled() }) + + // The tail is parked per-PTY (orca-runtime osc7ScanTailByPtyId) and per + // daemon scanner until the next chunk, so an attached slice pins one whole + // source chunk per tracked PTY. + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the source chunk behind a carried OSC scan tail', () => { + const chunkChars = 16 * 1024 + const ptys = 512 + const forceGc = createForceGc(forcedGc!) + forceGc() + const before = process.memoryUsage().heapUsed + const carried = new Map() + for (let index = 0; index < ptys; index += 1) { + // No terminator yet, so the tail is carried to the next chunk. + const chunk = `${'x'.repeat(chunkChars)}\x1b]7;file://host/Users/dev/orca/wt-${index}` + carried.set(`pty-${index}`, extractOscScanTail(chunk, 4096)) + } + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + expect(carried.get('pty-0')).toBe('\x1b]7;file://host/Users/dev/orca/wt-0') + // 8 MiB of source chunks stay alive if the tails are still attached. + expect(retainedMiB).toBeLessThan(2) + }) }) diff --git a/src/main/daemon/osc7-uri-extraction.ts b/src/main/daemon/osc7-uri-extraction.ts index 97d335b6a6b..03361aa6a40 100644 --- a/src/main/daemon/osc7-uri-extraction.ts +++ b/src/main/daemon/osc7-uri-extraction.ts @@ -1,3 +1,5 @@ +import { detachString } from '../../shared/detached-string' + const OSC7_PREFIX = '\x1b]7;' const ESC_CODE_UNIT = 0x1b const BEL_CODE_UNIT = 0x07 @@ -65,6 +67,10 @@ export function extractLastOsc7Uri(data: string): string | null { return lastUri } +// Callers park this tail until the next chunk arrives — osc7ScanTailByPtyId in +// orca-runtime.ts, TerminalOscCwdTitleScanner.scanTail in the daemon — so the +// returned slice is detached; otherwise each carried tail pins a whole source +// chunk for as long as its PTY or scanner stays alive. export function extractOscScanTail(input: string, limit: number): string { const lastOsc = input.lastIndexOf('\x1b]') const lastEscape = input.endsWith('\x1b') ? input.length - 1 : -1 @@ -77,5 +83,5 @@ export function extractOscScanTail(input: string, limit: number): string { if (suffix.includes('\x07') || suffix.includes('\x1b\\')) { return '' } - return suffix.slice(-limit) + return detachString(suffix.slice(-limit)) } diff --git a/src/main/daemon/terminal-mouse-mode-mirror.test.ts b/src/main/daemon/terminal-mouse-mode-mirror.test.ts new file mode 100644 index 00000000000..10f2e48a6d6 --- /dev/null +++ b/src/main/daemon/terminal-mouse-mode-mirror.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it } from 'vitest' +import { createForceGc, resolveForcedGc } from '../../shared/forced-gc-for-retention-tests' +import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' + +describe('TerminalMouseModeMirror', () => { + it('mirrors DECSET mouse tracking and SGR encoding modes', () => { + const mirror = new TerminalMouseModeMirror() + expect(mirror.mouseTrackingMode).toBe('none') + + mirror.scan('\x1b[?1002h\x1b[?1006h') + expect(mirror.mouseTrackingMode).toBe('drag') + expect(mirror.sgrMouseMode).toBe(true) + + mirror.scan('\x1b[?1002l') + expect(mirror.mouseTrackingMode).toBe('none') + }) + + it('assembles a DECSET split across chunks', () => { + const mirror = new TerminalMouseModeMirror() + mirror.scan('output\x1b[?100') + expect(mirror.mouseTrackingMode).toBe('none') + + mirror.scan('3h') + expect(mirror.mouseTrackingMode).toBe('any') + }) + + it('clears mirrored modes on RIS', () => { + const mirror = new TerminalMouseModeMirror() + mirror.scan('\x1b[?1003h\x1b[?1016h') + expect(mirror.sgrMousePixelsMode).toBe(true) + + mirror.scan('\x1bc') + expect(mirror.mouseTrackingMode).toBe('none') + expect(mirror.sgrMousePixelsMode).toBe(false) + }) + + // One mirror per daemon session holds the scan tail until the next chunk, so + // an attached slice pins a whole PTY chunk per live session. + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the source chunk behind a carried private-mode tail', () => { + const chunkChars = 16 * 1024 + const sessions = 512 + const forceGc = createForceGc(forcedGc!) + forceGc() + const before = process.memoryUsage().heapUsed + const mirrors = Array.from({ length: sessions }, () => { + const mirror = new TerminalMouseModeMirror() + // Ends mid-DECSET, so the params tail is carried into the next chunk. + mirror.scan(`${'x'.repeat(chunkChars)}\x1b[?1002;1006;100`) + return mirror + }) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + // The carry must still complete the split DECSET across the boundary. + mirrors[0]!.scan('3h') + expect(mirrors[0]!.mouseTrackingMode).toBe('any') + expect(mirrors[0]!.sgrMouseMode).toBe(true) + // 8 MiB of source chunks stay alive if the tails are still attached. + expect(retainedMiB).toBeLessThan(2) + }) +}) diff --git a/src/main/daemon/terminal-mouse-mode-mirror.ts b/src/main/daemon/terminal-mouse-mode-mirror.ts index 8f8b284f7f4..901db52da6b 100644 --- a/src/main/daemon/terminal-mouse-mode-mirror.ts +++ b/src/main/daemon/terminal-mouse-mode-mirror.ts @@ -1,3 +1,4 @@ +import { detachString } from '../../shared/detached-string' import type { TerminalModes } from './types' type MouseTrackingMode = NonNullable @@ -92,6 +93,8 @@ export class TerminalMouseModeMirror { } } + // Detached: one mirror per daemon session holds this tail until the next + // chunk, so an attached slice pins a whole PTY chunk per live session. private extractScanTail(input: string): string { const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) if (start === -1) { @@ -105,10 +108,10 @@ export class TerminalMouseModeMirror { return tail } if (tail.startsWith('\x1b[?')) { - return this.isIncompleteParams(tail.slice(3)) ? tail : '' + return this.isIncompleteParams(tail.slice(3)) ? detachString(tail) : '' } if (tail.startsWith('\x9b?')) { - return this.isIncompleteParams(tail.slice(2)) ? tail : '' + return this.isIncompleteParams(tail.slice(2)) ? detachString(tail) : '' } return '' } diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 9cee18d16cf..a475d3979e8 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -9,6 +9,7 @@ import { isShellProcess, normalizeTerminalTitle } from '../../shared/agent-detection' +import { detachString } from '../../shared/detached-string' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing' import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction' @@ -8926,7 +8927,9 @@ export class OrcaRuntimeService { } const appendedLower = appendedText.toLowerCase() const keywordHit = WAIT_BLOCKED_KEYWORD_PATTERN.test(`${state.keywordCarry}${appendedLower}`) - state.keywordCarry = appendedLower.slice(-WAIT_BLOCKED_KEYWORD_CARRY_CHARS) + // Detached: keywordCarry is parked per PTY until the next chunk, so an + // attached slice would pin the whole lowercased chunk per tracked PTY. + state.keywordCarry = detachString(appendedLower.slice(-WAIT_BLOCKED_KEYWORD_CARRY_CHARS)) // Why the cap keeps the tail: the accumulated text only anchors boundary- // spanning prompt detection; anything past the tail cap has scrolled out // of the retained tail the check reads anyway. diff --git a/src/main/runtime/orchestration/setup-completion-signal.test.ts b/src/main/runtime/orchestration/setup-completion-signal.test.ts index 0f2b3976f1e..b918d5638c0 100644 --- a/src/main/runtime/orchestration/setup-completion-signal.test.ts +++ b/src/main/runtime/orchestration/setup-completion-signal.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { createForceGc, resolveForcedGc } from '../../../shared/forced-gc-for-retention-tests' import { buildObservedSetupCommand, createSetupCompletionScanner } from './setup-completion-signal' describe('orchestration setup completion signal', () => { @@ -57,3 +58,36 @@ describe('orchestration setup completion signal', () => { expect(onComplete).toHaveBeenCalledWith(17) }) }) + +// The carry lives in the scanner closure — one per running setup — until the +// next chunk, so an attached slice pins a whole setup-output chunk each. +describe('setup completion scanner retention', () => { + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the source chunk behind the carry', () => { + const chunkChars = 16 * 1024 + const setups = 512 + const forceGc = createForceGc(forcedGc!) + forceGc() + const before = process.memoryUsage().heapUsed + const scanners = Array.from({ length: setups }, (_unused, index) => { + const scanner = createSetupCompletionScanner(`token-${index}`, () => undefined) + // A distinct chunk per setup. Sharing one would leave a single parent + // alive and pass whether or not the carry is detached. + scanner.scan(`${'x'.repeat(chunkChars)}setup-${index}`) + return scanner + }) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + // The carry must still complete a marker split across the chunk boundary. + const exitCodes: number[] = [] + const split = createSetupCompletionScanner('tok', (code) => exitCodes.push(code)) + split.scan(`${'x'.repeat(chunkChars)}__ORCA_SETUP_COMPLETE__:tok`) + split.scan(':7\n') + expect(exitCodes).toEqual([7]) + expect(scanners).toHaveLength(setups) + // 8 MiB of source chunks stay alive if the carries are attached. + expect(retainedMiB).toBeLessThan(2) + }) +}) diff --git a/src/main/runtime/orchestration/setup-completion-signal.ts b/src/main/runtime/orchestration/setup-completion-signal.ts index 17819c12a0c..50f23c721fb 100644 --- a/src/main/runtime/orchestration/setup-completion-signal.ts +++ b/src/main/runtime/orchestration/setup-completion-signal.ts @@ -1,3 +1,4 @@ +import { detachString } from '../../../shared/detached-string' import { resolveSetupRunnerCommand, type SetupRunnerCommandPlatform @@ -66,7 +67,9 @@ export function createSetupCompletionScanner( return } } - carry = combined.slice(-SETUP_COMPLETION_CARRY_LENGTH) + // Detached: the carry is held per running setup until its next chunk, so + // an attached slice would pin a whole setup-output chunk each. + carry = detachString(combined.slice(-SETUP_COMPLETION_CARRY_LENGTH)) } } } diff --git a/src/main/stats/agent-detector.test.ts b/src/main/stats/agent-detector.test.ts index 4349ea6929f..22a0daceec1 100644 --- a/src/main/stats/agent-detector.test.ts +++ b/src/main/stats/agent-detector.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { createForceGc, resolveForcedGc } from '../../shared/forced-gc-for-retention-tests' import { AgentDetector } from './agent-detector' afterEach(() => { @@ -241,4 +242,32 @@ describe('AgentDetector', () => { expect(stats.onAgentStart).toHaveBeenCalledWith('pty-1', 100) expect(stats.onAgentStop).toHaveBeenCalledWith('pty-1', 120) }) + + // meaningfulContentScanTailByPtyId parks the tail until the next chunk, so + // an attached slice pins one whole source chunk per tracked PTY. + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin source chunks behind carried per-PTY scan tails', () => { + const chunkChars = 16 * 1024 + const ptys = 512 + const forceGc = createForceGc(forcedGc!) + const stats = { onAgentStart: vi.fn(), onAgentStop: vi.fn() } + const detector = new AgentDetector(stats as never) + for (let index = 0; index < ptys; index += 1) { + detector.onData(`pty-${index}`, oscTitle('⠂ Writing patch'), 100) + } + + forceGc() + const before = process.memoryUsage().heapUsed + for (let index = 0; index < ptys; index += 1) { + // Ends mid-CSI, so the tail is carried into the next chunk. + detector.onData(`pty-${index}`, `${'y'.repeat(chunkChars)}\x1b[38;2;12;34;5`, 200) + } + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + expect(detector.trackedPtyCount).toBe(ptys) + // 8 MiB of source chunks stay alive if the tails are still attached. + expect(retainedMiB).toBeLessThan(2) + }) }) diff --git a/src/main/stats/agent-detector.ts b/src/main/stats/agent-detector.ts index 81330ce280d..5ae1532ee80 100644 --- a/src/main/stats/agent-detector.ts +++ b/src/main/stats/agent-detector.ts @@ -1,5 +1,6 @@ import { extractLastOscTitle, detectAgentStatusFromTitle } from '../../shared/agent-detection' import type { AgentStatus } from '../../shared/agent-detection' +import { detachString } from '../../shared/detached-string' import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail' import type { StatsCollector } from './collector' @@ -285,11 +286,13 @@ function isStTerminatedStringControlIntroducer(introducer: string): boolean { return introducer === 'P' || introducer === 'X' || introducer === '^' || introducer === '_' } +// Detached because meaningfulContentScanTailByPtyId parks this until the next +// chunk: an attached slice pins a whole source chunk per tracked PTY. function trimMeaningfulContentScanTail(value: string): string { if (value.length <= MEANINGFUL_CONTENT_SCAN_TAIL_LIMIT) { - return value + return detachString(value) } const introducer = value.slice(0, Math.min(2, value.length)) const suffixBudget = Math.max(0, MEANINGFUL_CONTENT_SCAN_TAIL_LIMIT - introducer.length) - return `${introducer}${value.slice(-suffixBudget)}` + return detachString(`${introducer}${value.slice(-suffixBudget)}`) } diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 68dc330f6dd..1cbf0d7eb48 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -11,6 +11,7 @@ import { resolveLiveAgentStatusConnectionRouting } from '@/lib/agent-status-conn import { scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' import { useAppStore } from '@/store' import { getWorktreeMapFromState } from '@/store/selectors' +import { detachString } from '../../../../shared/detached-string' import { parseWorkspaceKey } from '../../../../shared/workspace-scope' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { isEphemeralSetupTerminalWorktreeId } from '../../../../shared/ephemeral-setup-terminal-worktree-id' @@ -5223,7 +5224,9 @@ export function connectPanePty( return '' } } - return tail.slice(-TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS) + // Detached: this tail is parked per pane until the next chunk, so an + // attached slice would pin a whole PTY chunk for the pane's lifetime. + return detachString(tail.slice(-TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS)) } function foregroundRendererRiskOutputPrefersRenderRefresh(data: string): boolean { diff --git a/src/renderer/src/components/terminal-pane/pty-title-retention.test.ts b/src/renderer/src/components/terminal-pane/pty-title-retention.test.ts new file mode 100644 index 00000000000..0eb1d5328e4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-title-retention.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from 'vitest' +import { createForceGc, resolveForcedGc } from '../../../../shared/forced-gc-for-retention-tests' +import { createPtyOutputProcessor } from './pty-transport' + +// What this proves — and what it does not. +// +// Invariant under test: the title a pane hands the store (setRuntimePaneTitle / +// updateTabTitle) is a standalone string, not a V8 SlicedString over the PTY +// frame it was parsed from. Frames are sized at the production renderer-write +// bound (PTY_BATCH_FLUSH_CHUNK_CHARS = 16 KiB, src/main/ipc/pty.ts), not the +// multi-MiB frames an earlier draft of this test assumed. +// +// Panes are held live simultaneously with no store overwrite, which is the +// unbounded-in-principle shape. Production writes `[paneId]: title`, an +// overwrite that releases the previous parent, so the real live set is +// O(panes). This is a regression guard on the retention property, NOT a +// reproduction of a shipped out-of-memory crash. +// +const MIB = 1024 * 1024 +const CHUNK_CHARS = 16 * 1024 +const PANES = 2048 +// '█' is 2 bytes/char in V8, so a pinned frame costs twice its char count. +const PINNED_FRAME_MIB = (CHUNK_CHARS * PANES * 2) / MIB + +// resolveForcedGc works without --expose-gc, so this runs in CI's plain +// `vitest run`. Skips only if a hardened host refuses to expose gc at all. +const forcedGc = resolveForcedGc() +const describeWithGc = forcedGc ? describe : describe.skip +const forceGc = forcedGc ? createForceGc(forcedGc) : (): void => undefined + +function makeAgentFrame(index: number): string { + return `${'█'.repeat(CHUNK_CHARS)}\x1b]0;✳ Working… (esc to interrupt) pane-${index}\x07` +} + +describeWithGc('renderer PTY title retention', () => { + it('does not pin each pane PTY frame behind the title handed to the store', async () => { + forceGc() + const before = process.memoryUsage().heapUsed + // Stands in for store.runtimePaneTitlesByTabId / tab titles: one live title per pane. + const storedTitles: string[] = [] + + for (let pane = 0; pane < PANES; pane += 1) { + const processor = createPtyOutputProcessor({ + onTitleChange: (normalized) => { + storedTitles.push(normalized) + } + }) + processor.processData(makeAgentFrame(pane), { onData: () => undefined }) + // Drain the deferred side-effect queue the way the real drain timer does. + await new Promise((resolve) => setTimeout(resolve, 0)) + } + + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / MIB + const titleChars = storedTitles.reduce((total, title) => total + title.length, 0) + + // eslint-disable-next-line no-console + console.log( + `${storedTitles.length} stored pane titles (${titleChars} chars) -> heap delta ${retainedMiB.toFixed(2)} MiB; ` + + `source frames total ${PINNED_FRAME_MIB} MiB if every title pinned its parent` + ) + + expect(storedTitles).toHaveLength(PANES) + expect(storedTitles[0]).toBe('✳ Working… (esc to interrupt) pane-0') + // Titles hold well under 1 MiB of text. A delta near the frame total means + // the slices still point at their parents. + expect(retainedMiB).toBeLessThan(PINNED_FRAME_MIB / 8) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts index 0a430101daf..191865de0c3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.test.ts @@ -5,6 +5,10 @@ import { pasteTerminalText, sanitizeTerminalPasteText } from './terminal-bracketed-paste' +import { + createForceGc, + resolveForcedGc +} from '../../../../shared/forced-gc-for-retention-tests' function createTerminal(bracketedPasteMode = true) { const terminal = { @@ -252,4 +256,34 @@ describe('terminal bracketed paste policy', () => { expect(terminal.options.ignoreBracketedPasteMode).toBe(false) expect(splitCallCount).toBe(0) }) + + // The tail sits in a per-terminal WeakMap until the pane sees a 2004 toggle, + // so an attached slice pins a whole PTY chunk per interrupted pane. + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the source chunk behind the interrupted-pane mode tail', () => { + const forceGc = createForceGc(forcedGc!) + const panes = 512 + const chunkChars = 16 * 1024 + + // Plain stubs, not createTerminal: a vi.fn() per pane costs megabytes of + // mock bookkeeping and would swamp the retention signal being measured. + const terminals = Array.from({ length: panes }, () => ({ + modes: { bracketedPasteMode: true } + })) + + forceGc() + const before = process.memoryUsage().heapUsed + for (const [index, terminal] of terminals.entries()) { + markTerminalBracketedPasteInterrupted(terminal) + // No 2004 sequence, so the tail is carried rather than cleared. + observeTerminalBracketedPasteModeOutput(terminal, `${'x'.repeat(chunkChars)}pane-${index}`) + } + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + expect(terminals).toHaveLength(panes) + // ~8 MiB of source chunks stay alive if the tails are still attached. + expect(retainedMiB).toBeLessThan(2) + }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts index af276a19130..12bb326011f 100644 --- a/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts +++ b/src/renderer/src/components/terminal-pane/terminal-bracketed-paste.ts @@ -1,4 +1,5 @@ import type { Terminal } from '@xterm/xterm' +import { detachString } from '../../../../shared/detached-string' type BracketedPasteTerminal = { modes: { @@ -98,7 +99,12 @@ export function observeTerminalBracketedPasteModeOutput( return } const combined = (bracketedPasteModeOutputTail.get(terminal) ?? '') + data - bracketedPasteModeOutputTail.set(terminal, combined.slice(-BRACKETED_PASTE_MODE_TAIL_MAX)) + // Detached: this tail is parked in a per-terminal WeakMap until the pane sees + // a 2004 toggle, so an attached slice pins a whole PTY chunk per interrupted pane. + bracketedPasteModeOutputTail.set( + terminal, + detachString(combined.slice(-BRACKETED_PASTE_MODE_TAIL_MAX)) + ) if (hasBracketedPasteModeSequence(combined)) { interruptedBracketedPasteTerminals.delete(terminal) bracketedPasteModeOutputTail.delete(terminal) diff --git a/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts b/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts index 44c1a015557..36f9a81ca23 100644 --- a/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { createForceGc, resolveForcedGc } from '../../../../shared/forced-gc-for-retention-tests' import { nativeWindowsRewriteNeedsFollowupRenderRefresh, terminalOutputContainsEastAsianRendererRisk, @@ -404,3 +405,47 @@ describe('nativeWindowsRewriteNeedsFollowupRenderRefresh', () => { ).toBe(false) }) }) + +// The rewrite-CSI scan tail is parked in per-pane closure state +// (foreground/hiddenRewriteCsiScanTail in pty-connection.ts) until the next +// chunk, so an attached slice pins a whole PTY chunk per open pane. +// +// Why the split CSI is long: V8 only builds a SlicedString past +// SlicedString::kMinLength (13 chars); a shorter tail is already copied flat +// and would make this assertion pass with or without the detach. +describe('rewrite CSI scan tail retention', () => { + const splitCsi = '\x1b[1;2;3;4;5;6;7;8;9' + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the source chunk behind a carried rewrite CSI tail', () => { + const chunkChars = 16 * 1024 + const panes = 512 + const forceGc = createForceGc(forcedGc!) + expect(splitCsi.length).toBeGreaterThanOrEqual(13) + forceGc() + const before = process.memoryUsage().heapUsed + const tails = Array.from( + { length: panes }, + (_unused, index) => + // A distinct chunk per pane, each ending mid-CSI. Sharing one chunk + // would leave a single parent alive and pass either way. + terminalRewriteOutputRenderRefreshDecision( + `${'x'.repeat(chunkChars)}pane-${index}${splitCsi}`, + { previousChunkEndsWithCarriageReturn: false, previousRewriteCsiScanTail: '' } + ).nextRewriteCsiScanTail + ) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + // The tail must still complete the erase sequence across the boundary. + expect(tails[0]).toBe(splitCsi) + expect( + terminalRewriteOutputRenderRefreshDecision('K', { + previousChunkEndsWithCarriageReturn: false, + previousRewriteCsiScanTail: tails[0]! + }).prefersRenderRefresh + ).toBe(true) + // 8 MiB of source chunks stay alive if the carried tails are attached. + expect(retainedMiB).toBeLessThan(2) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts index a5556c7a6dc..b5dcc4fbbf5 100644 --- a/src/renderer/src/lib/pane-manager/terminal-complex-script.ts +++ b/src/renderer/src/lib/pane-manager/terminal-complex-script.ts @@ -1,6 +1,8 @@ // Why: some foreground ANSI redraws paint background fills before glyphs settle. // Detect those chunks so the terminal can force a narrow viewport refresh // without switching renderers based on the text content. +import { detachString } from '../../../../shared/detached-string' + const EMOJI_PRESENTATION_PATTERN = /\p{Emoji_Presentation}/u const ESCAPE_CHARACTER = String.fromCharCode(0x1b) const REWRITE_CSI_SCAN_TAIL_MAX_CHARS = 64 @@ -139,6 +141,9 @@ function containsRewriteEraseSequence(data: string): boolean { return false } +// Detached: callers park this tail in per-pane closure state (foreground/hidden +// RewriteCsiScanTail in pty-connection.ts) until the next chunk, so an attached +// slice pins a whole PTY chunk per open pane. function trailingIncompleteRewriteCsiTail(data: string): string { const escapeIndex = data.lastIndexOf(ESCAPE_CHARACTER) if (escapeIndex === -1) { @@ -164,7 +169,7 @@ function trailingIncompleteRewriteCsiTail(data: string): string { } return '' } - return tail + return detachString(tail) } export function terminalRewriteOutputPrefersRenderRefresh(data: string): boolean { diff --git a/src/shared/agent-status-field-normalization.ts b/src/shared/agent-status-field-normalization.ts index a56d4c412b4..cc72ef1336d 100644 --- a/src/shared/agent-status-field-normalization.ts +++ b/src/shared/agent-status-field-normalization.ts @@ -4,6 +4,7 @@ // truncate without splitting surrogate pairs. Extracted from // agent-status-types.ts, which owns the payload shapes and per-field caps. +import { detachString } from './detached-string' import { compactDispatchPromptForStatus, isOrcaDispatchStatusPrompt @@ -189,8 +190,16 @@ export function normalizeInteractivePromptField( if (typeof value !== 'string' || value.length === 0) { return undefined } + // Why detach: this field is cached in the store, so an over-cap value's + // truncated slice would otherwise pin the whole original string. + // Why only when truncation happened: an under-cap value is returned by + // identity, is already flat (JSON.parse / JSON.stringify output), and pins + // nothing — detaching it would copy the payload on every hook event. const truncated = truncatePreservingSurrogates(value, maxLength) - return truncated.length > 0 ? truncated : undefined + if (truncated.length === 0) { + return undefined + } + return truncated === value ? truncated : detachString(truncated) } export function normalizeOptionalField(value: unknown, maxLength: number): string | undefined { diff --git a/src/shared/agent-status-osc.test.ts b/src/shared/agent-status-osc.test.ts index ac14c495786..67f0cb1a85e 100644 --- a/src/shared/agent-status-osc.test.ts +++ b/src/shared/agent-status-osc.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { createAgentStatusOscProcessor } from './agent-status-osc' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' describe('createAgentStatusOscProcessor', () => { it('strips OSC 9999 payloads from terminal data and returns parsed statuses', () => { @@ -33,4 +34,29 @@ describe('createAgentStatusOscProcessor', () => { } ]) }) + + // The unterminated-payload carry lives in the processor closure until the + // next chunk, and there is one processor per pane, so an attached slice + // would pin a whole PTY chunk per pane. + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the source chunk behind a carried OSC 9999 payload', () => { + const chunkChars = 16 * 1024 + const panes = 512 + const forceGc = createForceGc(forcedGc!) + forceGc() + const before = process.memoryUsage().heapUsed + const processors = Array.from({ length: panes }, (_unused, index) => { + const processChunk = createAgentStatusOscProcessor() + // No terminator, so the payload is carried into the next chunk. + processChunk(`${'x'.repeat(chunkChars)}\x1b]9999;{"state":"working","prompt":"p-${index}"`) + return processChunk + }) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + // The carry must still work: 8 MiB of chunks stay alive if it is attached. + expect(processors[0]!('}\x07').payloads[0]).toEqual({ state: 'working', prompt: 'p-0' }) + expect(retainedMiB).toBeLessThan(2) + }) }) diff --git a/src/shared/agent-status-osc.ts b/src/shared/agent-status-osc.ts index 95908805907..b9ff3caa01d 100644 --- a/src/shared/agent-status-osc.ts +++ b/src/shared/agent-status-osc.ts @@ -1,5 +1,6 @@ import type { ParsedAgentStatusPayload } from './agent-status-types' import { parseAgentStatusPayload } from './agent-status-types' +import { detachString } from './detached-string' const OSC_AGENT_STATUS_PREFIX = '\x1b]9999;' @@ -70,7 +71,9 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen if (terminator === null) { const candidate = combined.slice(start) - pending = candidate.length > MAX_PENDING ? '' : candidate + // Detached: `pending` survives until the next chunk, so an attached + // slice would pin this whole chunk per processor (one per pane). + pending = candidate.length > MAX_PENDING ? '' : detachString(candidate) break } diff --git a/src/shared/agent-status-types.test.ts b/src/shared/agent-status-types.test.ts index 6bface4ec92..074ca448b37 100644 --- a/src/shared/agent-status-types.test.ts +++ b/src/shared/agent-status-types.test.ts @@ -13,6 +13,7 @@ import { AGENT_STATUS_STATES, AGENT_TYPE_MAX_LENGTH } from './agent-status-types' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' afterEach(() => { vi.restoreAllMocks() @@ -241,6 +242,57 @@ Fix dispatch fallback preview for normalized status prompts` expect(AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH).toBe(16000) }) + // The capped value is cached in the store; if truncation returned a raw + // slice it would keep the whole oversized payload alive. + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the oversized payload behind a capped interactivePrompt', () => { + const oversize = 2 * 1024 * 1024 + const payloads = 64 + const forceGc = createForceGc(forcedGc!) + forceGc() + const before = process.memoryUsage().heapUsed + const held = Array.from({ length: payloads }, (_unused, index) => + normalizeAgentStatusPayload({ + state: 'waiting', + interactivePrompt: `{"questions":["q-${index}"`.padEnd(oversize, 'z') + }) + ) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + expect(held[0]!.interactivePrompt).toHaveLength(AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) + // 128 MiB of source payloads stay alive if the capped slice is still attached. + expect(retainedMiB).toBeLessThan(16) + }) + + // An under-cap prompt pins nothing (it is the caller's own flat string), so + // it must be passed through by identity rather than copied on every hook + // event — this path fires many times per second during a tool-use run. + itWithGc('returns an under-cap interactivePrompt without copying it', () => { + const source = JSON.stringify({ + questions: [{ question: 'Which approach?', options: ['a'.repeat(4000), 'b'.repeat(4000)] }] + }) + expect(source.length).toBeLessThan(AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH) + const forceGc = createForceGc(forcedGc!) + const repeats = 20_000 + + forceGc() + const before = process.memoryUsage().heapUsed + const held = Array.from( + { length: repeats }, + () => + normalizeAgentStatusPayload({ state: 'waiting', interactivePrompt: source })! + .interactivePrompt + ) + forceGc() + const bytesPerCall = (process.memoryUsage().heapUsed - before) / repeats + + expect(held[0]).toBe(source) + // A copy would cost ~source.length bytes per call; identity costs a pointer. + expect(bytesPerCall).toBeLessThan(source.length / 4) + }) + it('leaves interactivePrompt undefined when absent or non-string', () => { expect(parseAgentStatusPayload('{"state":"working"}')!.interactivePrompt).toBeUndefined() expect( diff --git a/src/shared/codex-subagent-transcript.test.ts b/src/shared/codex-subagent-transcript.test.ts index d2f729912b5..eb6335eaa16 100644 --- a/src/shared/codex-subagent-transcript.test.ts +++ b/src/shared/codex-subagent-transcript.test.ts @@ -9,6 +9,7 @@ import { reconcileCodexSubagentTranscript } from './codex-subagent-transcript' import { codexRosterToSnapshots, type CodexSubagentRoster } from './codex-subagent-roster' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' const CHILD_ID = '019fa65f-3144-7151-9c02-cff7a28f316f' @@ -155,4 +156,37 @@ describe('Codex subagent transcript reconciliation', () => { expect(hasTrackedCodexTranscriptSubagents(state)).toBe(false) expect(roster.size).toBe(0) }) + + // The trailing partial line is parked per cursor in + // codexSubagentTranscriptByPaneKey until the next poll, and `split` yields + // slices of the up-to-1 MiB read buffer, so an attached carry pins a whole + // buffer per tracked pane. + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the read buffer behind a carried partial line', () => { + const dir = mkdtempSync(join(tmpdir(), 'codex-subagent-transcript-retention-')) + dirs.push(dir) + const forceGc = createForceGc(forcedGc!) + const panes = 128 + const bulkLine = JSON.stringify({ type: 'event_msg', payload: { type: 'x'.repeat(500_000) } }) + + const states = Array.from({ length: panes }, (_unused, index) => { + const parentPath = join(dir, `rollout-parent-${index}.jsonl`) + // Trailing partial line (no newline) is what gets carried. + writeFileSync(parentPath, `${jsonl([activity('started')])}${bulkLine}\n{"partial":${index}`) + return { state: createCodexSubagentTranscriptState(), parentPath } + }) + + forceGc() + const before = process.memoryUsage().heapUsed + for (const { state, parentPath } of states) { + reconcileCodexSubagentTranscript(state, new Map(), parentPath) + } + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + expect(states[0]!.state.parent.carry).toBe('{"partial":0') + // ~64 MiB of read buffers stay alive if the carries are still attached. + expect(retainedMiB).toBeLessThan(8) + }) }) diff --git a/src/shared/codex-subagent-transcript.ts b/src/shared/codex-subagent-transcript.ts index 3f9b30898c1..a7af643b5e2 100644 --- a/src/shared/codex-subagent-transcript.ts +++ b/src/shared/codex-subagent-transcript.ts @@ -6,6 +6,7 @@ import { upsertCodexSubagent, type CodexSubagentRoster } from './codex-subagent-roster' +import { detachString } from './detached-string' const TRANSCRIPT_READ_MAX_BYTES = 1024 * 1024 const TRANSCRIPT_LINE_MAX_BYTES = 256 * 1024 @@ -77,7 +78,10 @@ function readJsonlCursor(cursor: JsonlCursor): JsonRecord[] | undefined { const content = `${skippedPrefix ? '' : cursor.carry}${buffer.toString('utf8', 0, bytesRead)}` const lines = content.split('\n') cursor.offset = start + bytesRead - cursor.carry = lines.pop() ?? '' + // Detached: `split` yields slices of the up-to-1 MiB read buffer, and this + // carry is parked per cursor (one per pane, plus one per tracked subagent, in + // codexSubagentTranscriptByPaneKey) until the next poll. + cursor.carry = detachString(lines.pop() ?? '') if (skippedPrefix) { lines.shift() } diff --git a/src/shared/command-code-output-status.test.ts b/src/shared/command-code-output-status.test.ts index a86c89a174b..1a38c03491b 100644 --- a/src/shared/command-code-output-status.test.ts +++ b/src/shared/command-code-output-status.test.ts @@ -3,6 +3,7 @@ import { createCommandCodeOutputStatusDetector, stripTerminalControl } from './command-code-output-status' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' afterEach(() => { vi.restoreAllMocks() @@ -462,4 +463,28 @@ describe('terminal control stripping', () => { expectLegacyEquivalent(data) } }) + + // Every pane runs this detector (the ring is written before the Command Code + // early-out), so an attached ring pins one whole PTY chunk per open pane. + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the source chunk behind the recent raw-text ring', () => { + const forceGc = createForceGc(forcedGc!) + const panes = 512 + const chunkChars = 16 * 1024 + + forceGc() + const before = process.memoryUsage().heapUsed + const detectors = Array.from({ length: panes }, (_unused, index) => { + const detector = createCommandCodeOutputStatusDetector({ onWorking: () => undefined }) + detector.observe(`${'x'.repeat(chunkChars)}pane-${index}`) + return detector + }) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + expect(detectors).toHaveLength(panes) + // ~8 MiB of source chunks stay alive if the rings are still attached. + expect(retainedMiB).toBeLessThan(2) + }) }) diff --git a/src/shared/command-code-output-status.ts b/src/shared/command-code-output-status.ts index 41e4c9eefc1..09292432344 100644 --- a/src/shared/command-code-output-status.ts +++ b/src/shared/command-code-output-status.ts @@ -9,6 +9,7 @@ import { cleanCommandCodePromptCandidate, isCommandCodeIdlePromptCandidate } from './command-code-prompt-text' +import { detachString } from './detached-string' import { stripTerminalControl } from './terminal-control-stripping' export { stripTerminalControl } from './terminal-control-stripping' @@ -136,11 +137,15 @@ function rawTextMayContainCommandCodeBanner(rawText: string): boolean { return rawText.includes('C') && rawText.includes('o') && rawText.includes('d') } +// Detached: every pane parks this ring until its next chunk (the detector runs +// before the Command Code early-out), so an attached slice pins a whole PTY +// chunk per pane. The empty-prefix case matters too: V8 returns `data` itself +// from `'' + data`, so that branch slices the raw chunk as well. function appendRecentRawText(previousRawText: string, data: string): string { if (data.length >= RECENT_TEXT_LIMIT) { - return data.slice(-RECENT_TEXT_LIMIT) + return detachString(data.slice(-RECENT_TEXT_LIMIT)) } - return (previousRawText + data).slice(-RECENT_TEXT_LIMIT) + return detachString((previousRawText + data).slice(-RECENT_TEXT_LIMIT)) } function buildStatusScanRawText(prefix: string, data: string): string { diff --git a/src/shared/detached-string.test.ts b/src/shared/detached-string.test.ts new file mode 100644 index 00000000000..84249839239 --- /dev/null +++ b/src/shared/detached-string.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { detachString } from './detached-string' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' + +describe('detachString', () => { + it('returns the value unchanged', () => { + for (const value of ['', 'a', '✳ Working… (esc to interrupt)', '{"questions":[]}', ' \n\t ']) { + expect(detachString(value)).toBe(value) + } + }) + + it('preserves surrogate pairs and astral characters', () => { + const value = '🚀 done \u{1f4a9}' + expect(detachString(value)).toBe(value) + expect(detachString(value).length).toBe(value.length) + }) + + it('preserves a lone surrogate', () => { + const value = '\ud83d' + expect(detachString(value)).toBe(value) + }) + + // Why the control arm: V8 only builds a SlicedString when the result is at + // least SlicedString::kMinLength (13) chars; shorter slices are already + // copied flat. A retention assertion over a short result therefore passes + // with or without detachString and proves nothing. Measuring the raw slice + // in the same run makes the test fail if that premise ever stops holding. + // + // resolveForcedGc works without --expose-gc, so this guard runs under the + // plain `vitest run` CI uses instead of silently skipping. + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('detaches a slice from its parent buffer', () => { + const chunkChars = 16 * 1024 + const slices = 4096 + const forceGc = createForceGc(forcedGc!) + // Comfortably over kMinLength so the untouched slice really is a SlicedString. + const measure = (detach: boolean): number => { + const held: string[] = [] + forceGc() + const before = process.memoryUsage().heapUsed + for (let index = 0; index < slices; index += 1) { + const sliced = `${'x'.repeat(chunkChars)}|retained-title-${index}`.slice(chunkChars + 1) + held.push(detach ? detachString(sliced) : sliced) + } + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + expect(held).toHaveLength(slices) + expect(held[0].length).toBeGreaterThanOrEqual(13) + return retainedMiB + } + + const attachedMiB = measure(false) + const detachedMiB = measure(true) + // ~128 MiB of parents stay alive when the slices are left attached. + expect(attachedMiB).toBeGreaterThan(16) + expect(detachedMiB).toBeLessThan(attachedMiB / 8) + }) +}) diff --git a/src/shared/detached-string.ts b/src/shared/detached-string.ts new file mode 100644 index 00000000000..a29345740c2 --- /dev/null +++ b/src/shared/detached-string.ts @@ -0,0 +1,18 @@ +/** + * Return a copy of `value` that shares no backing store with the string it was + * sliced from. + * + * Why: `String.prototype.slice` on a long string produces a V8 SlicedString + * that keeps a pointer to its parent, so a short slice retained in long-lived + * state (a store entry, a cached field) pins the entire source buffer — a raw + * PTY chunk or a hook JSON payload — for as long as the slice is reachable. + * Concatenating forces V8 to flatten into a fresh flat string, dropping the + * parent pointer. The leading space is stripped back off so the value is + * unchanged. + */ +export function detachString(value: string): string { + if (value.length === 0) { + return '' + } + return ` ${value}`.slice(1) +} diff --git a/src/shared/draft-paste-ready-scanner.test.ts b/src/shared/draft-paste-ready-scanner.test.ts index 3af02f06795..fc3f629087a 100644 --- a/src/shared/draft-paste-ready-scanner.test.ts +++ b/src/shared/draft-paste-ready-scanner.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { createDraftPasteReadyScanner } from './draft-paste-ready-scanner' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' const DECSET_BRACKETED_PASTE = '\x1b[?2004h' const SHOW_CURSOR = '\x1b[?25h' @@ -136,3 +137,32 @@ describe('createDraftPasteReadyScanner', () => { }) }) }) + +// The 512-char rings live in the scanner closure — one scanner per pane +// waiting to deliver a startup draft — so an attached slice pins a whole PTY +// chunk per waiter instead of the 512 chars the ring documents. +describe('draft paste scanner retention', () => { + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the source chunk behind the recent rings', () => { + const chunkChars = 16 * 1024 + const waiters = 512 + const forceGc = createForceGc(forcedGc!) + forceGc() + const before = process.memoryUsage().heapUsed + const scanners = Array.from({ length: waiters }, (_unused, index) => { + const scanner = createDraftPasteReadyScanner('codex-composer-prompt') + // A distinct chunk per waiter. Sharing one would leave a single parent + // alive and pass whether or not the rings are detached. + scanner.observe(`${DECSET_BRACKETED_PASTE}${'x'.repeat(chunkChars)}pane-${index}`) + return scanner + }) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + // The rings must still detect the prompt glyph on the next chunk. + expect(scanners[0]!.observe(CODEX_PROMPT)).toEqual({ ready: true, armQuietTimer: false }) + // 8 MiB of source chunks stay alive if the rings are attached. + expect(retainedMiB).toBeLessThan(2) + }) +}) diff --git a/src/shared/draft-paste-ready-scanner.ts b/src/shared/draft-paste-ready-scanner.ts index da491dc2d93..dfd5296d30a 100644 --- a/src/shared/draft-paste-ready-scanner.ts +++ b/src/shared/draft-paste-ready-scanner.ts @@ -1,3 +1,4 @@ +import { detachString } from './detached-string' import type { DraftPasteReadySignal } from './tui-agent-config' // Why: agents enable bracketed paste (DECSET 2004) before their composer is @@ -12,6 +13,7 @@ const CODEX_COMPOSER_PROMPT = '›' // racing the composer mount under slow/noisy startup. mimo-code uses the same // signal by parity; the quiet-window fallback covers any agent that differs. const DECTCEM_SHOW_CURSOR = '\x1b[?25h' +const RING_CHARS = 512 export type DraftPasteReadyScanResult = { /** The agent-specific ready signal fired — caller should deliver the paste now. */ @@ -60,7 +62,10 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) return { observe(data: string): DraftPasteReadyScanResult { const combined = recent + data - recent = combined.slice(-512) + // Detached: both rings survive until the next chunk (and past settle, until + // the scanner itself is dropped), so an attached slice would pin a whole + // PTY chunk per waiting pane instead of the 512 chars the ring promises. + recent = detachString(combined.slice(-RING_CHARS)) if (!saw2004) { const markerIndex = combined.indexOf(DECSET_BRACKETED_PASTE) if (markerIndex === -1) { @@ -71,7 +76,7 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) if (signalMarker !== null && postHandshakeChunk.includes(signalMarker)) { return { ready: true, armQuietTimer: false } } - postHandshakeRecent = postHandshakeChunk.slice(-512) + postHandshakeRecent = detachString(postHandshakeChunk.slice(-RING_CHARS)) } else { if ( signalMarker !== null && @@ -79,7 +84,7 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal) ) { return { ready: true, armQuietTimer: false } } - postHandshakeRecent = (postHandshakeRecent + data).slice(-512) + postHandshakeRecent = detachString((postHandshakeRecent + data).slice(-RING_CHARS)) } // Why: marker-based signals (Codex glyph, opencode show-cursor) must NOT // arm the quiet window. opencode goes silent for ~1.5-2s between enabling diff --git a/src/shared/forced-gc-for-retention-tests.ts b/src/shared/forced-gc-for-retention-tests.ts new file mode 100644 index 00000000000..9c0a49fc89e --- /dev/null +++ b/src/shared/forced-gc-for-retention-tests.ts @@ -0,0 +1,37 @@ +import { setFlagsFromString } from 'node:v8' +import { runInNewContext } from 'node:vm' + +/** + * Resolve a real forced-GC function for retention tests, without requiring the + * runner to pass `--expose-gc`. + * + * Why: CI runs plain `pnpm exec vitest run` (.github/workflows/pr.yml), so + * `globalThis.gc` is undefined and every `describe.skip`-on-missing-gc + * retention guard silently stops running — a reintroduced raw slice would land + * green. `v8.setFlagsFromString('--expose-gc')` plus a throwaway VM context + * hands back a real `gc` binding in the worker; the flag is reset immediately + * so nothing else in the process observes it. Returns null only if the host + * genuinely refuses (hardened runtimes), which keeps callers skippable. + */ +export function resolveForcedGc(): (() => void) | null { + const existing = (globalThis as { gc?: () => void }).gc + if (typeof existing === 'function') { + return existing + } + try { + setFlagsFromString('--expose-gc') + const exposed = runInNewContext('gc') as unknown + setFlagsFromString('--no-expose-gc') + return typeof exposed === 'function' ? (exposed as () => void) : null + } catch { + return null + } +} + +/** Double-collect so a freshly unreachable parent is actually released. */ +export function createForceGc(gc: () => void): () => void { + return () => { + gc() + gc() + } +} diff --git a/src/shared/osc-title-extraction.retention.test.ts b/src/shared/osc-title-extraction.retention.test.ts new file mode 100644 index 00000000000..e841662c8e6 --- /dev/null +++ b/src/shared/osc-title-extraction.retention.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' +import { extractAllOscTitles } from './osc-title-extraction' + +// What this proves — and what it does not. +// +// Invariant under test: an extracted OSC title is a standalone string, not a V8 +// SlicedString pinning the PTY chunk it was cut from. Chunks are sized at the +// production renderer-write bound (PTY_BATCH_FLUSH_CHUNK_CHARS = 16 KiB, see +// src/main/ipc/pty.ts), not the multi-MiB chunks an earlier draft assumed. +// +// This is the unbounded-in-principle case: N titles from N distinct chunks all +// held live at once. Production's per-pane store write is an overwrite, so the +// live set there is O(panes) and small. So this is a regression guard on the +// retention property, NOT a reproduction of a shipped out-of-memory crash. +// +const MIB = 1024 * 1024 +const CHUNK_CHARS = 16 * 1024 +const RETAINED_TITLES = 4096 +const PINNED_CHUNK_MIB = (CHUNK_CHARS * RETAINED_TITLES) / MIB + +// resolveForcedGc works without --expose-gc, so this runs in CI's plain +// `vitest run`. Skips only if a hardened host refuses to expose gc at all. +const forcedGc = resolveForcedGc() +const describeWithGc = forcedGc ? describe : describe.skip +const forceGc = forcedGc ? createForceGc(forcedGc) : (): void => undefined + +function makePtyChunk(index: number): string { + // A realistic dense agent frame: bulk redraw plus one OSC 0 working title. + return `${'x'.repeat(CHUNK_CHARS)}\x1b]0;✳ Working… (esc to interrupt) ${index}\x07` +} + +describeWithGc('OSC title retention', () => { + it('does not pin the source PTY chunk behind a retained title', () => { + forceGc() + const before = process.memoryUsage().heapUsed + const retained: string[] = [] + for (let index = 0; index < RETAINED_TITLES; index += 1) { + // The chunk itself goes out of scope every iteration; only the title survives. + const titles = extractAllOscTitles(makePtyChunk(index)) + retained.push(titles.at(-1) as string) + } + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / MIB + const titleChars = retained.reduce((total, title) => total + title.length, 0) + + // eslint-disable-next-line no-console + console.log( + `retained ${retained.length} titles (${titleChars} chars total) -> heap delta ${retainedMiB.toFixed(2)} MiB; ` + + `source chunks total ${PINNED_CHUNK_MIB} MiB if every title pinned its parent` + ) + + expect(retained).toHaveLength(RETAINED_TITLES) + expect(retained[0]).toBe('✳ Working… (esc to interrupt) 0') + // The titles hold well under 1 MiB of actual text. A delta anywhere near the + // source-chunk total means the slices still point at their parents. + expect(retainedMiB).toBeLessThan(PINNED_CHUNK_MIB / 8) + }) +}) diff --git a/src/shared/osc-title-extraction.ts b/src/shared/osc-title-extraction.ts index 20760457237..bc11d3f537d 100644 --- a/src/shared/osc-title-extraction.ts +++ b/src/shared/osc-title-extraction.ts @@ -1,3 +1,5 @@ +import { detachString } from './detached-string' + const ESC_CODE_UNIT = 0x1b const BEL_CODE_UNIT = 0x07 const RIGHT_BRACKET_CODE_UNIT = 0x5d @@ -59,15 +61,18 @@ function parseOscTitleAt(data: string, index: number): OscTitleParseResult { function readBoundedOscTitle(data: string, titleStart: number, titleEnd: number): string { // Why: PTY output can contain pasted or remote-controlled OSC titles; keep // downstream title detection bounded while preserving trailing status words. + // Why detach here: titles outlive their chunk in pane/tab state, and a raw + // slice would pin the whole PTY chunk. Detaching at the extraction boundary + // means no consumer can bypass it. const length = titleEnd - titleStart if (length <= MAX_OSC_TITLE_CHARS) { - return data.slice(titleStart, titleEnd) + return detachString(data.slice(titleStart, titleEnd)) } const prefixLength = Math.ceil(MAX_OSC_TITLE_CHARS / 2) const suffixLength = MAX_OSC_TITLE_CHARS - prefixLength - return ( + return detachString( data.slice(titleStart, titleStart + prefixLength) + - data.slice(titleEnd - suffixLength, titleEnd) + data.slice(titleEnd - suffixLength, titleEnd) ) } diff --git a/src/shared/osc-title-scan-tail.test.ts b/src/shared/osc-title-scan-tail.test.ts index f6afef56f48..0c6efb78f54 100644 --- a/src/shared/osc-title-scan-tail.test.ts +++ b/src/shared/osc-title-scan-tail.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' import { extractOscTitleScanTail } from './osc-title-scan-tail' describe('extractOscTitleScanTail', () => { @@ -14,4 +15,32 @@ describe('extractOscTitleScanTail', () => { expect(extractOscTitleScanTail('\x1b]7;file://host/tmp')).toBe('') expect(extractOscTitleScanTail('\x1b]133;D;0\x07\x1b')).toBe('\x1b') }) + + // A carried tail lives in a per-PTY map (agent-detector.ts, + // orca-runtime.ts) until the next chunk, so a raw slice would pin one whole + // source chunk per tracked PTY. + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the source chunk behind a carried tail', () => { + const chunkChars = 16 * 1024 + const ptys = 512 + const forceGc = createForceGc(forcedGc!) + // A split title: no terminator yet, so the tail is carried to the next chunk. + const makeChunk = (index: number): string => + `${'x'.repeat(chunkChars)}\x1b]0;✳ Working… (esc to interrupt) pty-${index}` + + forceGc() + const before = process.memoryUsage().heapUsed + const carried = new Map() + for (let index = 0; index < ptys; index += 1) { + carried.set(`pty-${index}`, extractOscTitleScanTail(makeChunk(index))) + } + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + expect(carried.size).toBe(ptys) + expect(carried.get('pty-0')).toBe('\x1b]0;✳ Working… (esc to interrupt) pty-0') + // 16 MiB of source chunks stay alive if the tails are still attached. + expect(retainedMiB).toBeLessThan(2) + }) }) diff --git a/src/shared/osc-title-scan-tail.ts b/src/shared/osc-title-scan-tail.ts index 62e49cf9236..4709f89bd95 100644 --- a/src/shared/osc-title-scan-tail.ts +++ b/src/shared/osc-title-scan-tail.ts @@ -1,7 +1,13 @@ +import { detachString } from './detached-string' + const OSC_TITLE_SCAN_TAIL_LIMIT = 4096 const OSC_TITLE_PREFIX_LENGTH = 4 const OSC_TITLE_CODES = new Set(['0', '1', '2']) +// Callers park the returned tail in a per-PTY map until the next chunk +// arrives. The non-empty tails are slices of the raw chunk, so they are +// detached (see trimOscTitleScanTail) — otherwise each carried tail would pin +// a whole source chunk for as long as its PTY stays tracked. export function extractOscTitleScanTail(input: string): string { const lastOsc = input.lastIndexOf('\x1b]') if (lastOsc !== -1) { @@ -26,11 +32,11 @@ function extractIncompleteTitleOscTail(suffix: string): string { function trimOscTitleScanTail(value: string): string { if (value.length <= OSC_TITLE_SCAN_TAIL_LIMIT) { - return value + return detachString(value) } // Preserve the OSC introducer while keeping the newest payload bytes, so // bounded tails can still reconstruct a split title terminator. const prefix = value.slice(0, Math.min(OSC_TITLE_PREFIX_LENGTH, value.length)) const suffixBudget = Math.max(0, OSC_TITLE_SCAN_TAIL_LIMIT - prefix.length) - return `${prefix}${value.slice(-suffixBudget)}` + return detachString(`${prefix}${value.slice(-suffixBudget)}`) } diff --git a/src/shared/terminal-color-scheme-protocol.test.ts b/src/shared/terminal-color-scheme-protocol.test.ts index 76a320a9192..5e880536afd 100644 --- a/src/shared/terminal-color-scheme-protocol.test.ts +++ b/src/shared/terminal-color-scheme-protocol.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' import { mode2031SequenceFor, resolveTerminalColorSchemeMode, @@ -51,4 +52,27 @@ describe('terminal color scheme protocol', () => { tail: '' }) }) + + // The incomplete private-mode tail is carried in per-pane scan state until the + // next chunk, so an attached slice pins a whole PTY chunk per pane. + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the source chunk behind a carried private-mode tail', () => { + const chunkChars = 16 * 1024 + const panes = 512 + const forceGc = createForceGc(forcedGc!) + forceGc() + const before = process.memoryUsage().heapUsed + const tails = Array.from( + { length: panes }, + // No final byte yet, so the params tail is carried to the next chunk. + () => scanMode2031Sequences('', `${'x'.repeat(chunkChars)}\x1b[?1049;2004;2026;12345`).tail + ) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + expect(tails[0]).toBe('\x1b[?1049;2004;2026;12345') + // 8 MiB of source chunks stay alive if the tails are still attached. + expect(retainedMiB).toBeLessThan(2) + }) }) diff --git a/src/shared/terminal-color-scheme-protocol.ts b/src/shared/terminal-color-scheme-protocol.ts index 1b6d680c2a1..181767c976b 100644 --- a/src/shared/terminal-color-scheme-protocol.ts +++ b/src/shared/terminal-color-scheme-protocol.ts @@ -1,3 +1,4 @@ +import { detachString } from './detached-string' import type { GlobalSettings } from './types' export type TerminalColorSchemeMode = 'dark' | 'light' @@ -131,6 +132,8 @@ function hasMode2031(params: string): boolean { return params.split(';').some((param) => Number(param) === 2031) } +// Detached: the retained tail is carried in per-pane scan state until the next +// chunk, so an attached slice pins a whole PTY chunk per pane. function extractPrivateModeScanTail(input: string): string { const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) if (start === -1) { @@ -144,10 +147,10 @@ function extractPrivateModeScanTail(input: string): string { return tail } if (tail.startsWith('\x1b[?')) { - return isIncompletePrivateModeParams(tail.slice(3)) ? tail : '' + return isIncompletePrivateModeParams(tail.slice(3)) ? detachString(tail) : '' } if (tail.startsWith('\x9b?')) { - return isIncompletePrivateModeParams(tail.slice(2)) ? tail : '' + return isIncompletePrivateModeParams(tail.slice(2)) ? detachString(tail) : '' } return '' } diff --git a/src/shared/terminal-github-pr-link-detector.test.ts b/src/shared/terminal-github-pr-link-detector.test.ts index 89fbf422341..e3b6dbe44b3 100644 --- a/src/shared/terminal-github-pr-link-detector.test.ts +++ b/src/shared/terminal-github-pr-link-detector.test.ts @@ -1,4 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' import { createTerminalGitHubPRLinkDetector } from './terminal-github-pr-link-detector' const issue8126Url = 'https://github.com/owner/repo/pull/10' @@ -291,4 +292,36 @@ describe('createTerminalGitHubPRLinkDetector', () => { } ]) }) + + // The URL carry lives in the detector closure until the next chunk, and there + // is one detector per pane/PTY, so an attached slice pins a whole PTY chunk + // per pane for as long as the pane stays open. + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the source chunk behind a carried PR-URL prefix', () => { + const chunkChars = 16 * 1024 + const panes = 512 + const forceGc = createForceGc(forcedGc!) + forceGc() + const before = process.memoryUsage().heapUsed + const detectors = Array.from({ length: panes }, (_unused, index) => { + const observe = createTerminalGitHubPRLinkDetector() + // Ends mid-URL, so the prefix is carried into the next chunk. + observe(`${'x'.repeat(chunkChars)}https://github.com/acme/repo-${index}/pul`) + return observe + }) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + // The carry must still complete the split URL across the chunk boundary. + expect(detectors[0]!('l/12\n')).toEqual([ + { + url: 'https://github.com/acme/repo-0/pull/12', + slug: { owner: 'acme', repo: 'repo-0', host: 'github.com' }, + number: 12 + } + ]) + // 8 MiB of source chunks stay alive if the carries are still attached. + expect(retainedMiB).toBeLessThan(2) + }) }) diff --git a/src/shared/terminal-github-pr-link-detector.ts b/src/shared/terminal-github-pr-link-detector.ts index 7d6204ce393..6e396fa4eb8 100644 --- a/src/shared/terminal-github-pr-link-detector.ts +++ b/src/shared/terminal-github-pr-link-detector.ts @@ -8,6 +8,7 @@ * path. Both paths must share the carry/dedupe semantics or links split across * chunks would resolve differently per authority mode. */ +import { detachString } from './detached-string' import type { RepoSlug } from './github-links' import { parseGitHubIssueOrPRLink } from './github-links' @@ -65,6 +66,8 @@ function lastIndexOfHttpScheme(value: string, fromIndex?: number): number { return lastIndex } +// Detached: the carry survives in the detector closure until the next chunk, and +// there is one detector per pane/PTY, so an attached slice pins a whole chunk each. function getPotentialGitHubPRCarry(value: string): string { // Why bounded: carry is always a suffix of at most MAX_CARRY_LENGTH, so a scheme // further back can only ever be dropped — scanning to it is O(chunk) per PTY write. @@ -75,16 +78,16 @@ function getPotentialGitHubPRCarry(value: string): string { const schemeIndex = windowStart + schemeIndexInWindow return hasTerminalUrlWhitespace(value, schemeIndex, value.length) ? '' - : value.slice(schemeIndex) + : detachString(value.slice(schemeIndex)) } const fragment = endsWithHttpSchemePrefixFragment(tailWindow) if (fragment === '' || windowStart === 0) { - return fragment + return detachString(fragment) } // Why look behind: an older scheme means the URL already overran the cap, so the // carry is abandoned rather than restarted from this fragment. - return lastIndexOfHttpScheme(value, windowStart - 1) === -1 ? fragment : '' + return lastIndexOfHttpScheme(value, windowStart - 1) === -1 ? detachString(fragment) : '' } function hasTerminalUrlWhitespace(value: string, start: number, end: number): boolean { diff --git a/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts index 9ad2cb4d77a..0f41bb04dee 100644 --- a/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts +++ b/src/shared/terminal-kitty-keyboard-mode-tracker.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' import { TerminalKittyKeyboardModeTracker } from './terminal-kitty-keyboard-mode-tracker' describe('TerminalKittyKeyboardModeTracker', () => { @@ -147,4 +148,59 @@ describe('TerminalKittyKeyboardModeTracker', () => { ranAndExited.scanReplay('\x1b[>1uoutput\x1b[ { + const chunkChars = 16 * 1024 + const panes = 512 + const forceGc = createForceGc(forcedGc!) + expect(splitPrivateModeTail.length).toBeGreaterThanOrEqual(13) + forceGc() + const before = process.memoryUsage().heapUsed + const trackers = Array.from({ length: panes }, (_unused, index) => { + const tracker = new TerminalKittyKeyboardModeTracker() + // Ends mid-DECSET, so the params tail is carried into the next chunk. + tracker.scan(`${'x'.repeat(chunkChars)}pty-${index}${splitPrivateModeTail}`) + return tracker + }) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + // The carry must still complete the split sequence across the boundary. + trackers[0]!.scan('9h') + expect(trackers[0]!.isAlternateScreen).toBe(true) + // 8 MiB of source chunks stay alive if the tails are still attached. + expect(retainedMiB).toBeLessThan(2) + }) + + // scanReplay is fed whole reattach snapshots, not 16 KiB PTY chunks, so an + // attached tail pins far more per pane than the live path does. + itWithGc('does not pin a reattach snapshot behind a carried kitty scan tail', () => { + // A default 5000-row scrollback snapshot at ~120 chars/row. + const snapshotChars = 5000 * 120 + const panes = 20 + const forceGc = createForceGc(forcedGc!) + forceGc() + const before = process.memoryUsage().heapUsed + const trackers = Array.from({ length: panes }, (_unused, index) => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scanReplay(`${'x'.repeat(snapshotChars)}pane-${index}${splitPrivateModeTail}`) + return tracker + }) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + trackers[0]!.scan('9h') + expect(trackers[0]!.isAlternateScreen).toBe(true) + // ~11 MiB of snapshots stay alive if the tails are still attached. + expect(retainedMiB).toBeLessThan(2) + }) }) diff --git a/src/shared/terminal-kitty-keyboard-mode-tracker.ts b/src/shared/terminal-kitty-keyboard-mode-tracker.ts index 2b501a2c9ff..64531a21f13 100644 --- a/src/shared/terminal-kitty-keyboard-mode-tracker.ts +++ b/src/shared/terminal-kitty-keyboard-mode-tracker.ts @@ -1,3 +1,5 @@ +import { detachString } from './detached-string' + // Why: PTY/SSH chunks can split an escape sequence before its final byte. // Keep parser state far beyond normal sequence lengths while bounding memory. const KITTY_SCAN_TAIL_LIMIT = 4096 @@ -169,6 +171,10 @@ export class TerminalKittyKeyboardModeTracker { } } + // Detached: this tracker is per-pane (renderer) and per-PTY (main), and the + // tail outlives its input until the next chunk. An attached slice would pin + // the whole input — a live PTY chunk, or a full reattach/replay snapshot on + // the scanReplay path — for as long as the pane stays open. private extractScanTail(input: string): string { const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) if (start === -1) { @@ -189,7 +195,7 @@ export class TerminalKittyKeyboardModeTracker { if (body === null) { return '' } - return this.isIncompleteSequenceBody(body) ? tail : '' + return this.isIncompleteSequenceBody(body) ? detachString(tail) : '' } private isIncompleteSequenceBody(body: string): boolean { diff --git a/src/shared/terminal-osc133-command-finished.test.ts b/src/shared/terminal-osc133-command-finished.test.ts new file mode 100644 index 00000000000..03c638b8c64 --- /dev/null +++ b/src/shared/terminal-osc133-command-finished.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from 'vitest' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' +import { createOsc133CommandFinishedScanner } from './terminal-osc133-command-finished' + +describe('createOsc133CommandFinishedScanner', () => { + it('reports the exit code for a complete OSC 133;D sequence', () => { + const onCommandFinished = vi.fn() + const scanner = createOsc133CommandFinishedScanner(onCommandFinished) + + scanner.scan('build output\x1b]133;D;3\x07more') + + expect(onCommandFinished).toHaveBeenCalledWith(3) + }) + + it('carries an unterminated sequence into the next chunk', () => { + const onCommandFinished = vi.fn() + const scanner = createOsc133CommandFinishedScanner(onCommandFinished) + + scanner.scan('\x1b]133;D;') + expect(onCommandFinished).not.toHaveBeenCalled() + + scanner.scan('7\x07') + expect(onCommandFinished).toHaveBeenCalledWith(7) + }) + + it('drops the carry on reset', () => { + const onCommandFinished = vi.fn() + const scanner = createOsc133CommandFinishedScanner(onCommandFinished) + + scanner.scan('\x1b]133;D;1') + scanner.reset() + scanner.scan('\x07') + + expect(onCommandFinished).not.toHaveBeenCalled() + }) + + // The carry lives in the scanner closure until the next chunk, and there is + // one scanner per pane, so an attached slice pins a whole PTY chunk per pane. + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the source chunk behind a carried OSC 133 payload', () => { + const chunkChars = 16 * 1024 + const panes = 512 + const forceGc = createForceGc(forcedGc!) + const finished = vi.fn() + forceGc() + const before = process.memoryUsage().heapUsed + const scanners = Array.from({ length: panes }, (_unused, index) => { + const scanner = createOsc133CommandFinishedScanner(finished) + // No terminator yet, so the payload is carried into the next chunk. + scanner.scan(`${'x'.repeat(chunkChars)}\x1b]133;D;${index} pending payload tail text`) + return scanner + }) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + // The carry must still resolve across the chunk boundary. + scanners[0]!.scan('\x07') + expect(finished).toHaveBeenCalledWith(0) + // 8 MiB of source chunks stay alive if the carries are still attached. + expect(retainedMiB).toBeLessThan(2) + }) +}) diff --git a/src/shared/terminal-osc133-command-finished.ts b/src/shared/terminal-osc133-command-finished.ts index e4b9cdb8129..33c1ddaec84 100644 --- a/src/shared/terminal-osc133-command-finished.ts +++ b/src/shared/terminal-osc133-command-finished.ts @@ -8,6 +8,8 @@ * terminators, best-effort exit codes) must be identical in both. */ +import { detachString } from './detached-string' + type OscTerminator = { index: number length: number @@ -87,10 +89,14 @@ export function createOsc133CommandFinishedScanner( const payloadStart = start + OSC_133_PREFIX.length const terminator = findOscTerminator(combined, payloadStart) if (!terminator) { - carry = combined.slice(start) - if (carry.length > MAX_OSC_CARRY_LENGTH) { - carry = carry.slice(carry.length - MAX_OSC_CARRY_LENGTH) - } + const pending = combined.slice(start) + // Detached: the carry outlives this chunk in the scanner closure (one per + // pane), so an attached slice would pin the whole chunk until the next one. + carry = detachString( + pending.length > MAX_OSC_CARRY_LENGTH + ? pending.slice(pending.length - MAX_OSC_CARRY_LENGTH) + : pending + ) return } diff --git a/src/shared/terminal-partial-escape-tail.test.ts b/src/shared/terminal-partial-escape-tail.test.ts index e0ef2095ddd..211bb3ea9d0 100644 --- a/src/shared/terminal-partial-escape-tail.test.ts +++ b/src/shared/terminal-partial-escape-tail.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' import { advancePartialEscapeTail, extractPartialEscapeTail, @@ -83,4 +84,29 @@ describe('advancePartialEscapeTail', () => { const huge = `\x1b]0;${'x'.repeat(MAX_PARTIAL_ESCAPE_TAIL_LENGTH + 10)}` expect(advancePartialEscapeTail('', huge)).toBe('') }) + + // The headless emulator parks this tail per session until the next write and + // ships it in every snapshot, so an attached slice pins a whole written + // stream per live session. + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the written stream behind a carried escape tail', () => { + const chunkChars = 16 * 1024 + const sessions = 512 + const forceGc = createForceGc(forcedGc!) + forceGc() + const before = process.memoryUsage().heapUsed + const tails = Array.from({ length: sessions }, (_unused, index) => + // Unterminated OSC, so the tail is carried into the next write. + advancePartialEscapeTail('', `${'x'.repeat(chunkChars)}\x1b]0;pane-${index} title`) + ) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + expect(tails[0]).toBe('\x1b]0;pane-0 title') + // The fold must still complete the split sequence on the next chunk. + expect(advancePartialEscapeTail(tails[0]!, '\x07rest')).toBe('') + // 8 MiB of source streams stay alive if the tails are still attached. + expect(retainedMiB).toBeLessThan(2) + }) }) diff --git a/src/shared/terminal-partial-escape-tail.ts b/src/shared/terminal-partial-escape-tail.ts index 1ee606e60f6..9d8e53b2b2d 100644 --- a/src/shared/terminal-partial-escape-tail.ts +++ b/src/shared/terminal-partial-escape-tail.ts @@ -6,6 +6,8 @@ // partial sequence at the ingest boundary lets snapshot producers append it // after the serialized screen so the continuation completes exactly as live. +import { detachString } from './detached-string' + // Mirrors the VT500 parser states that can span a chunk boundary. C0 controls // (except ESC/CAN/SUB) execute mid-sequence without aborting it, matching // xterm's state machine. @@ -145,5 +147,9 @@ export function extractPartialEscapeTail(stream: string): string { * (tracking abandoned) when the tail exceeds the cap — see the cap comment. */ export function advancePartialEscapeTail(pendingTail: string, chunk: string): string { const tail = extractPartialEscapeTail(pendingTail + chunk) - return tail.length > MAX_PARTIAL_ESCAPE_TAIL_LENGTH ? '' : tail + // Detached: this is the ingest-time fold, so callers park the result per + // emulator/session until the next write; an attached slice would pin the + // whole written stream. Detaching after the cap check keeps the discarded + // over-cap tail (an unterminated OSC can span the entire chunk) free. + return tail.length > MAX_PARTIAL_ESCAPE_TAIL_LENGTH ? '' : detachString(tail) } diff --git a/src/shared/terminal-reply-query-extraction.test.ts b/src/shared/terminal-reply-query-extraction.test.ts new file mode 100644 index 00000000000..ed02cdf5c95 --- /dev/null +++ b/src/shared/terminal-reply-query-extraction.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' +import { + extractHiddenStartupRendererQueryData, + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS +} from './terminal-reply-query-extraction' + +describe('extractHiddenStartupRendererQueryData', () => { + it('carries a CSI query split across chunks', () => { + const first = extractHiddenStartupRendererQueryData('output\x1b[6', '') + expect(first.pending).toBe('\x1b[6') + expect(first.statefulQueryData).toBe('') + + const second = extractHiddenStartupRendererQueryData('n', first.pending) + expect(second.statefulQueryData).toBe('\x1b[6n') + expect(second.pending).toBe('') + }) + + it('carries a partial OSC color query across chunks', () => { + const first = extractHiddenStartupRendererQueryData('\x1b]11;', '') + expect(first.pending).toBe('\x1b]11;') + + const second = extractHiddenStartupRendererQueryData('?\x07', first.pending) + expect(second.oscColorQueryData).toBe('\x1b]11;?\x07') + expect(second.pending).toBe('') + }) + + it('bounds the carried pending prefix', () => { + const { pending } = extractHiddenStartupRendererQueryData(`\x1b[${'1;'.repeat(200)}`, '') + expect(pending.length).toBe(HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS) + }) + + // `pending` is parked per pane (hiddenStartupRendererQueryPending in + // pty-connection.ts) until the next chunk, so an attached slice pins a whole + // PTY chunk per open pane. + // + // Why the split CSI is long: V8 only builds a SlicedString past + // SlicedString::kMinLength (13 chars); a shorter prefix is already copied + // flat and would make this assertion pass with or without the detach. + const splitCsi = '\x1b[?1049;2004;2026' + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the source chunk behind a carried pending query', () => { + const chunkChars = 16 * 1024 + const panes = 512 + const forceGc = createForceGc(forcedGc!) + expect(splitCsi.length).toBeGreaterThanOrEqual(13) + forceGc() + const before = process.memoryUsage().heapUsed + const pendings = Array.from( + { length: panes }, + (_unused, index) => + // A distinct chunk per pane, each ending mid-CSI. Sharing one chunk + // would leave a single parent alive and pass either way. + extractHiddenStartupRendererQueryData( + `${'x'.repeat(chunkChars)}pane-${index}${splitCsi}`, + '' + ).pending + ) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + // The pending prefix must still assemble the query across the boundary. + expect(pendings[0]).toBe(splitCsi) + expect(extractHiddenStartupRendererQueryData('$p', pendings[0]!).statefulQueryData).toBe( + `${splitCsi}$p` + ) + // 8 MiB of source chunks stay alive if the pending prefixes are attached. + expect(retainedMiB).toBeLessThan(2) + }) +}) diff --git a/src/shared/terminal-reply-query-extraction.ts b/src/shared/terminal-reply-query-extraction.ts index 0ff95b943aa..fdca71cf94d 100644 --- a/src/shared/terminal-reply-query-extraction.ts +++ b/src/shared/terminal-reply-query-extraction.ts @@ -4,6 +4,7 @@ // to drop: the renderer's hidden-output restore queue and main's pending-cap // bulk drop. A swallowed query means the program that sent it waits forever // for a reply (the bench DSR timeout). +import { detachString } from './detached-string' import { parseTerminalOscColorQuery } from './terminal-osc-color-reply' export const HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS = 64 @@ -15,6 +16,13 @@ export type ExtractedRendererQueryData = { pending: string } +// Detached: callers park `pending` until the next chunk — one per pane in the +// renderer (hiddenStartupRendererQueryPending, pty-connection.ts) — so an +// attached slice would pin a whole PTY chunk for as long as the pane lives. +function detachedQueryPending(input: string, start: number): string { + return detachString(input.slice(start, start + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS)) +} + export function extractHiddenStartupRendererQueryData( data: string, pending: string @@ -45,10 +53,7 @@ export function extractHiddenStartupRendererQueryData( statelessQueryData, statefulQueryData, oscColorQueryData, - pending: input.slice( - candidateIndex, - candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS - ) + pending: detachedQueryPending(input, candidateIndex) } } const sequence = input.slice(candidateIndex, finalByteIndex + 1) @@ -68,10 +73,7 @@ export function extractHiddenStartupRendererQueryData( statelessQueryData, statefulQueryData, oscColorQueryData, - pending: input.slice( - candidateIndex, - candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS - ) + pending: detachedQueryPending(input, candidateIndex) } } if (query.kind === 'none') { diff --git a/src/shared/terminal-reply-query-scan.test.ts b/src/shared/terminal-reply-query-scan.test.ts index 2a94eaad63d..13b8246ab82 100644 --- a/src/shared/terminal-reply-query-scan.test.ts +++ b/src/shared/terminal-reply-query-scan.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { createForceGc, resolveForcedGc } from './forced-gc-for-retention-tests' import { EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE, scanTerminalReplyQuerySequences @@ -38,4 +39,54 @@ describe('terminal reply query scan', () => { expect(second.queries).toEqual([]) }) + + // The pending prefix is parked in per-stream scan state until the next chunk + // for the whole mobile-stream buffering window, so an attached slice pins a + // whole PTY chunk per buffering stream. + // + // Why the pending prefix is long: V8 only builds a SlicedString past + // SlicedString::kMinLength (13 chars); a shorter one is already copied flat + // and would make this assertion pass with or without the detach. + const splitQueryPrefix = '\x1b[?1049;2004;2026' + const forcedGc = resolveForcedGc() + const itWithGc = forcedGc ? it : it.skip + itWithGc('does not pin the source chunk behind a pending split query', () => { + const chunkChars = 16 * 1024 + const streams = 512 + const forceGc = createForceGc(forcedGc!) + expect(splitQueryPrefix.length).toBeGreaterThanOrEqual(13) + forceGc() + const before = process.memoryUsage().heapUsed + const states = Array.from( + { length: streams }, + (_unused, index) => + // A distinct chunk per stream, each ending mid-CSI so the partial query + // is carried. Sharing one chunk would leave a single parent alive and + // make the assertion pass whether or not the pending prefix is detached. + scanTerminalReplyQuerySequences( + `${'x'.repeat(chunkChars)}pty-${index}${splitQueryPrefix}`, + 0, + EMPTY_TERMINAL_REPLY_QUERY_SCAN_STATE + ).state + ) + forceGc() + const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024) + + // The pending prefix must still assemble the query across the boundary. + const pendingStartSeq = states[0]!.pendingStartSeq! + const resumed = scanTerminalReplyQuerySequences( + '$p', + pendingStartSeq + splitQueryPrefix.length, + states[0]! + ) + expect(resumed.queries).toEqual([ + { + data: `${splitQueryPrefix}$p`, + startSeq: pendingStartSeq, + endSeq: pendingStartSeq + splitQueryPrefix.length + 2 + } + ]) + // 8 MiB of source chunks stay alive if the pending prefixes are attached. + expect(retainedMiB).toBeLessThan(2) + }) }) diff --git a/src/shared/terminal-reply-query-scan.ts b/src/shared/terminal-reply-query-scan.ts index 434d37041d7..78eb44ffa45 100644 --- a/src/shared/terminal-reply-query-scan.ts +++ b/src/shared/terminal-reply-query-scan.ts @@ -1,3 +1,4 @@ +import { detachString } from './detached-string' import { findCsiFinalByteIndex } from './terminal-reply-query-extraction' import { parseTerminalOscColorQuery } from './terminal-osc-color-reply' @@ -45,8 +46,11 @@ function isReplyElicitingCsi(sequence: string): boolean { ) } +// Detached: the returned pending prefix is parked in per-stream scan state +// until the next chunk, so an attached slice would pin a whole PTY chunk for +// every buffering terminal stream. function boundedPending(input: string, startIndex: number): string { - return input.slice(startIndex, startIndex + MAX_PENDING_QUERY_CHARS) + return detachString(input.slice(startIndex, startIndex + MAX_PENDING_QUERY_CHARS)) } export function scanTerminalReplyQuerySequences(