perf(terminal): detach retained PTY slice strings from their source chunks

`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.
This commit is contained in:
Neil
2026-07-29 17:32:58 -07:00
parent 32926bc831
commit 1457a4a090
46 changed files with 1135 additions and 43 deletions
@@ -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<string, string>()
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)
})
})
+7 -1
View File
@@ -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))
}
@@ -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)
})
})
@@ -1,3 +1,4 @@
import { detachString } from '../../shared/detached-string'
import type { TerminalModes } from './types'
type MouseTrackingMode = NonNullable<TerminalModes['mouseTrackingMode']>
@@ -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 ''
}
+4 -1
View File
@@ -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.
@@ -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)
})
})
@@ -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))
}
}
}
+29
View File
@@ -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)
})
})
+5 -2
View File
@@ -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)}`)
}
@@ -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 {
@@ -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)
})
})
@@ -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)
})
})
@@ -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)
@@ -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)
})
})
@@ -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 {
+10 -1
View File
@@ -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 {
+26
View File
@@ -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)
})
})
+4 -1
View File
@@ -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
}
+52
View File
@@ -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(
@@ -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)
})
})
+5 -1
View File
@@ -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()
}
@@ -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)
})
})
+7 -2
View File
@@ -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 {
+59
View File
@@ -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)
})
})
+18
View File
@@ -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)
}
@@ -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)
})
})
+8 -3
View File
@@ -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
@@ -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()
}
}
@@ -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)
})
})
+8 -3
View File
@@ -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)
)
}
+29
View File
@@ -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<string, string>()
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)
})
})
+8 -2
View File
@@ -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)}`)
}
@@ -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)
})
})
+5 -2
View File
@@ -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 ''
}
@@ -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)
})
})
@@ -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 {
@@ -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[<u')
expect(ranAndExited.flags).toBe(0)
})
// One tracker per pane (renderer) and per PTY (main) holds the scan tail
// until the next chunk, so an attached slice pins a whole PTY chunk each.
//
// Why the tail 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.
const splitPrivateModeTail = '\x1b[?1049;2004;2026;1234'
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
itWithGc('does not pin the source chunk behind a carried kitty scan tail', () => {
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)
})
})
@@ -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 {
@@ -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)
})
})
+10 -4
View File
@@ -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
}
@@ -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)
})
})
+7 -1
View File
@@ -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)
}
@@ -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)
})
})
+10 -8
View File
@@ -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') {
@@ -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)
})
})
+5 -1
View File
@@ -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(