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

`String.prototype.slice` on a long string yields a V8 SlicedString that keeps
a pointer to its parent. Extraction boundaries that park a short result in
long-lived state — an OSC title in pane/tab state, a scan tail in a per-PTY
map, a carry in a per-pane scanner closure — therefore pinned the entire
source PTY chunk for as long as that short string stayed reachable.

Add `detachString` (src/shared/detached-string.ts), which forces V8 to flatten
via `` ` ${value}`.slice(1) ``, and apply it at 22 extraction boundaries across
shared, main, and renderer.

This is memory hygiene, not a crash fix. Production splits renderer-bound PTY
writes at PTY_BATCH_FLUSH_CHUNK_CHARS (16 KiB) and `setRuntimePaneTitle` writes
`[paneId]: title`, an overwrite that releases the previous parent, so live
retention is O(panes) and measures ~0.01 MiB today. It is bounded by that
overwrite, unbounded in principle, and cheap to make robust.

Retention guards resolve a real `gc` binding via `v8.setFlagsFromString`
instead of requiring `--expose-gc`, so they actually run under the plain
`vitest run` CI uses rather than silently skipping. Neutering `detachString`
to `return value` fails all 23 guards.
This commit is contained in:
Neil
2026-07-29 17:32:58 -07:00
parent 32926bc831
commit 1457a4a090
46 changed files with 1135 additions and 43 deletions
@@ -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)
})
})