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
@@ -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)
})
})