From a7ffb244e45fee0cb75a129aaa726ce7a2f68845 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:23:57 -0700 Subject: [PATCH] perf(terminal): bound the reattach payload agent-signal scan (#12681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hasCursorAgentReattachPayloadScreenSignal built a char-by-char copy of the entire reattach payload so it could read the last header plus 5000 chars. On a 2MB daemon snapshot that cost 17.5ms of synchronous renderer main-thread work — ~75% of what xterm then spends parsing the same bytes — and the miss case paid it in full for a result that is always false. Two changes, both matching existing in-tree precedent: bound the scan to a 256KB tail (as the kitty tracker already bounds its own scan), and strip via the shared precompiled CSI_SEQUENCE_PATTERN instead of a hand-rolled loop, which is also faster in V8 because it copies spans rather than building a rope per character. 2MB snapshot, header hit 17.5ms -> 0.80ms (22x) 2MB snapshot, miss 8.7ms -> 0.52ms (17x) 200KB snapshot, header hit 1.5ms -> 0.62ms (2.4x) config/scripts/terminal-reattach-payload-scan-benchmark.mjs reproduces this and asserts every candidate agrees with the baseline before timing it. It also records a negative result: porting the daemon mouse mirror's includes() pre-filter to the kitty tracker makes reattach slower, because snapshots always contain the introducer. Adds guards for the two behaviours a future shortcut would silently break: a CSI-split header must still match, and a header behind the tail bound must not. Also byte-pins POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, which shipped unpinned. Co-authored-by: Orca --- ...rminal-reattach-payload-scan-benchmark.mjs | 274 ++++++++++++++++++ .../terminal-pane/pty-connection.test.ts | 75 +++++ .../terminal-pane/pty-connection.ts | 34 +-- .../terminal-mode-reset-profiles.test.ts | 12 + 4 files changed, 373 insertions(+), 22 deletions(-) create mode 100644 config/scripts/terminal-reattach-payload-scan-benchmark.mjs diff --git a/config/scripts/terminal-reattach-payload-scan-benchmark.mjs b/config/scripts/terminal-reattach-payload-scan-benchmark.mjs new file mode 100644 index 000000000..fedfdc2c6 --- /dev/null +++ b/config/scripts/terminal-reattach-payload-scan-benchmark.mjs @@ -0,0 +1,274 @@ +#!/usr/bin/env node +// Benchmark: the renderer-side scans that run over a whole reattach payload. +// +// 1. hasCursorAgentReattachPayloadScreenSignal — compares the old hand-rolled char-by-char +// CSI strip against the shipped shape (256KB tail + shared CSI_SEQUENCE_PATTERN). Every +// variant is asserted to agree with the baseline before it is timed. +// 2. TerminalKittyKeyboardModeTracker.scanReplay — measured to justify leaving it alone, and +// to record that porting the daemon mouse mirror's includes() pre-filter makes it slower. +// +// Payloads are generated deterministically (LCG, no Math.random) so runs compare. +// +// Run with: node config/scripts/terminal-reattach-payload-scan-benchmark.mjs +import { performance } from 'node:perf_hooks' +import { CSI_SEQUENCE_PATTERN } from '../../src/shared/ansi-escape-sequences.ts' +import { TerminalKittyKeyboardModeTracker } from '../../src/shared/terminal-kitty-keyboard-mode-tracker.ts' + +const ROUNDS = Number(process.env.ORCA_REATTACH_SCAN_BENCH_ROUNDS ?? '7') +const MIN_ITERATION_MS = 120 + +// --- payload generation ----------------------------------------------------- + +function makeRng(seed) { + let state = seed >>> 0 + return () => { + state = (Math.imul(state, 1664525) + 1013904223) >>> 0 + return state / 0x100000000 + } +} + +const WORDS = [ + 'src', + 'renderer', + 'components', + 'terminal', + 'pane', + 'connection', + 'reattach', + 'payload', + 'snapshot', + 'daemon', + 'passed', + 'failed', + 'warning', + 'building', + 'index.ts', + '1.24s', + 'ok' +] + +// Heavily SGR-colored scrollback, ~120 cols/line — what a serialized daemon snapshot +// of a colorized TUI/build log looks like. +function buildColoredScrollback(targetBytes, seed) { + const rng = makeRng(seed) + const lines = [] + let size = 0 + while (size < targetBytes) { + let line = '' + let visible = 0 + while (visible < 118) { + const word = WORDS[Math.floor(rng() * WORDS.length)] + const color = 16 + Math.floor(rng() * 200) + line += `\x1b[38;5;${color}m${word}\x1b[0m ` + visible += word.length + 1 + } + lines.push(line) + size += line.length + 1 + } + return lines.join('\r\n') +} + +// Live TUIs also emit cursor moves / erases; keep some of those in the mix. +function withCursorTraffic(body, seed) { + const rng = makeRng(seed) + return body + .split('\r\n') + .map((line, i) => `\x1b[${(i % 40) + 1};1H\x1b[K${line}${rng() < 0.1 ? '\x1b[?25l' : ''}`) + .join('\r\n') +} + +const CURSOR_AGENT_SCREEN = [ + '\x1b[38;5;39mCursor Agent\x1b[0m', + '', + ' \x1b[2mReady\x1b[0m', + '', + '\x1b[38;5;245m→ \x1b[0m' +].join('\r\n') + +function buildPayloads() { + const base200k = buildColoredScrollback(200 * 1024, 1) + const base2m = buildColoredScrollback(2 * 1024 * 1024, 2) + const relayTail = buildColoredScrollback(100 * 1024, 3) + return [ + { name: '200KB snapshot, header hit (tail)', data: `${base200k}\r\n${CURSOR_AGENT_SCREEN}` }, + { name: '200KB snapshot, no header (miss)', data: base200k }, + { name: '2MB snapshot, header hit (tail)', data: `${base2m}\r\n${CURSOR_AGENT_SCREEN}` }, + { name: '2MB snapshot, no header (miss)', data: base2m }, + { name: '100KB relay tail, cursor traffic', data: withCursorTraffic(relayTail, 4) } + ] +} + +// --- strip variants --------------------------------------------------------- + +// Baseline: verbatim copy of pty-connection.ts:448 (not exported). +function stripBaseline(data) { + let normalized = '' + let index = 0 + while (index < data.length) { + if (data.charCodeAt(index) === 0x1b && data[index + 1] === '[') { + index += 2 + while (index < data.length) { + const code = data.charCodeAt(index) + index += 1 + if (code >= 0x40 && code <= 0x7e) { + break + } + } + continue + } + normalized += data[index] + index += 1 + } + return normalized +} + +const stripRegex = (data) => data.replace(CSI_SEQUENCE_PATTERN, '') + +const HEADER = 'Cursor Agent' +const TAIL_CHARS = 5000 +// Mirrors CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS in pty-connection.ts. +const SUFFIX_RAW_CHARS = 256 * 1024 + +const signalBaseline = (data) => hasSignal(stripBaseline(data)) +const signalRegex = (data) => hasSignal(stripRegex(data)) +// The shipped shape. +const signalSuffixRegex = (data) => + hasSignal(stripRegex(data.length > SUFFIX_RAW_CHARS ? data.slice(-SUFFIX_RAW_CHARS) : data)) + +function hasSignal(normalized) { + const headerIndex = normalized.lastIndexOf(HEADER) + if (headerIndex === -1) { + return false + } + return normalized.slice(headerIndex + HEADER.length, headerIndex + TAIL_CHARS).includes(`${'→'} `) +} + +// --- timing ----------------------------------------------------------------- + +function median(values) { + const sorted = [...values].sort((a, b) => a - b) + const mid = sorted.length >> 1 + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2 +} + +function timeMsPerCall(fn, arg) { + let iterations = 1 + while (true) { + const start = performance.now() + for (let i = 0; i < iterations; i++) { + globalThis.__sink = fn(arg) + } + const elapsed = performance.now() - start + if (elapsed >= MIN_ITERATION_MS || iterations >= 1 << 22) { + return elapsed / iterations + } + iterations *= Math.max(2, Math.ceil(MIN_ITERATION_MS / Math.max(elapsed, 0.01))) + } +} + +function measure(fn, arg) { + timeMsPerCall(fn, arg) // warmup / JIT + const samples = [] + for (let round = 0; round < ROUNDS; round++) { + samples.push(timeMsPerCall(fn, arg)) + } + return median(samples) +} + +const pad = (value, width) => String(value).padStart(width) +const kb = (data) => `${(data.length / 1024).toFixed(0)}KB` + +// --- run -------------------------------------------------------------------- + +const payloads = buildPayloads() + +console.log('Reattach payload scans, ms/call (median of %d rounds). Lower is better.\n', ROUNDS) +console.log('== hasCursorAgentReattachPayloadScreenSignal ==') +console.log( + `${pad('payload', 36)} ${pad('size', 8)} ${pad('char-loop', 11)} ${pad('regex', 10)} ${pad('suffix+regex', 13)} ${pad('speedup', 9)}` +) +for (const { name, data } of payloads) { + const expected = signalBaseline(data) + for (const [label, fn] of Object.entries({ + regex: signalRegex, + 'suffix+regex': signalSuffixRegex + })) { + if (fn(data) !== expected) { + throw new Error(`${label} disagrees with baseline on "${name}"`) + } + } + const base = measure(signalBaseline, data) + const re = measure(signalRegex, data) + const suffix = measure(signalSuffixRegex, data) + console.log( + `${pad(name, 36)} ${pad(kb(data), 8)} ${pad(base.toFixed(3), 11)} ${pad(re.toFixed(3), 10)} ${pad(suffix.toFixed(3), 13)} ${pad(`${(base / suffix).toFixed(0)}x`, 9)}` + ) +} + +console.log('\n== TerminalKittyKeyboardModeTracker.scanReplay ==') +console.log( + `${pad('payload', 36)} ${pad('size', 8)} ${pad('scanReplay', 11)} ${pad('+includes gate', 15)}` +) +const kittyScan = (data) => { + const tracker = new TerminalKittyKeyboardModeTracker() + tracker.scanReplay(data) + return tracker.flags +} +// Mirrors src/main/daemon/terminal-mouse-mode-mirror.ts:41-47. +const kittyGated = (data) => { + if (!data.includes('\x1b[?') && !data.includes('\x1bc') && !data.includes('\x9b')) { + return 0 + } + return kittyScan(data) +} +for (const { name, data } of payloads) { + console.log( + `${pad(name, 36)} ${pad(kb(data), 8)} ${pad(measure(kittyScan, data).toFixed(3), 11)} ${pad(measure(kittyGated, data).toFixed(3), 15)}` + ) +} + +// The gate can only pay off where no introducer exists: live plain-output chunks, not +// snapshots (which carry ?1049h/?25l by construction). +const liveChunk = buildColoredScrollback(4 * 1024, 5).replaceAll('\x1b', '') +const introducerRows = [ + ['4KB live chunk, no escapes', liveChunk], + ['4KB live chunk, \\x1b[?25l present', `${liveChunk}\x1b[?25l`] +] +console.log( + `\n${pad('payload', 36)} ${pad('size', 8)} ${pad('scanReplay', 11)} ${pad('+includes gate', 15)}` +) +for (const [name, data] of introducerRows) { + console.log( + `${pad(name, 36)} ${pad(kb(data), 8)} ${pad(measure(kittyScan, data).toFixed(3), 11)} ${pad(measure(kittyGated, data).toFixed(3), 15)}` + ) +} + +console.log( + '\nSnapshot/replay payloads always contain \\x1b[?, so the includes() gate never fires on\nthe reattach path — it only helps the live per-chunk path.' +) + +// Reference point: xterm parses the same bytes right after these scans run, so its cost +// is the yardstick for whether the scans are worth cutting. +const xtermHeadless = await import('@xterm/headless') +const Terminal = xtermHeadless.Terminal ?? xtermHeadless.default.Terminal +const writeToXterm = (data) => + new Promise((resolve) => { + const terminal = new Terminal({ cols: 120, rows: 40, scrollback: 5000, allowProposedApi: true }) + const start = performance.now() + terminal.write(data, () => { + const elapsed = performance.now() - start + terminal.dispose() + resolve(elapsed) + }) + }) + +console.log(`\n== xterm headless parse of the same payload (reference) ==`) +console.log(`${pad('payload', 36)} ${pad('size', 8)} ${pad('xterm write', 11)}`) +for (const { name, data } of payloads) { + await writeToXterm(data) + const samples = [] + for (let round = 0; round < ROUNDS; round++) { + samples.push(await writeToXterm(data)) + } + console.log(`${pad(name, 36)} ${pad(kb(data), 8)} ${pad(median(samples).toFixed(3), 11)}`) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 7ad97e6b6..29f693640 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -16427,6 +16427,81 @@ describe('connectPanePty', () => { disposable.dispose() }) + // Why pinned: the classifier strips CSI precisely so a styled header still matches. A future + // "just lastIndexOf the raw bytes" shortcut would pass every other test and silently break this. + it('still detects the Cursor Agent screen when CSI styling splits the header and the marker', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('renamed shell') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + const manager = createManager(1) + const deps = createDeps() + const disposable = await withMockedDocumentActiveElement(textarea, async () => { + const connection = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.( + '\x1b[4;3HCursor \x1b[1mAgent\x1b[0m\x1b[9;3H→\x1b[0m Plan, search, build anything' + ) + await flushAsyncTicks(12) + + expect(pane.terminal.write).toHaveBeenCalledWith( + POST_REPLAY_LIVE_AGENT_REATTACH_RESET, + expect.any(Function) + ) + return connection + }) + disposable.dispose() + }) + + // Why pinned: the scan reads only a bounded tail, so a header buried behind megabytes of + // scrollback describes a finished run, not the current screen. + it('ignores a Cursor Agent screen buried beyond the payload scan tail limit', async () => { + const { connectPanePty } = await import('./pty-connection') + enableActiveRuntimeEnvironment() + const transport = createMockTransport('remote:env-1@@terminal-1') + const capturedReplayCallback: { + current: ((data: string, meta?: { clearBeforeReplay?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedReplayCallback.current = callbacks.onReplayData ?? null + return { id: 'remote:env-1@@terminal-1', replay: '' } + }) + transportFactoryQueue.push(transport) + setReattachPaneTitle('renamed shell') + + const pane = createPane(1) + const textarea = {} as HTMLTextAreaElement + configureTerminalFocusMode(pane, textarea) + const manager = createManager(1) + const deps = createDeps() + const disposable = await withMockedDocumentActiveElement(textarea, async () => { + const connection = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedReplayCallback.current?.( + `${ANSI_POSITIONED_CURSOR_AGENT_REATTACH_SCREEN}${'shell scrollback\r\n'.repeat(20_000)}` + ) + await flushAsyncTicks(12) + + expect(transport.sendInput).not.toHaveBeenCalledWith('\x1b[I') + return connection + }) + disposable.dispose() + }) + it('downgrades a scrollback-only Cursor Agent signal when the parsed viewport shows a shell', async () => { const { connectPanePty } = await import('./pty-connection') enableActiveRuntimeEnvironment() diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index ec3350022..8167a1c49 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -115,6 +115,7 @@ import { queuePanePtyResizeIfHeld, type PanePtyResizeHoldFlushDetail } from '@/lib/pane-manager/pane-pty-resize-hold' +import { CSI_SEQUENCE_PATTERN } from '../../../../shared/ansi-escape-sequences' import { buildPostReplayLiveAgentReattachReset, POST_REPLAY_LIVE_AGENT_SNAPSHOT_RESET, @@ -469,33 +470,22 @@ function terminalOwnsDomFocus(terminal: TerminalWithFocusMode): boolean { return document.activeElement === terminal.textarea } -function stripAnsiCsiSequences(data: string): string { - let normalized = '' - let index = 0 - while (index < data.length) { - if (data.charCodeAt(index) === 0x1b && data[index + 1] === '[') { - index += 2 - while (index < data.length) { - const code = data.charCodeAt(index) - index += 1 - if (code >= 0x40 && code <= 0x7e) { - break - } - } - continue - } - normalized += data[index] - index += 1 - } - return normalized -} - const CURSOR_AGENT_REATTACH_HEADER = 'Cursor Agent' const CURSOR_AGENT_REATTACH_INPUT_MARKER = '→' const CURSOR_AGENT_REATTACH_SCREEN_SIGNAL_MAX_CHARS = 5000 +// Why bounded: reattach payloads reach multiple MB, but every replay puts the current screen last +// and only the header nearest the end matters. 256KB clears even a fully SGR-styled frame by ~2x, +// so the cut only ever drops stale scrollback — which would have been rejected anyway. +const CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS = 256 * 1024 function hasCursorAgentReattachPayloadScreenSignal(data: string): boolean { - const normalized = stripAnsiCsiSequences(data) + const tail = + data.length > CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS + ? data.slice(-CURSOR_AGENT_REATTACH_SCAN_TAIL_LIMIT_CHARS) + : data + // Why CSI only: OSC-carried titles must keep counting as a header occurrence, as they did when + // this stripped CSI by hand. + const normalized = tail.replace(CSI_SEQUENCE_PATTERN, '') // Why: anchor on the LAST header occurrence — replay buffers keep scrollback, // and an earlier finished run must not classify the current screen. const headerIndex = normalized.lastIndexOf(CURSOR_AGENT_REATTACH_HEADER) diff --git a/src/shared/terminal-mode-reset-profiles.test.ts b/src/shared/terminal-mode-reset-profiles.test.ts index bddcd3dbe..cef6d77a0 100644 --- a/src/shared/terminal-mode-reset-profiles.test.ts +++ b/src/shared/terminal-mode-reset-profiles.test.ts @@ -6,6 +6,7 @@ import { POST_REPLAY_LIVE_SNAPSHOT_RESET, POST_REPLAY_MODE_RESET, POST_REPLAY_REATTACH_RESET, + POST_REPLAY_REATTACH_RESET_KEEP_MOUSE, RESET_MOUSE_REPORTING, buildPostReplayLiveAgentReattachReset, replayPayloadEndsWithCursorHidden @@ -33,6 +34,17 @@ describe('terminal mode reset profiles', () => { expect(POST_REPLAY_REATTACH_RESET).not.toContain('\x1b[?2004l') }) + // Why ?1004l stays: #944 — a hard-killed TUI leaves the daemon emulator on the alternate buffer, + // so this profile can reach a plain shell, where armed focus reporting rings BEL on every pane + // switch. Dropping it would also make this byte-identical to the live-agent profile. + it('pins the live alternate-screen profile, which keeps mouse reporting but not focus', () => { + expect(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE).toBe( + '\x1b[0 q\x1b[<99u\x1b[=0u\x1b[?25h\x1b[?1004l' + ) + expect(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE).not.toContain(RESET_MOUSE_REPORTING) + expect(POST_REPLAY_REATTACH_RESET_KEEP_MOUSE).not.toBe(POST_REPLAY_LIVE_AGENT_REATTACH_RESET) + }) + // Why: #12101 — a cold-restored seed re-arms mouse reporting for a dead TUI. it('disarms mouse reporting on the cold-restore seed', () => { expect(COLD_RESTORE_SEED_MODE_RESET).toBe(RESET_MOUSE_REPORTING)