Files
orca/src/shared/draft-paste-ready-scanner.test.ts
T
Neil 1457a4a090 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.
2026-07-29 17:32:58 -07:00

169 lines
7.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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'
const HIDE_CURSOR = '\x1b[?25l'
const CODEX_PROMPT = '\x1b[1m›\x1b[0m Ask Codex to do anything'
describe('createDraftPasteReadyScanner', () => {
describe('render-cursor-after-bracketed-paste (opencode / mimo-code)', () => {
it('is ready when show-cursor renders after bracketed paste in one chunk', () => {
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${SHOW_CURSOR}`)).toEqual({
ready: true,
armQuietTimer: false
})
})
it('does not fire on bracketed paste alone, then fires once show-cursor arrives', () => {
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
// Why: opencode enables bracketed paste ~1.5-2s before its composer mounts
// and stays SILENT in between. The cursor gates delivery and must NOT arm
// the quiet window, which would otherwise fire during that silent gap and
// paste before the composer exists.
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
ready: false,
armQuietTimer: false
})
expect(scanner.observe('startup banner output')).toEqual({
ready: false,
armQuietTimer: false
})
expect(scanner.observe(SHOW_CURSOR)).toEqual({ ready: true, armQuietTimer: false })
})
it('resolves from a single replayed buffer holding both markers (SSH/remote replay path)', () => {
// Why: the runtime waiter feeds recentPtyOutputById as one observe() call
// when the agent emitted 2004 + show-cursor before the subscription
// attached; a single combined buffer must still resolve.
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
expect(
scanner.observe(`banner\n${DECSET_BRACKETED_PASTE}composer\n${SHOW_CURSOR}rest`)
).toEqual({ ready: true, armQuietTimer: false })
})
it('detects a bracketed-paste handshake split across a chunk boundary', () => {
// Why: the pre-handshake `recent` ring must reassemble a \x1b[?2004h that
// straddles two PTY packets, or cursor-gated readiness breaks for
// fragmented startup output.
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
expect(scanner.observe('\x1b[?20')).toEqual({ ready: false, armQuietTimer: false })
expect(scanner.observe('04h')).toEqual({ ready: false, armQuietTimer: false })
expect(scanner.observe(SHOW_CURSOR)).toEqual({ ready: true, armQuietTimer: false })
})
it('detects show-cursor split across a later chunk boundary', () => {
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
scanner.observe(DECSET_BRACKETED_PASTE)
// The escape sequence is split mid-bytes across two separate chunks.
expect(scanner.observe('render noise \x1b[?')).toEqual({ ready: false, armQuietTimer: false })
expect(scanner.observe('25h')).toEqual({ ready: true, armQuietTimer: false })
})
it('never arms the quiet window during the silent pre-composer gap', () => {
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
scanner.observe(DECSET_BRACKETED_PASTE)
// Why: opencode is silent here; arming the quiet window would fire before
// the composer mounts and pre-empt the cursor signal (the original bug).
// Delivery waits for show-cursor, bounded by the caller's hard timeout.
for (let i = 0; i < 5; i += 1) {
expect(scanner.observe(`setup output ${i}`)).toEqual({ ready: false, armQuietTimer: false })
}
})
it('does not treat hide-cursor as the ready signal', () => {
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
// \x1b[?25l (hide) must not be mistaken for \x1b[?25h (show).
expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${HIDE_CURSOR}`)).toEqual({
ready: false,
armQuietTimer: false
})
})
it('ignores show-cursor that appears before bracketed paste is enabled', () => {
const scanner = createDraftPasteReadyScanner('render-cursor-after-bracketed-paste')
// A pre-handshake cursor toggle must not trip readiness.
expect(scanner.observe(SHOW_CURSOR)).toEqual({ ready: false, armQuietTimer: false })
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
ready: false,
armQuietTimer: false
})
})
})
describe('codex-composer-prompt (unchanged behavior)', () => {
it('is ready on the composer glyph after bracketed paste and never arms the quiet timer', () => {
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
ready: false,
armQuietTimer: false
})
expect(scanner.observe(CODEX_PROMPT)).toEqual({ ready: true, armQuietTimer: false })
})
it('detects the composer glyph inside a large first render chunk', () => {
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
expect(scanner.observe(`${DECSET_BRACKETED_PASTE}${CODEX_PROMPT}${'x'.repeat(900)}`)).toEqual(
{ ready: true, armQuietTimer: false }
)
})
it('never arms the quiet-window fallback', () => {
const scanner = createDraftPasteReadyScanner('codex-composer-prompt')
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({
ready: false,
armQuietTimer: false
})
expect(scanner.observe('noise')).toEqual({ ready: false, armQuietTimer: false })
})
})
describe('render-quiet-after-bracketed-paste (default)', () => {
it('arms the quiet timer after bracketed paste and never reports a signal', () => {
const scanner = createDraftPasteReadyScanner('render-quiet-after-bracketed-paste')
expect(scanner.observe(DECSET_BRACKETED_PASTE)).toEqual({ ready: false, armQuietTimer: true })
// Show-cursor is not a signal for the default path; it just keeps arming.
expect(scanner.observe(SHOW_CURSOR)).toEqual({ ready: false, armQuietTimer: true })
})
it('does nothing until bracketed paste is enabled', () => {
const scanner = createDraftPasteReadyScanner('render-quiet-after-bracketed-paste')
expect(scanner.observe('pre-handshake output')).toEqual({
ready: false,
armQuietTimer: false
})
})
})
})
// 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)
})
})