mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
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:
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 ''
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user