fix(terminal): close retained string review gaps

This commit is contained in:
Neil
2026-07-29 17:32:58 -07:00
parent 1457a4a090
commit fdd692e895
48 changed files with 103 additions and 290 deletions
+1 -5
View File
@@ -33,9 +33,7 @@ describe('OSC-7 URI extraction', () => {
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.
// Production persists one scan tail per PTY and daemon scanner.
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
itWithGc('does not pin the source chunk behind a carried OSC scan tail', () => {
@@ -46,7 +44,6 @@ describe('OSC-7 URI extraction', () => {
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))
}
@@ -54,7 +51,6 @@ describe('OSC-7 URI extraction', () => {
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)
})
})
+1 -4
View File
@@ -67,10 +67,7 @@ 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.
// Persisted scan tails must not retain their source PTY chunks.
export function extractOscScanTail(input: string, limit: number): string {
const lastOsc = input.lastIndexOf('\x1b]')
const lastEscape = input.endsWith('\x1b') ? input.length - 1 : -1
@@ -34,8 +34,7 @@ describe('TerminalMouseModeMirror', () => {
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.
// Production persists one scan tail per daemon session.
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
itWithGc('does not pin the source chunk behind a carried private-mode tail', () => {
@@ -46,18 +45,15 @@ describe('TerminalMouseModeMirror', () => {
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)
})
})
@@ -93,8 +93,7 @@ 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.
// Persisted per-session tails must not retain their source PTY chunks.
private extractScanTail(input: string): string {
const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b'))
if (start === -1) {
+7 -14
View File
@@ -11,6 +11,7 @@ import {
} from '../../shared/agent-detection'
import { detachString } from '../../shared/detached-string'
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
import { retainTerminalPendingAnsi } from './terminal-pending-ansi'
import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction'
import { parseFileUriPathParts } from '../daemon/osc7-file-uri'
@@ -8927,8 +8928,7 @@ export class OrcaRuntimeService {
}
const appendedLower = appendedText.toLowerCase()
const keywordHit = WAIT_BLOCKED_KEYWORD_PATTERN.test(`${state.keywordCarry}${appendedLower}`)
// Detached: keywordCarry is parked per PTY until the next chunk, so an
// attached slice would pin the whole lowercased chunk per tracked PTY.
// Persisted keyword carries must not retain their source PTY chunks.
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
@@ -32477,7 +32477,6 @@ const WAIT_BLOCKED_KEYWORD_CARRY_CHARS = 31
const MAX_TAIL_LINES = 2000
const MAX_TAIL_CHARS = 256 * 1024
const MAX_TAIL_PARTIAL_CHARS = 4000
const MAX_TAIL_PENDING_ANSI_CHARS = 4096
const DEFAULT_TERMINAL_READ_LIMIT = 120
const MAX_TERMINAL_READ_LIMIT = 2000
const MAX_TERMINAL_PREVIEW_CHARS = 32 * 1024
@@ -34693,13 +34692,16 @@ function normalizeTerminalChunk(
if (char === '\x1b') {
appendTerminalNormalizedSpan(parts, combined, textStart, index)
if (index + 1 >= combined.length) {
return { text: parts.join(''), pendingAnsi: combined.slice(index) }
return {
text: parts.join(''),
pendingAnsi: retainTerminalPendingAnsi(combined.slice(index))
}
}
const parsed = parseAnsiControlSequence(combined, index)
if (!parsed) {
return {
text: parts.join(''),
pendingAnsi: trimPendingAnsiControl(combined.slice(index))
pendingAnsi: retainTerminalPendingAnsi(combined.slice(index))
}
}
if (parsed.kind === 'csi' && isTerminalPreviewLineControl(parsed)) {
@@ -34763,15 +34765,6 @@ function terminalChunkNeedsNormalization(chunk: string): boolean {
return false
}
function trimPendingAnsiControl(value: string): string {
if (value.length <= MAX_TAIL_PENDING_ANSI_CHARS) {
return value
}
const introducer = value.slice(0, Math.min(2, value.length))
const suffixBudget = Math.max(0, MAX_TAIL_PENDING_ANSI_CHARS - introducer.length)
return `${introducer}${value.slice(-suffixBudget)}`
}
function isTerminalPreviewLineControl(parsed: {
final: string
params: string
@@ -59,8 +59,7 @@ describe('orchestration setup completion signal', () => {
})
})
// 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.
// Production persists one carry per running setup.
describe('setup completion scanner retention', () => {
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
@@ -72,22 +71,18 @@ describe('setup completion scanner retention', () => {
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)
})
})
@@ -67,8 +67,7 @@ export function createSetupCompletionScanner(
return
}
}
// Detached: the carry is held per running setup until its next chunk, so
// an attached slice would pin a whole setup-output chunk each.
// Persisted setup carries must not retain their source output chunks.
carry = detachString(combined.slice(-SETUP_COMPLETION_CARRY_LENGTH))
}
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { createForceGc, resolveForcedGc } from '../../shared/forced-gc-for-retention-tests'
import { retainTerminalPendingAnsi } from './terminal-pending-ansi'
describe('retainTerminalPendingAnsi', () => {
it('bounds an incomplete control while preserving its introducer and suffix', () => {
expect(retainTerminalPendingAnsi(`\x1b]${'x'.repeat(5000)}`)).toBe(`\x1b]${'x'.repeat(4094)}`)
})
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
itWithGc('does not pin PTY chunks behind pending ANSI state', () => {
const forceGc = createForceGc(forcedGc!)
forceGc()
const before = process.memoryUsage().heapUsed
const held = Array.from({ length: 512 }, (_unused, index) => {
const chunk = `${'x'.repeat(16 * 1024)}\x1b]0;partial-${index}`
return retainTerminalPendingAnsi(chunk.slice(chunk.lastIndexOf('\x1b')))
})
forceGc()
const retainedMiB = (process.memoryUsage().heapUsed - before) / (1024 * 1024)
expect(held[0]).toBe('\x1b]0;partial-0')
expect(retainedMiB).toBeLessThan(2)
})
})
+12
View File
@@ -0,0 +1,12 @@
import { detachString } from '../../shared/detached-string'
const MAX_TERMINAL_PENDING_ANSI_CHARS = 4096
export function retainTerminalPendingAnsi(value: string): string {
if (value.length <= MAX_TERMINAL_PENDING_ANSI_CHARS) {
return detachString(value)
}
const introducer = value.slice(0, Math.min(2, value.length))
const suffixBudget = MAX_TERMINAL_PENDING_ANSI_CHARS - introducer.length
return detachString(`${introducer}${value.slice(-suffixBudget)}`)
}
+1 -4
View File
@@ -243,8 +243,7 @@ describe('AgentDetector', () => {
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.
// Production persists one meaningful-content tail per PTY.
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
itWithGc('does not pin source chunks behind carried per-PTY scan tails', () => {
@@ -260,14 +259,12 @@ describe('AgentDetector', () => {
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)
})
})
+1 -2
View File
@@ -286,8 +286,7 @@ 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.
// Persisted per-PTY tails must not retain their source chunks.
function trimMeaningfulContentScanTail(value: string): string {
if (value.length <= MEANINGFUL_CONTENT_SCAN_TAIL_LIMIT) {
return detachString(value)
@@ -21,10 +21,10 @@ import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal
import { isWorktreeRemovalFenceError } from '../../../../shared/worktree-removal-fence-error'
import { parseTerminalOscColorQuery } from '../../../../shared/terminal-osc-color-reply'
import {
HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS,
containsCsiRendererQuery,
containsStatefulRendererQuery,
extractHiddenStartupRendererQueryData,
extractHiddenStartupRendererQueryPending,
findCsiFinalByteIndex,
isStatefulRendererReplyCsiQuery,
isStatelessRendererReplyCsiQuery
@@ -5224,8 +5224,7 @@ export function connectPanePty(
return ''
}
}
// 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.
// Persisted per-pane tails must not retain their source PTY chunks.
return detachString(tail.slice(-TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS))
}
@@ -6260,7 +6259,7 @@ export function connectPanePty(
if (input.startsWith('\x1b[')) {
const finalByteIndex = findCsiFinalByteIndex(input, 2)
if (finalByteIndex === -1) {
nextPending = input.slice(0, HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS)
nextPending = extractHiddenStartupRendererQueryPending(input, 0)
consumedInputChars = input.length
} else {
const sequence = input.slice(0, finalByteIndex + 1)
@@ -6274,7 +6273,7 @@ export function connectPanePty(
} else if (input.startsWith('\x1b]')) {
const query = parseTerminalOscColorQuery(input, 0)
if (query.kind === 'partial') {
nextPending = input.slice(0, HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS)
nextPending = extractHiddenStartupRendererQueryPending(input, 0)
consumedInputChars = input.length
} else if (query.kind === 'match') {
oscColorQueryData = input.slice(0, query.endIndex)
@@ -2,28 +2,12 @@ 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.
//
// This guards 16 KiB frame detachment, not production's O(panes) overwrite model.
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
@@ -36,7 +20,6 @@ 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) {
@@ -46,7 +29,6 @@ describeWithGc('renderer PTY title retention', () => {
}
})
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))
}
@@ -54,7 +36,6 @@ describeWithGc('renderer PTY title retention', () => {
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`
@@ -62,8 +43,6 @@ describeWithGc('renderer PTY title retention', () => {
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,10 +5,7 @@ import {
pasteTerminalText,
sanitizeTerminalPasteText
} from './terminal-bracketed-paste'
import {
createForceGc,
resolveForcedGc
} from '../../../../shared/forced-gc-for-retention-tests'
import { createForceGc, resolveForcedGc } from '../../../../shared/forced-gc-for-retention-tests'
function createTerminal(bracketedPasteMode = true) {
const terminal = {
@@ -257,8 +254,7 @@ describe('terminal bracketed paste policy', () => {
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.
// Production persists one interrupted-mode tail per terminal.
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
itWithGc('does not pin the source chunk behind the interrupted-pane mode tail', () => {
@@ -266,8 +262,7 @@ describe('terminal bracketed paste policy', () => {
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.
// Avoid mock bookkeeping that would swamp the retention signal.
const terminals = Array.from({ length: panes }, () => ({
modes: { bracketedPasteMode: true }
}))
@@ -276,14 +271,12 @@ describe('terminal bracketed paste policy', () => {
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)
})
})
@@ -99,8 +99,7 @@ export function observeTerminalBracketedPasteModeOutput(
return
}
const combined = (bracketedPasteModeOutputTail.get(terminal) ?? '') + data
// 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.
// Persisted per-terminal tails must not retain their source PTY chunks.
bracketedPasteModeOutputTail.set(
terminal,
detachString(combined.slice(-BRACKETED_PASTE_MODE_TAIL_MAX))
@@ -406,13 +406,7 @@ describe('nativeWindowsRewriteNeedsFollowupRenderRefresh', () => {
})
})
// 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.
// The ≥13-char control arm proves detachment for one persisted rewrite tail per pane.
describe('rewrite CSI scan tail retention', () => {
const splitCsi = '\x1b[1;2;3;4;5;6;7;8;9'
const forcedGc = resolveForcedGc()
@@ -427,8 +421,6 @@ describe('rewrite CSI scan tail retention', () => {
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: '' }
@@ -437,7 +429,6 @@ describe('rewrite CSI scan tail retention', () => {
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', {
@@ -445,7 +436,6 @@ describe('rewrite CSI scan tail retention', () => {
previousRewriteCsiScanTail: tails[0]!
}).prefersRenderRefresh
).toBe(true)
// 8 MiB of source chunks stay alive if the carried tails are attached.
expect(retainedMiB).toBeLessThan(2)
})
})
@@ -141,9 +141,7 @@ 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.
// Persisted per-pane rewrite tails must not retain their source PTY chunks.
function trailingIncompleteRewriteCsiTail(data: string): string {
const escapeIndex = data.lastIndexOf(ESCAPE_CHARACTER)
if (escapeIndex === -1) {
@@ -190,11 +190,7 @@ 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.
// Only truncated values can retain oversized payloads; preserve hot-path identity otherwise.
const truncated = truncatePreservingSurrogates(value, maxLength)
if (truncated.length === 0) {
return undefined
+1 -5
View File
@@ -35,9 +35,7 @@ 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.
// Production persists one unterminated payload per pane.
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
itWithGc('does not pin the source chunk behind a carried OSC 9999 payload', () => {
@@ -48,14 +46,12 @@ describe('createAgentStatusOscProcessor', () => {
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)
})
+1 -2
View File
@@ -71,8 +71,7 @@ export function createAgentStatusOscProcessor(): (data: string) => ProcessedAgen
if (terminator === null) {
const candidate = combined.slice(start)
// Detached: `pending` survives until the next chunk, so an attached
// slice would pin this whole chunk per processor (one per pane).
// Persisted per-pane payloads must not retain their source PTY chunks.
pending = candidate.length > MAX_PENDING ? '' : detachString(candidate)
break
}
+2 -7
View File
@@ -242,8 +242,7 @@ 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.
// The store caches capped interactive prompts.
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
itWithGc('does not pin the oversized payload behind a capped interactivePrompt', () => {
@@ -262,13 +261,10 @@ Fix dispatch fallback preview for normalized status prompts`
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.
// Under-cap prompts stay on the allocation-free hook path.
itWithGc('returns an under-cap interactivePrompt without copying it', () => {
const source = JSON.stringify({
questions: [{ question: 'Which approach?', options: ['a'.repeat(4000), 'b'.repeat(4000)] }]
@@ -289,7 +285,6 @@ Fix dispatch fallback preview for normalized status prompts`
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)
})
+1 -6
View File
@@ -157,10 +157,7 @@ describe('Codex subagent transcript reconciliation', () => {
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.
// Production persists one partial line per transcript cursor.
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
itWithGc('does not pin the read buffer behind a carried partial line', () => {
@@ -172,7 +169,6 @@ describe('Codex subagent transcript reconciliation', () => {
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 }
})
@@ -186,7 +182,6 @@ describe('Codex subagent transcript reconciliation', () => {
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)
})
})
+1 -3
View File
@@ -78,9 +78,7 @@ 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
// 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.
// Persisted cursor carries must not retain the transcript read buffer.
cursor.carry = detachString(lines.pop() ?? '')
if (skippedPrefix) {
lines.shift()
@@ -464,8 +464,7 @@ describe('terminal control stripping', () => {
}
})
// 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.
// Every pane persists this ring before Command Code detection.
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
itWithGc('does not pin the source chunk behind the recent raw-text ring', () => {
@@ -484,7 +483,6 @@ describe('terminal control stripping', () => {
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)
})
})
+1 -4
View File
@@ -137,10 +137,7 @@ 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.
// Every pane persists this ring before Command Code detection.
function appendRecentRawText(previousRawText: string, data: string): string {
if (data.length >= RECENT_TEXT_LIMIT) {
return detachString(data.slice(-RECENT_TEXT_LIMIT))
+1 -10
View File
@@ -20,21 +20,13 @@ describe('detachString', () => {
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.
// A ≥13-char raw control arm proves both SlicedString retention and default-run GC.
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()
@@ -52,7 +44,6 @@ describe('detachString', () => {
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)
})
+1 -12
View File
@@ -1,15 +1,4 @@
/**
* 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.
*/
/** Break V8 SlicedString parent retention by forcing a fresh flat string. */
export function detachString(value: string): string {
if (value.length === 0) {
return ''
+1 -7
View File
@@ -138,9 +138,7 @@ 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.
// Production persists two 512-char rings per startup-draft waiter.
describe('draft paste scanner retention', () => {
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
@@ -152,17 +150,13 @@ describe('draft paste scanner retention', () => {
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)
})
})
+1 -3
View File
@@ -62,9 +62,7 @@ export function createDraftPasteReadyScanner(readySignal: DraftPasteReadySignal)
return {
observe(data: string): DraftPasteReadyScanResult {
const combined = recent + data
// 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.
// Persisted scanner rings must not retain their source PTY chunks.
recent = detachString(combined.slice(-RING_CHARS))
if (!saw2004) {
const markerIndex = combined.indexOf(DECSET_BRACKETED_PASTE)
+2 -13
View File
@@ -1,18 +1,7 @@
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.
*/
/** Resolve forced GC under plain Vitest; hardened runtimes may return null. */
export function resolveForcedGc(): (() => void) | null {
const existing = (globalThis as { gc?: () => void }).gc
if (typeof existing === 'function') {
@@ -28,7 +17,7 @@ export function resolveForcedGc(): (() => void) | null {
}
}
/** Double-collect so a freshly unreachable parent is actually released. */
/** Collect twice so freshly unreachable parents are released. */
export function createForceGc(gc: () => void): () => void {
return () => {
gc()
@@ -2,31 +2,17 @@ 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.
//
// This guards 16 KiB chunk detachment, not a production OOM reproduction.
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`
}
@@ -36,7 +22,6 @@ describeWithGc('OSC title retention', () => {
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)
}
@@ -44,7 +29,6 @@ describeWithGc('OSC title retention', () => {
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`
@@ -52,8 +36,6 @@ describeWithGc('OSC title retention', () => {
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)
})
})
+1 -3
View File
@@ -61,9 +61,7 @@ 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.
// Extracted titles must not retain their source PTY chunks.
const length = titleEnd - titleStart
if (length <= MAX_OSC_TITLE_CHARS) {
return detachString(data.slice(titleStart, titleEnd))
+1 -5
View File
@@ -16,16 +16,13 @@ describe('extractOscTitleScanTail', () => {
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.
// Production persists one title scan tail per 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}`
@@ -40,7 +37,6 @@ describe('extractOscTitleScanTail', () => {
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)
})
})
+1 -4
View File
@@ -4,10 +4,7 @@ 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.
// Persisted per-PTY tails must not retain their source chunks.
export function extractOscTitleScanTail(input: string): string {
const lastOsc = input.lastIndexOf('\x1b]')
if (lastOsc !== -1) {
@@ -53,8 +53,7 @@ describe('terminal color scheme protocol', () => {
})
})
// 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.
// Production persists one private-mode tail per pane.
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
itWithGc('does not pin the source chunk behind a carried private-mode tail', () => {
@@ -65,14 +64,12 @@ describe('terminal color scheme protocol', () => {
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)
})
})
+1 -2
View File
@@ -132,8 +132,7 @@ 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.
// Persisted per-pane tails must not retain their source PTY chunks.
function extractPrivateModeScanTail(input: string): string {
const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b'))
if (start === -1) {
@@ -293,9 +293,7 @@ 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.
// Production persists one URL carry per pane or PTY.
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
itWithGc('does not pin the source chunk behind a carried PR-URL prefix', () => {
@@ -306,14 +304,12 @@ describe('createTerminalGitHubPRLinkDetector', () => {
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',
@@ -321,7 +317,6 @@ describe('createTerminalGitHubPRLinkDetector', () => {
number: 12
}
])
// 8 MiB of source chunks stay alive if the carries are still attached.
expect(retainedMiB).toBeLessThan(2)
})
})
@@ -66,8 +66,7 @@ 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.
// Long URL carries must not retain their source PTY chunk.
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.
@@ -83,11 +82,11 @@ function getPotentialGitHubPRCarry(value: string): string {
const fragment = endsWithHttpSchemePrefixFragment(tailWindow)
if (fragment === '' || windowStart === 0) {
return detachString(fragment)
return 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 ? detachString(fragment) : ''
return lastIndexOfHttpScheme(value, windowStart - 1) === -1 ? fragment : ''
}
function hasTerminalUrlWhitespace(value: string, start: number, end: number): boolean {
@@ -149,12 +149,7 @@ describe('TerminalKittyKeyboardModeTracker', () => {
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.
// The ≥13-char control arm proves detachment for one persisted tail per tracker.
const splitPrivateModeTail = '\x1b[?1049;2004;2026;1234'
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
@@ -167,24 +162,19 @@ describe('TerminalKittyKeyboardModeTracker', () => {
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.
// Replay consumes whole reattach snapshots rather than 16 KiB live chunks.
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!)
@@ -200,7 +190,6 @@ describe('TerminalKittyKeyboardModeTracker', () => {
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)
})
})
@@ -171,10 +171,7 @@ 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.
// Persisted tracker tails must not retain live chunks or replay snapshots.
private extractScanTail(input: string): string {
const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b'))
if (start === -1) {
@@ -34,8 +34,7 @@ describe('createOsc133CommandFinishedScanner', () => {
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.
// Production persists one unterminated payload per pane scanner.
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
itWithGc('does not pin the source chunk behind a carried OSC 133 payload', () => {
@@ -47,17 +46,14 @@ describe('createOsc133CommandFinishedScanner', () => {
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)
})
})
@@ -90,8 +90,7 @@ export function createOsc133CommandFinishedScanner(
const terminator = findOscTerminator(combined, payloadStart)
if (!terminator) {
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.
// Persisted per-pane carries must not retain their source PTY chunks.
carry = detachString(
pending.length > MAX_OSC_CARRY_LENGTH
? pending.slice(pending.length - MAX_OSC_CARRY_LENGTH)
@@ -85,9 +85,7 @@ describe('advancePartialEscapeTail', () => {
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.
// Production persists and snapshots one partial escape tail per emulator.
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
itWithGc('does not pin the written stream behind a carried escape tail', () => {
@@ -97,16 +95,13 @@ describe('advancePartialEscapeTail', () => {
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)
})
})
+1 -4
View File
@@ -147,9 +147,6 @@ 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)
// 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.
// Persisted emulator tails must not retain the whole written stream.
return tail.length > MAX_PARTIAL_ESCAPE_TAIL_LENGTH ? '' : detachString(tail)
}
@@ -30,13 +30,7 @@ describe('extractHiddenStartupRendererQueryData', () => {
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.
// The ≥13-char control arm proves detachment for one persisted query per pane.
const splitCsi = '\x1b[?1049;2004;2026'
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
@@ -50,8 +44,6 @@ describe('extractHiddenStartupRendererQueryData', () => {
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}`,
''
@@ -60,12 +52,10 @@ describe('extractHiddenStartupRendererQueryData', () => {
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)
})
})
@@ -16,10 +16,8 @@ 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 {
// Pending query state must not retain the source PTY chunk.
export function extractHiddenStartupRendererQueryPending(input: string, start: number): string {
return detachString(input.slice(start, start + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS))
}
@@ -53,7 +51,7 @@ export function extractHiddenStartupRendererQueryData(
statelessQueryData,
statefulQueryData,
oscColorQueryData,
pending: detachedQueryPending(input, candidateIndex)
pending: extractHiddenStartupRendererQueryPending(input, candidateIndex)
}
}
const sequence = input.slice(candidateIndex, finalByteIndex + 1)
@@ -73,7 +71,7 @@ export function extractHiddenStartupRendererQueryData(
statelessQueryData,
statefulQueryData,
oscColorQueryData,
pending: detachedQueryPending(input, candidateIndex)
pending: extractHiddenStartupRendererQueryPending(input, candidateIndex)
}
}
if (query.kind === 'none') {
+1 -12
View File
@@ -40,13 +40,7 @@ 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.
// The ≥13-char control arm proves detachment for one persisted mobile-stream query.
const splitQueryPrefix = '\x1b[?1049;2004;2026'
const forcedGc = resolveForcedGc()
const itWithGc = forcedGc ? it : it.skip
@@ -60,9 +54,6 @@ describe('terminal reply query scan', () => {
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,
@@ -72,7 +63,6 @@ describe('terminal reply query scan', () => {
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',
@@ -86,7 +76,6 @@ describe('terminal reply query scan', () => {
endSeq: pendingStartSeq + splitQueryPrefix.length + 2
}
])
// 8 MiB of source chunks stay alive if the pending prefixes are attached.
expect(retainedMiB).toBeLessThan(2)
})
})
+1 -3
View File
@@ -46,9 +46,7 @@ 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.
// Persisted query prefixes must not retain their source PTY chunks.
function boundedPending(input: string, startIndex: number): string {
return detachString(input.slice(startIndex, startIndex + MAX_PENDING_QUERY_CHARS))
}