From e84a8ddec97d5f367298fa22a9db2bbed8478af4 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:27:47 -0700 Subject: [PATCH] Terminal performance initiative: pipeline fixes + term-speed-2 revival + PTY flow control (integration branch) (#7214) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Skip legacy hidden skip grammar assertions * Fix hidden TUI snapshot test setup * Fix sleep wake history test contract * Fix hidden delivery startup gate helper * Fix hidden Latin skip branch predicate * Fix hidden synchronized split-boundary replay * Stabilize remote runtime mixed subscription test * Keep hidden startup query parser active during window * Stabilize raw emoji golden restore width * Stabilize raw emoji golden fixture completion * Keep terminals responsive under agent output load * Add frozen-terminal repro harness and silent-drop regression tests Investigation harness for the frozen-terminal reports (Discord #performance, issue #2836): pane shows content, shell alive, daemon output.log flat while typing. - e2e: renderer crash -> auto-reload recovery and three restart/restore shapes (live daemon, SIGSTOP-wedged daemon, daemon killed between launches), each probing input at both drop layers. Post-crash phases drive the renderer from the main process because a crashed target severs Playwright's CDP session even though the app recovers. - e2e helpers: layer-discriminating probes (direct pty.write vs transport input, plus pty:listSessions ownership-rebuild revival). - unit repro: vendored xterm 6.1.0-beta.287 WriteBuffer permanently wedges when a sync throw escapes a write-completion callback or a custom parser handler (xterm-write-buffer-stall.repro.test.ts). - unit repros for both silent input-drop layers: main drops writes for a live PTY once ptyOwnership loses the id (revived by listSessions), and the renderer transport stays unbound after a failed connect. - pty.test.ts: unregister every leaked SSH provider id in afterEach so module-level provider state cannot leak across tests. Co-authored-by: Orca * Harden xterm write pipeline against sync-throw wedge that freezes panes A synchronous exception escaping xterm's WriteBuffer loop permanently wedges that terminal: _innerWrite has no try/catch around the parse action or the write-completion callback, the tail re-schedule never runs, and write() only re-arms on an empty buffer. The pane stops rendering and, if a replay was in flight, the replay guard latches and pty-connection's onData silently eats every keystroke — matching the field reports (Discord #performance, issue #2836: content visible, shell alive, daemon output.log flat). Both vectors verified against vendored xterm 6.1.0-beta.287 in xterm-write-buffer-stall.repro.test.ts. Three layers of defense: - Guard every write-completion callback Orca hands xterm at the two choke points (writeForegroundTerminalChunk, writeBackgroundTerminalChunk), with settle and onParsed guarded separately so a WebGL/renderer failure during viewport settle cannot starve the replay-guard release. - Guard all throwing-capable custom parser handlers (DA1, OSC 10/11, CSI ?h/?l mode reports, OSC 52 clipboard, OSC 7 cwd), degrading a throw to "not handled" — same escape class as terminal-link-provider-guard.ts. - Replay-guard watchdog: each engagement releases exactly once, from xterm's completion or a 10s watchdog, so a lost completion (wedged pipeline, disposed-terminal race) cannot latch the guard on a live pane; replayIntoTerminalAsync resolves on either path so restore chains cannot hang. Force-releases record a crash breadcrumb. All guard trips record rate-capped crash breadcrumbs, so the next field occurrence names the throwing stack instead of failing silently. Co-authored-by: Orca * Cap unbounded terminal output buffers in main and the foreground queue Field evidence (Discord #performance / #2836): renderer memory climbs to ~1.5 GB and terminals freeze; a force reload does not help until memory recovers. Two unbounded buffers matched that shape: - Main-process pendingData grew by string concatenation without bound while the renderer could not receive (frozen, starved, mid-reload) — main-heap bloat a renderer reload cannot clear. Now capped at 2 MB per PTY: past the cap the buffered bytes are dropped and the entry stays O(1) until the renderer ACKs again, then a droppedOutput sentinel is delivered and the pane repaints from the authoritative main-owned buffer snapshot (existing hidden-output restore path) instead of continuing a stream with a silent gap. - The renderer output scheduler capped only hidden-pane backlogs; the foreground path could queue a visible pane's flood without bound when the drain could not keep up. The 2 MB cap now applies to every foreground enqueue branch too, with a foreground-specific skip notice. Verified: new main-side cap test (starve → flood → sentinel → normal flow resumes), renderer sentinel-to-snapshot-restore test, two foreground scheduler cap tests; full pty/terminal-pane/pane-manager suites (1981 tests) and typecheck pass. Co-authored-by: Orca * Make replay-guard stall release probe-certified instead of time-based The previous stall watchdog blindly released the input guard after 10s. If a replay were genuinely still parsing on a starved machine, that early release could leak xterm's auto-replies into the shell — and into agent TUIs, where a leaked ESC reads as the user pressing Escape. Replace the blind release with a probe: when a completion looks overdue, enqueue an empty write behind the replay. xterm parses writes in order, so every outcome is provably safe: - probe parses after the replay completion ran: normal release already happened; probe is a no-op. - probe parses but the replay completion never ran: all replay bytes have parsed, no further auto-replies can exist — the completion was genuinely lost. Release + breadcrumb. - probe never parses (bounded wait): the pipeline is wedged, and a dead parser can never emit auto-replies, so releasing cannot leak input. Release + breadcrumb naming the pane as needing recovery. While the probe is pending — a slow-but-alive replay — the guard now HOLDS instead of releasing early; that case is pinned by a regression test. Co-authored-by: Orca * Scale output backlog caps with the scrollback setting and breadcrumb drops The 2 MB pending-output caps were flat, which risked dropping lines a 50k-row scrollback user would have retained. Both caps (main pendingData and the renderer output queue) now derive from one shared policy: max(2 MB, scrollbackRows x 120 chars) — 2 MB at the 5k default, 6 MB at the 50k max. The main side reads the setting live via getSettings; the renderer scheduler is configured where the terminal lifecycle already reads the scrollback setting. Every drop now records a rate-limited crash breadcrumb with dropped and cap sizes (terminal_output_backlog_dropped in the renderer, terminal_pending_output_dropped in main — no pty ids, session ids can embed workspace paths). Field drop frequency and size decide whether the cap constants need raising, replacing theory with data (#2836, #7017). Backlog skip notices are now cap-agnostic since the limit varies. Co-authored-by: Orca * Extract breadcrumb recording into a collection-safe leaf module Playwright loads spec imports at collection time, and e2e specs import terminal-module constants (e.g. terminal-attention.spec.ts pulls POST_REPLAY_MODE_RESET from layout-serialization, whose chain reaches replay-guard). The breadcrumb import added to the terminal modules made that chain reach crash-diagnostics.ts, whose top-level import.meta.hot and webview-registry import crash Playwright's transform ("ReferenceError: exports is not defined in ES module scope") — every e2e shard failed at collection before running a single test. Move recordRendererCrashBreadcrumb into crash-breadcrumb-recorder.ts (type-only imports, no import.meta) and point the terminal modules and their test mocks at it; crash-diagnostics re-exports for existing callers. Full e2e suite collects again (262 tests / 94 files); unit suites, typecheck, lint green. No runtime behavior change. Co-authored-by: Orca * Add cross-terminal pipeline benchmark (DSR-fenced throughput + latency probe) Run inside any terminal (Orca pane, iTerm2, Ghostty, Terminal.app, VS Code) to measure its full byte path. DSR round-trip latency at idle and under a paced agent-TUI load, plus fenced throughput over four deterministic fixtures. The DSR fence forces 'all bytes parsed' before the clock stops so xterm.js-class ingest queues can't flatter the result. First piece of the terminal performance initiative's measurement rig. Co-authored-by: Orca * Add terminal performance initiative plan Working plan for the orca-performance branch: verified architecture findings, workstreams (baselines, #7153 validation, term-speed-2 revival with merge-scout numbers, stall fixes, flow control, rig extensions, utilityProcess router, telemetry), benchmark protocol, sequencing, and baseline-relative success criteria. Co-authored-by: Orca * Add cross-terminal baseline results (Orca 1.4.91 prod vs Terminal.app vs Ghostty) Headline: Orca DSR latency under 1MB/s agent-TUI load is p50 134ms / p99 292ms vs 0.45ms (Terminal.app) and 0.21ms (Ghostty). Idle latency is fine (0.69ms p50) — the problem is queueing under load, not the pipeline hop. agent-tui fenced throughput: Orca 2.0 MB/s vs Terminal.app 37 MB/s, Ghostty 78 MB/s. Co-authored-by: Orca * Add pipeline-loss decomposition benches (headless xterm + daemon ingest) Both isolate layers of the 51x agent-tui gap found in baseline-jul02: bare @xterm/headless parses agent-tui at 103 MB/s and daemon Session ingest (emulator + pending-output recording + fanout) at 103 MB/s — on the byte stream the full Orca pipeline delivers at 2.0 MB/s. Parser and daemon are exonerated; the loss is in main per-chunk processing, delivery/ACK pacing, or renderer layers above xterm. Co-authored-by: Orca * Record baseline + decomposition findings in initiative plan Co-authored-by: Orca * Add dev-build orca-performance bench result (confounded: dev mode, 282-col window, 3MB fixtures) DSR under load p50 161ms — the #7139/#7150 branch does not move the under-load latency class. Expected in hindsight: DSR replies are ordered within the output stream, so the metric measures output-queue depth; cooperative drain paces input responsiveness but cannot reorder the queue. Shrinking the queue itself (producer flow control, task 6) and raising agent-tui throughput (task 9) are the levers for this number. Co-authored-by: Orca * Record dev-build #7153 check in findings log Co-authored-by: Orca * Parse-clock high-priority terminal drains instead of fixed-nap dripping Attribution (task #9): the drain loop wrote at most 2x16KB then slept 4/16ms regardless of parse speed — an isolation bench (new pane-terminal-output-scheduler-throughput.bench.test.ts) measures that drip at 1.9 MB/s background / 27 MB/s foreground against xterm's ~103 MB/s parse rate, matching the baseline-jul02 end-to-end numbers (agent-tui 2.0 MB/s in prod 1.4.91). Fix: high-priority (visible-pane) drains now re-arm on xterm's parse-completion callback and carry 8 writes per tick; the isolation ceiling rises 27 -> 117.6 MB/s (parse-limited). Background cadence is deliberately unchanged (2 MB/s drip protects the focused pane; hidden delivery is term-speed-2's job). DRAIN_TIME_BUDGET_MS still bounds per-tick work, preserving #7139's cooperative-drain intent. Validation: 621 scheduler/guard/pty tests green, typecheck clean. Co-authored-by: Orca * Record task #9 attribution + parse-clock fix in findings log Co-authored-by: Orca * Findings: 51x loss attributed to O(tail) retained-tail redraw path in main onPtyData Co-authored-by: Orca * Window the retained-tail redraw path to the cursor's reach Attribution (findings log 2026-07-03): main's onPtyData consumed ~93% of the event loop under an agent-TUI flood, and the dominant term was appendNormalizedToMultilineTailBuffer + finalizeRetainedTerminalRows materializing ~2x tail-length row objects plus a per-row trailing-space regex on every chunk — 0.888ms/chunk at the 2,000-line cap, on every Claude-Code-shaped frame (cursor-up + erase-below). The multiline algorithm now runs on a suffix window sized by the chunk's maximum upward cursor excursion (plus the inherited redraw cursor and a safety margin); the untouched prefix is shared by reference with a cheap last-char trailing-space check to match the reference trim. Pathological full-height cursor-ups fall back to the unwindowed implementation, which is kept verbatim and exported as the reference for the 500-case differential fuzz (retained-tail-redraw-window.equivalence.test.ts). Micro-bench at a full 2,000-line tail: 0.888 -> 0.073 ms/chunk (12x). 1,415 runtime tests green, typecheck clean. Co-authored-by: Orca * Add dev bench results: parse-clock and windowed-tail fixes Co-authored-by: Orca * Record windowed-tail partial win + next-cycle recipe in findings log Co-authored-by: Orca * Findings: remaining whale is the per-chunk blocked-reason check (~85% of onPtyData post-fix) Co-authored-by: Orca * Throttle the terminal wait-blocked check off the PTY hot path Post-windowed-tail attribution (findings log 2026-07-03): the blocked- reason complex — two full-tail buildTerminalWaitText builds plus toLowerCase and multi-pattern scans per chunk, existing only to stamp waitBlockedAt — consumed ~85% of onPtyData's remaining cost (~700-790ms/s under an agent-TUI flood). The check now runs at a 50ms cadence over coalesced chunks (PTY chunk boundaries are arbitrary, so coalescing preserves semantics), with a trailing-edge timer so burst-final state is always evaluated, and an immediate bypass when the incoming chunk (plus a 31-char split carry) contains a prompt keyword — so actionable-prompt stamping stays per-chunk-immediate while keyword-free flood frames skip the complex entirely. Previous wait text is cached per pty instead of rebuilt, and state is cleared at both pty teardown sites. 1,415 runtime tests green (including the cross-chunk prompt test, which exercises the keyword bypass), typecheck and lint clean. Co-authored-by: Orca * Findings + results: three stacked fixes unlock the pipeline (agent-tui 16x, DSR-load p50 161->18.8ms in dev) Co-authored-by: Orca * Add producer flow-control design to initiative plan Co-authored-by: Orca * Findings: revival branch green but perf-gated — daemon Session ingest regressed 103->40-48 MB/s (chain emulator restructure); merge blocked until blockedfix parity Co-authored-by: Orca * Pre-filter daemon OSC/mouse scanners for introducer-free chunks Skips the scan-tail copy and full-chunk walks when a chunk cannot contain an OSC or private-mode sequence (single native includes() checks), with split-sequence correctness preserved via explicit tail retention. Strictly positive micro-optimization on the daemon per-chunk path; 641 daemon tests green (1 pre-existing WSL failure unrelated). Co-authored-by: Orca * Retract confounded daemon conviction; mandate load-controlled A/B protocol for the revival merge gate Co-authored-by: Orca * Record A/B gate pass in findings log; add A/B result JSONs Co-authored-by: Orca * Add producer-side PTY flow control (watermarks + protocol v19) Main now pauses the actual PTY when a pane's renderer-pending backlog crosses the 256KB high watermark and resumes once it drains below the 32KB low watermark (wide hysteresis band so a draining queue cannot flap pause/resume per flush slice). node-pty pause() stops the pty fd read, so the kernel/ConPTY buffer fills and a flooding shell blocks on write — flood-induced buffered lag becomes shell blocking instead of unbounded main-process buffering (terminal-performance-initiative §5). Transport: new fire-and-forget pausePty/resumePty daemon notifications (protocol v19; 18 added to PREVIOUS_DAEMON_PROTOCOL_VERSIONS), routed DaemonServer -> TerminalHost -> Session -> subprocess pause()/resume(). LocalPtyProvider pauses node-pty directly. Router/degraded providers forward; IPtyProvider gains optional pauseProducer/resumeProducer. Safety invariants: - Lost-resume failsafe: daemon Session auto-resumes 5s after a pause with no matching resume; main re-asserts the pause at most once per 5s while still above the high watermark, so a lost resume can never wedge a shell and a sustained flood stays throttled. - Resume on every teardown path: Session kill/exit/dispose/detach; main releases on pty exit and on window-destroyed bookkeeping wipes; the adapter owes paused sessions a resumePty on the next connect after a socket drop. - Providers without support (SSH relay, legacy protocol <= v18) no-op silently, and the scrollback-scaled pending-output cap still bounds main memory when pause is unavailable. - Kill switch: PRODUCER_FLOW_CONTROL_ENABLED in ipc/pty.ts flips the whole mechanism off in one line. daemon-errors.ts is split out of types.ts to stay under the max-lines cap. Tests: watermark transitions/hysteresis/re-assert (controller unit), lost-resume failsafe + resume-on-kill/exit/dispose/detach (session), notification routing + v18 gating + reconnect owed-resume (adapter), direct pause/resume (local provider), and a flood test asserting pause fires once, pending stays bounded at HIGH + one chunk, and resume fires once after drain (ipc/pty). Co-authored-by: Orca * Findings: flow control merged; definition-of-done accounting; prod verification re-scoped to packaged RC Co-authored-by: Orca * Fix stray brace from revival merge in long-table-scroll-restore e2e spec (broke e2e transform in CI) Co-authored-by: Orca * Prod verdict: v1.4.121-rc.0 bench — DSR-load p50 134->18.6ms (7.2x), agent-tui 2.0->11.2 MB/s, idle at Terminal.app parity; pipeline now cadence-bound Co-authored-by: Orca * Recover terminal output delivery after system sleep Root cause: main gates every pty:data send on a global + per-PTY in-flight counter that only renderer ACKs decrement. If ACKs are lost across a system suspend, the counters pin at the cap and every PTY — old and newly created — is silently gated forever while output piles up in pendingData. A focus-preserving display wake also fires no renderer focus/visibilitychange events, so terminal wake recovery (and the WebGL context-loss latch clear) never runs. Only a renderer reload recovered. Three fixes: - ACK-stall watchdog (src/main/ipc/pty.ts): if sends stay gate-blocked for 10s with zero ACK progress while the renderer webContents is alive, warn once, reset the in-flight delivery counters, and flush held pendingData. Armed lazily on the first gate-blocked send and disarmed by every ACK, so it can never fire under healthy heavy load. - Renderer lifecycle reset now also zeroes the in-flight counters — a reload/navigation destroys the renderer dispatcher, so outstanding ACKs can never arrive and stale counters would gate the new renderer. - System-resume wake IPC: main relays powerMonitor 'resume' as system:resumed to live windows (plus forceRepaint); preload exposes ui.onSystemResumed; the terminal wake-recovery hook runs the same recovery path as window focus/visibilitychange. Co-authored-by: Orca * VS Code head-to-head: Orca beats/ties 5 of 6 metrics (16x idle, 5x styles-stress, better p99); load p50 gap attributed to ACK window + timer-clamped drain cadence Co-authored-by: Orca * Schedule zero-delay terminal drains via MessageChannel Chromium clamps nested setTimeout(0) to ~4ms, stacking dead gaps onto every parse-clocked drain tick; the explicit 4ms high-priority re-arm interval added more. A posted message is still a macrotask — input and paint are serviced between posts — so cooperative yielding survives without the clamp. Generation-tokened cancellation; vitest keeps the timer path (fake timers can't advance channel posts) plus a real-timer smoke test for the channel path. Standing-queue target: VS Code's ~7ms class (measured us 18.6ms, them 7.18ms, same rig). Co-authored-by: Orca * Cut daemon and main PTY batch windows 8ms -> 2ms At 9% pipeline utilization the DSR-under-load latency is fixed batching windows, not queue depth (proved by the MessageChannel drain lever moving nothing). Both hops charged an expected half-window per chunk; 2ms keeps burst coalescing at negligible IPC overhead (~500 msgs/s worst case vs MB/s payloads). Co-authored-by: Orca * Findings + tests: batch windows were the DSR-load gap (19->8.0ms dev); timing tests updated to 2ms windows Co-authored-by: Orca * Fix PR CI and guard resume relay during shutdown Co-authored-by: Orca * Chain e2e specs 6/6 green — gate x drain validation debt paid Co-authored-by: Orca * Replace ack-stall watchdog with cumulative ACKs + solicited delivery resync Design review: the 10s blind-reset watchdog decided correctness from a wall-clock threshold. Rework piece 1 into a deterministic two-part design (pieces 2 and 3 — lifecycle-reset counter zeroing and powerMonitor wake IPC — are unchanged): - Cumulative ACKs (TCP-style): the renderer dispatcher now tracks a monotonic per-pty total of processed chars (terminal-pty-ack-gate) and sends it on every ACK alongside the legacy per-chunk delta. Main keeps per-pty sentChars/ackedChars and max-merges received totals — idempotent and reorder-tolerant, so a lost ACK self-heals when any later ACK arrives instead of becoming permanent in-flight debt. Provider (SSH/daemon) backpressure is credited only the derived delta, clamped, never negative. Main tolerates both payload shapes keyed by field presence (dev hot-reload can mix renderer/main versions); totals reset on pty exit and renderer lifecycle reset on both sides. - Solicited resync (replaces the blind reset): when new pty data arrives while that pty's delivery is fully gated and no probe is outstanding, main sends pty:requestDeliveryResync; the renderer replies with its cumulative totals and main reconciles via max-merge, then flushes held pendingData. Event-triggered, verified-state recovery — no wall-clock threshold decides correctness. The only timer is a 5s request/response hygiene timeout that clears the outstanding flag and logs one diagnostic warn per silent streak; it never mutates counters (a renderer that cannot answer has dead IPC — reload is the only cure). The 10s corrective watchdog is deleted. Co-authored-by: Orca * Starting point: prior agent's garble differential fuzz harness Three files recovered (were untracked) from a prior agent killed by API outages, plus a trivial curly-brace lint fix in the op dispatcher so the pre-commit hook passes: - src/shared/agent-tui-ansi-fuzz-stream.ts (seeded agent-TUI byte-stream gen) - src/shared/terminal-restore-parity-fixture.ts (renderer-parity fixture) - src/main/daemon/headless-emulator-fidelity.fuzz.test.ts (suite 1: differential HeadlessEmulator vs @xterm/headless reference on identical bytes) Co-authored-by: Orca * Suite 1 findings: two new serialize round-trip bugs (B bold-loss, C cursor) Scanned seeds 1..2000. Beyond the pre-documented serialize wrap-null-cell bug (A, 27 seeds, tolerated), the fuzz surfaced two NEW real @xterm/addon-serialize 0.15.0-beta.287 round-trip defects, both of which garble a revealed hidden pane: - Bug B (seeds 435, 770, 1321): serializing a dim cell followed by a bold-only cell emits \x1b[1;22m; SGR 22 clears bold too, so restored bold is lost. Minimal repro: '\x1b[2mA\x1b[22m\x1b[1mB' -> restored 'B' loses bold. - Bug C (seeds 454, 1696): a final content row filled to the right margin leaves xterm wrap-pending; the serializer's relative cursor restore lands one column short. Minimal repro: '0123456789\x1b[3;5H' at cols=10 -> cursor x=3 not x=4. Both isolated to pure serializer replay (no Orca preamble), confirming upstream. Parity fixture verified faithful to the renderer pane's buffer options. Each is pinned as a standalone it.skip repro; full evidence + classification in notes/garble-fuzz-divergences.md. Seed 113 (handoff's DECSC/DECRC case) does not diverge on the current harness. No production code changed. Co-authored-by: Orca * Add perf prerelease update check modifier Co-authored-by: Orca * Suite 2: hidden-reveal seq-reconciliation fuzz + two new snapshot bugs (D, E) Property-tests the reveal seq-reconciliation byte-stitch (getChunkDataAfterSnapshot / reconcileChunkAgainstRestoredSnapshot in pty-connection.ts), mirrored exactly: N=200 seeded hide/reveal scenarios with a rich agent-TUI hidden prefix snapshot and an append-only racing tail, chunked with seq/rawLength meta, seq-domain restarts, unmetered chunks and droppedOutput markers. Asserts snapshot-at-S + reconciled tail == snapshot-of-everything (seq-neutral) and == always-visible (end-to-end). Runtime ~5s at 200; FUZZ_ITERATIONS override documented. Two NEW real snapshot-limitation garbles found while building it, both distinct from suite 1's serialize bugs and pinned as standalone it.skip repros: - Bug D: the DECSC saved-cursor register is not serialized. A hidden TUI that saves the cursor (ESC 7 / CSI s) and restores it on reveal (ESC 8 / CSI u) lands the restore at home. Repro: 'AB\x1b7\x1b[4;10HCD' + '\x1b8X' -> 'XB' vs 'ABX'. - Bug E: a snapshot taken mid-escape-sequence (a PTY read split an escape) drops the partial sequence (it's parser state, not buffer), so the tail's continuation renders literal. Repro: 'AB\x1b[3' + 'mCD' -> 'ABmCD' vs 'ABCD'. Fired on ~24% of the corpus (tolerated + counted via prefixEndsMidSequence). The append-only-tail design isolates seq reconciliation from these and the Bug C cursor cascade. Full evidence + fix directions in notes. No production changes. Co-authored-by: Orca * Suite 3: 25-cycle park/reveal drift e2e test Extends terminal-hidden-view-parking.spec.ts with a deterministic 25-cycle park->reveal test on a static rich alt-screen TUI frame (box drawing, SGR colors, wide CJK/emoji). Baselines against the frame after the first snapshot restore (so both sides pass through identical machinery — the alt-screen restore correctly drops normal-buffer scrollback, which is contract not garble), then asserts every subsequent reveal reproduces it byte-for-byte with no accumulated drift and no hidden-skip banner. Exercises the real renderer teardown + HeadlessEmulator snapshot restore + PTY reattach path the fuzz suites model in isolation. Passes in ~29s (electron-headless, workers=1). Co-authored-by: Orca * Fix two serialize round-trip bugs garbling hidden-terminal snapshot restore BUG B (addon patch): @xterm/addon-serialize's SGR diff emitted bold/dim set params before the shared intensity reset 22, so "1;22" wiped a freshly set bold and a bare "22" dropped a still-set bold/dim. Patched via pnpm patchedDependencies (config/patches) to diff bold+dim as one intensity group with the clearing 22 emitted first. Other flag pairs (4/24, 3/23, 7/27, ...) have dedicated resets and were verified unaffected. BUG C (Orca-side hardening): the addon restores the cursor with relative moves computed from where it assumes replay leaves the cursor; a final row filled exactly to the right margin leaves replay wrap-pending and the restore lands one column short. New shared serializeWithAbsoluteCursor appends an absolute CUP from the source terminal's authoritative cursor at every restore/replay serialize site (daemon/runtime HeadlessEmulator.getSnapshot, renderer mobile snapshot serializer, shutdown layout capture). It skips empty snapshots and wrap-pending sources so it never changes already-correct behavior. Round-trip repros + non-regression coverage in src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts (verified failing with the fixes stashed). buildRehydrateSequences extracted to its own module to keep headless-emulator.ts under the max-lines budget. Co-authored-by: Orca * Gates: tolerate+count Bugs B/C in deep mode; drop inverse from reconciliation tail - Fidelity suite: add snapshotHasSelfCancellingBoldReset (Bug B) and isMarginWrapPendingCursorOffByOne (Bug C) predicates so the corpus tolerates + counts them like Bug A. FUZZ_ITERATIONS=2000 is now green (~113s) and fails only on genuinely new divergences; each tolerance keeps its <50% degeneracy guard. Default 300 unchanged (~17s). - Reconciliation suite: drop SGR 7 (inverse) from the append-only tail. Inverse marks trailing blanks with an inverse-fg the serializer round-trips slightly differently by capture depth — a Bug-B-class serialize nuance, not seq reconciliation. FUZZ_ITERATIONS=1000 is now green; default 200 unchanged. - Notes updated: every bug class is both pinned (skipped repro) and tolerated in its corpus; combined default runtime ~19s. Regex uses String.fromCharCode(27) to stay oxlint no-control-regex clean. Co-authored-by: Orca * Keep RC update checks off perf prereleases Co-authored-by: Orca * Fix snapshot DECSC register loss (Bug D) and mid-escape boundary drop (Bug E) Bug D: the serialized screen cannot carry the VT100 DECSC saved-cursor register, so a hidden ESC 7 followed by a post-reveal ESC 8 restored to home and clobbered live cells. The snapshot epilogue now re-saves at the source's saved position before the final absolute CUP (readSavedCursorRegister + serializeWithAbsoluteCursor; the active buffer's own register, so alt screens carry theirs). Position-only by design; never-saved terminals are left untouched. Bug E: a PTY read ending mid-escape leaves the sequence in the emulator's parser, so serialize dropped it and the racing tail's continuation bytes rendered literally after reveal (~24% of the fuzz corpus). The emulator now tracks the unparsed trailing partial at ingest (terminal-partial-escape-tail.ts, committed post-parse like the mouse mirror) and ships it as TerminalSnapshot.pendingEscapeTailAnsi. applyMainBufferSnapshot writes it LAST, after POST_REPLAY reset — any later ESC would abort the dangling sequence. Seq accounting is unchanged: the tail is a suffix of bytes the snapshot seq already counts, so reconcile slicing needs no adjustment. Fuzz suites: unskip the Bug B/C repros (fixed on this branch) and the new D/E repros; remove the B/C/E tolerance predicates so regressions fail loudly. Only Bug A (upstream wrap null-cell) stays tolerated + counted. Green at FUZZ_ITERATIONS=2000 (fidelity) and 1000 (reconciliation). Co-authored-by: Orca * Count suffixed RC tags (rc.N.perf) in the shared rc counter — second suffixed cut collided with the first Co-authored-by: Orca * Classify suffixed rc tags (rc.N.perf) as rc telemetry identity in release builds The build-identity guard only knew vX.Y.Z and vX.Y.Z-rc.N, so suffixed perf RCs cut fine but every platform build refused the tag and the releases published empty. Co-authored-by: Orca * Cut the hidden-restore flood feedback loop (A) + query carve-out on drops (B) (A) Under a foreground flood, the hidden-output-restore loop re-fetched snapshots endlessly: each synchronous applyMainBufferSnapshot starved ACK processing, main pinned at the in-flight cap, dropped at the pending cap, and every droppedOutput/modelRestoreNeeded marker re-armed another restore until the flood ended (rc.7.perf DSR timeouts). - Restore loop: a foreground live-chunk queue overflow now abandons the restore immediately (the stream is outrunning snapshot fetch+replay), with a 3-iteration hard cap + lifecycle warn as backstop. - Re-arm gate: drop markers/sentinels and reconcile seq-gaps on a visible pane during its own in-flight/just-abandoned restore no longer re-arm; live bytes write through and ONE deferred repaint (2s after the last backpressure signal) heals the gap. Hidden-pane gate semantics are unchanged. - Query salvage: discarding queued restore bytes (overflow/refetch) now extracts DSR/CPR/DA/OSC-color queries and replays them to xterm so replies still flow. (B) Main-side: dropOversizedPendingPtyData carves reply-eliciting query sequences out of the dropped buffer (and out of post-drop latched data, bounded) and ships them on the droppedOutput sentinel, so DSR probes survive bulk drops. Query scanning moved to src/shared/terminal-reply-query-extraction.ts, shared verbatim with the renderer's hidden-startup query extraction. Co-authored-by: Orca * ACK terminal output at parse-drain, not dispatcher enqueue (C) The renderer credited main's per-PTY in-flight window the moment a pty:data chunk entered the dispatcher, so the 512KB window meant "bytes received", never "bytes parsed". Under flood the renderer write queue grew unbounded behind instant ACKs; main saw no backpressure, crossed the pending cap, and bulk-dropped output (rc.7.perf DSR timeouts). Crediting is now parse-deferred: each delivery carries a fire-once credit (deliverPtyDataWithDeferredAck); the pane's first scheduler write claims it (writeTerminalOutput.ackCredit) and the output scheduler fires it when the bytes are consumed — after terminal.write in the parse-clocked drain, or on ANY discard path (backlog cap replacement, discardTerminalOutput, disposed-terminal drops, flush recovery). Deliveries that never reach the scheduler (reconcile drops, restore queueing, pre-mount eager buffer) settle at handler return, so the invariant holds: every delivered chunk credits exactly once, parsed or discarded. E2E ack-gate hold/release and delivery-resync semantics are unchanged (all crediting still routes through ackPtyData). Main-side equilibrium: with ACKs at parse cadence, in-flight becomes true backpressure — pendingData stays near the 256KB producer-pause watermark, far under the >=2MB drop cap, so bulk floods block the shell (node-pty pause) instead of dropping. Co-authored-by: Orca * Synthesize salvaged query replies directly instead of replaying into xterm The 10MB dev bench proved the write-back salvage insufficient: a pending-cap drop always triggers a snapshot restore, whose replay guard swallows xterm auto-replies and whose discardTerminalOutput races away still-queued query writes — the salvaged DSR died both ways and the fence still timed out. Salvage now answers directly on the input path (immune to both): CPR (CSI 6n) from the live buffer via transport.sendInput, DA1 with the renderer's canned response, OSC color probes via the existing direct responder. Rare queries (DECRQM, DA2) keep the best-effort xterm replay. Co-authored-by: Orca * Untrack branch-added bench result JSONs (20 files); keep numbers in the findings log Files stay on disk; main's 7 pre-existing results are untouched. Co-authored-by: Orca * Branch guide: document merge-not-rebase sync strategy and conflict pattern Co-authored-by: Orca * Merge origin/main (#7316 tab-strip click-vs-drag fix); adapt #7290 recovery-reload tests to this branch's dual did-finish-load listeners The three tests grabbed the FIRST did-finish-load listener; on this branch the renderer delivery-gate reset registers before the orphan sweep, so the sweep tests exercised the wrong handler (one failing, two vacuously green). They now fire all listeners like a real reload. Co-authored-by: Orca * Fix branch CI lint: split pane-interaction functions out of artificial-opencode-terminal-load.spec (815>800 lines), modernize perf-html-report script No max-lines disable per repo rules; extracted to artificial-opencode-pane-interactions.ts. toReversed() and import.meta.filename replace reverse()/fileURLToPath. Co-authored-by: Orca * Fix Windows update-relaunch killing the live terminal daemon On a Windows update relaunch the daemon can be wedged past every RPC budget (final checkpoint flush + installer/AV disk pressure), so the 3s health check AND the 5s session-list hello both time out while sessions are still alive - and the launcher failed closed, killing the daemon and every terminal session it owned. - Adopt an unresponsive daemon whose pipe still accepts a raw connection; a new rejected health state keeps replacing daemons that answered and refused the handshake (never adoptable). - Give Windows pid files a real startedAtMs (daemon self-reports it in the ready IPC message) and verify it via CIM CreationDate piggybacked on the existing command-line query, so the pid-recycling guard is no longer inert on win32. - Only delete legacy daemon pid/token files when the pid-file process is provably dead; deleting a live daemon''s token made its sessions permanently unadoptable after a protocol bump. - Capture agent resume records every 60s in the renderer (skipping unchanged records) so hard kills still leave a fresh resume record. * Heal blank terminals when main→renderer push delivery dies (renderer-pull delivery watchdog) Field evidence (v1.4.121-rc.0 debug snapshot, 2026-07-06): a wedged window held 530,115 un-ACKed in-flight chars — one PTY pinned at the 512KiB per-PTY high water plus a fresh terminal's 245-char prompt that was sent and never consumed — while the user ran the snapshot over invoke from that same window. Main→renderer push delivery (pty:data and every sibling channel) was dead; renderer→main→renderer invoke was alive. Upstream precedent for one-directional IPC death: electron#37067 (suspected Mojo pipe disconnect, stalled as need-info). Every terminal goes blank, new terminals are born blank, and only a renderer reload recovered. The existing recovery layers cover the OTHER variants of this bug family and structurally cannot reach this one: - The xterm write-pipeline sync-throw guards, output-buffer caps, and probe-certified replay-guard release (#7150 family) run only after bytes arrive in the renderer — here they never do. (The pending cap did work as designed in the field: ~2.1MB pendingDroppedChars, bounded main heap.) - Cumulative ACKs self-heal lost ACK messages and the solicited delivery resync reconciles verified totals (4647df86a; #7260 on main) — but the resync probe, the powerMonitor wake relay, and the droppedOutput restore markers all ride main→renderer push, the direction that is dead. The probe's unanswered path deliberately only logs. This adds the missing lane, renderer-initiated and ridden entirely over invoke — the direction the field snapshot proved alive: - terminal-delivery-watchdog.ts: 15s heartbeat, free while output flows. Hot-path cost is one Map upsert per received chunk; a tick does no IPC unless the terminal plane was silent for the whole interval and a PTY still expects delivery. Two consecutive silent ticks with main reporting ACK-starved in-flight confirm the wedge; heals are one-shot per 60s cooldown so a persisting wedge cannot repaint-storm. - pty:reportRendererDeliveryState (invoke): always max-merges the renderer's cumulative processed totals (a free extra repair lane for the lost-ACK variant); with heal:true — and only after main has itself seen ≥10s of ACK silence — writes off bytes the renderer provably never received (received ≤ acked < sent; a received-but-unparsed backpressure window is never written off), drops that PTY's pendingData (snapshot covers everything ≤ markerSeq, hidden-drop parity), credits provider flow control, and returns restore markers in the reply. - The renderer re-attaches all push listeners (cures a detached-listener variant outright; a safe no-op against a dead channel) and routes the pulled markers through the existing pty:modelRestoreNeeded machinery — panes repaint from the main-owned buffer snapshot with zero push delivery involved. - Field discrimination built in: the heal warn logs ipcRenderer.listenerCount('pty:data') (listener detached vs channel dead) with the full delivery snapshot, so the next occurrence names the root cause without asking the user to run anything in a console. Repro harness: the exposeStore-gated __terminalDeliveryWatchdog hook blackholes pty:data ahead of the dispatcher — the field failure in miniature (no receive count, no ACK credit, no dispatch). terminal-push-delivery-loss-recovery.spec.ts proves the wedged output repaints while the blackhole is still engaged and live flow resumes after release, with no reload. Unit suites pin the watchdog state machine (zero IPC under flow, two-tick confirm, cooldown), the dispatcher reattach seam, and the main-side write-off semantics. Perf: nothing added to main's send/flush path; the renderer data path gains one integer/Map update per chunk; idle cost is one ~100-byte invoke per 15s only during total terminal silence. Terminal perf e2e suite (typing latency, redraw freeze, output scheduler, hidden TUI restore, artificial opencode load) passes on this change; no watchdog activity occurs under ack-gate pressure scenarios because receive-progress gates the heartbeat. Co-authored-by: Orca * Expose the hidden-yet-visible delivery-gate contradiction in the debug snapshot The v1.4.124-rc.2.perf blank-terminal field snapshot showed a different state than the v1.4.121 transport wedge: no delivery gating at all (ackGatedFlushSkipCount 0, in-flight 38KB, far under every cap) but TWO ptys hidden-delivery-gated with 78MB dropped as hidden. The aggregate counters cannot say whether the pane the user was staring at was one of the gated ones — the one number that separates "normal background dropping" from "main is starving a visible pane because the reveal unmark never fired". Add hiddenDeliveryGatedVisiblePtyCount / hiddenDeliveryGatedActivePtyCount (overlap of the gate's hidden set with the renderer's visible/active reports — a contradiction that must be zero) to the delivery debug snapshot, and a once-per-minute warn when hidden-gated bytes are dropped for a pty the renderer reports visible or active, with the full snapshot attached. Zero cost outside the debug read and the already-dropping path. Co-authored-by: Orca * Unlatch the hidden-delivery gate when user input disproves a stuck document.visibilityState macOS occlusion tracking can wedge document.visibilityState at 'hidden' after display sleep and never fire another visibilitychange. The hidden- delivery gate then keeps dropping renderer-bound bytes for panes the user is looking at (field snapshot 2026-07-06, v1.4.124-rc.2.perf: 78MB dropped across 2 pane-level-visible ptys with a fully healthy transport), and every recovery path (window focus, system-resume relay, backlog recovery) re-ran syncHiddenRendererPtyDelivery only to recompute the same stale predicate — nothing could ever clear the gate. The user sees a frozen terminal; typing echo is dropped in main; only a reload recovers. Real user input while the document claims hidden is a physical contradiction: keystrokes and clicks only reach a focused, on-screen window. stale-document-visibility.ts latches that proof, runs each pane's existing visibilitychange resync (gate unhide + hidden-output snapshot restore), and hands authority back to the occlusion tracker on the next genuine visibilitychange. No timers; the failure bias is safe — a wrong latch can only restore pre-gate delivery cost, never drop bytes. Hot path unchanged: the foreground predicate still returns on the same single comparison while the document is visible. tests/e2e/terminal-stuck-occlusion-recovery.spec.ts pins the wedge (visibilityState pinned hidden -> output dropped, not painted; the hiddenDeliveryGatedVisiblePtyCount field discriminator reads >0) and the recovery (one Shift keypress repaints the missed output from the main-owned snapshot, no reload, while visibilityState still reads hidden). Negative control verified: the spec fails without this fix. Typing-latency perf gate passes; terminal-pane unit suites 366/366. Co-authored-by: Orca * Add a one-paste terminal freeze report: __orcaTerminalFreezeReport() Every field report of the frozen-terminal family so far has needed follow-up asks (console output, main logs, second snapshots) because each capture showed one process's counters at one instant. This makes a single DevTools command sufficient: `await window.__orcaTerminalFreezeReport()` returns renderer state (document.visibilityState + the stale-visibility override, pty:data listener count, delivery-watchdog totals), main's debug snapshot extended with a per-pty delivery table (sent/acked/pending, hidden vs visible-set membership, last send/ACK ages, window focus flags, power suspend/resume ages, app version), and bounded breadcrumb rings from BOTH processes recording the transitions that matter: gate marks/unmarks, visibilitychange and stale-visibility latches, watchdog stalls and heals, restore markers, heal write-offs, and renderer lifecycle resets (so "user already reloaded" is visible in the history). Costs stay off the data path: breadcrumbs record only rare transitions into a 100-entry ring with same-kind coalescing (a flood costs one slot per second); the per-pty table is built only when the snapshot is read; the per-send bookkeeping adds one Date.now() to existing accounting writes. Pty ids are redacted to their `@@` suffix because daemon session ids embed worktree paths. The report assembles over invoke IPC — the direction proven alive in every observed wedge — and a failing invoke is captured as data instead of sinking the report. The stuck-occlusion e2e now also pins the report end-to-end: after the wedge + keystroke recovery, the report must carry the stale-visibility latch and gate transitions in the renderer ring, gate-mark/unmark in main's ring, and a populated per-pty table. Suites: pty.test.ts 258, terminal-pane 1705, shared ring 5; typing-latency perf gate passes. Co-authored-by: Orca * perf(daemon): keep-tail thin hidden panes' stream so agent floods never bury typing (STA multi-workspace lag) Hidden panes are exempt from pendingData flow control (main gate-drops their bytes after ingestion), so N background agents ran unbounded ahead on the one shared daemon->main stream socket (measured 192MB user-space backlog) and visible-pane echo waited FIFO behind it — typing appeared seconds late whenever several agents burst on a loaded machine (8x512KB/s + 12 CPU spinners: p50 293ms fix-off; 12x1MB/s: 6.1s). Mechanism (replaces producer pacing — no reveal catch-up, ever): - Shallow socket write gate (128KB) + per-session fairness bypass bounds echo latency by construction; kernel-flush refill sentinel keeps held bulk draining at full speed (drain-only refill capped at ~8MB/s). - Backgrounded sessions' queued output is keep-tail dropped (newest 512KB kept, in-order dataGap replaces the middle); a ~2MB GLOBAL budget shrinks per-session keep-tails (floor 64KB) so a worktree switch never waits behind the aggregate. Reply-eliciting query bytes (DSR/DA/OSC probes) are salvaged from dropped spans. - Notifications are structurally lossless: the daemon runs the same shared scanners main uses (bell/OSC 133/pr-link/2031) over every byte BEFORE drop decisions and relays facts in byte order; ordered background markers hand scan authority back and forth, seeded with the emulator's partial escape tail so a sequence split across the handoff neither phantom-fires nor goes missing. Titles/agent-status stay main-side (kept-tail convergent). - Main: background = hidden AND no remote view subscriber (a live mobile/web view is never thinned); on dataGap main resets cross-chunk parse carries, drops the headless mobile mirror, and reuses the hidden-drop model-restore marker. Wire: three new stream events, tolerated within protocol v19 (old mains ignore unknown events; old daemons never see the trigger). Kill switches: ORCA_DAEMON_BACKGROUND_STREAM_DROP=0, ORCA_DAEMON_SHALLOW_SOCKET_GATE=0. A/B (pnpm bench:multi-workspace-typing): 8x512KB/s + 12 CPU workers p50 293ms/p90 647ms -> 15/21ms (= baseline); 12x1MB/s 6,146ms -> 20ms; light loads unchanged; zero missing echoes. Latin hidden-restore e2e green (probe-verified aggregate-drain root cause). New deterministic repro harness: tests/e2e/terminal-multi-workspace-typing-latency.spec.ts + CPU pressure workers. Co-authored-by: Orca * diag(terminal): breadcrumb WebGL context-loss/atlas + wake triggers into freeze report Silent instrumentation (memory ring only, no new console lines) so the next post-wake garble report attributes itself. Adds: - shared/terminal-webgl-diagnostics.ts: lib-safe sink so pane-webgl-renderer (lib) can record without importing the components-layer ring; wired to the ring in terminal-freeze-breadcrumbs. - webgl-context-loss crumb at onContextLoss, webgl-atlas-reset crumb at the atlas registry reset — the pair that distinguishes 'atlas corrupted' from 'missed repaint'. - wake-recovery: crumb (focus/visibilitychange/system-resumed) with the clearGlyphAtlases decision; source in the kind so distinct triggers don't coalesce. - per-pane WebGL state (getAllPaneRenderingDiagnostics) in the freeze report. Gates: typecheck 0 errors; terminal suites 328 files pass; oxlint clean. Co-authored-by: Orca * fix(lint): use Number.parseInt/parseFloat in terminal-view-attributes oxlint unicorn(prefer-number-properties) flagged 24 global parseInt/parseFloat calls in the terminal-view-attributes feature (ee540f32d, perf-branch only), failing PR Checks 'verify' lint. Mechanical global→Number.* rewrite via oxlint --fix; behaviorally identical. Pre-existed the latest main merge. Co-authored-by: Orca * fix(test): update stale terminal test stubs/expectations to current runtime Three pre-existing perf-branch test failures (red before the latest main merge; unrelated to it) — all stale test scaffolding lagging behind perf-branch features, no production code changed: - provider-dispatch.test.ts: electron mock missing powerMonitor, which pty.ts installPowerSignalBreadcrumbs now calls on registerPtyHandlers. Added powerMonitor:{on:vi.fn()} to match pty.test.ts. - runtime-terminal-stream.test.ts: onSnapshot now receives a second { pendingEscapeTailAnsi } meta arg (#7329); updated the three exact-arg toHaveBeenCalledWith assertions. - terminal-multiplex-escape-tail.test.ts: stubRuntime missing registerRemoteTerminalViewSubscriber (subscribe path calls it now, erroring before snapshot serialize); added the stub used by sibling multiplex tests. Co-authored-by: Orca * Fix hidden/parked split-pane exit stranding ghost or resurrected panes Deterministic e2e repro of the field 'ghost blank pane' incident (a closed/finished setup-split leaf persisted in root with no binding and remounted as a permanently blank pane) found two teardown gaps at the hidden-view parking boundary: 1. The kept-exit guard ('freshly split pane can lose its newborn PTY during setup') fired for HIDDEN panes. The hidden-delivery gate withholds their bytes, so a hidden split always looks output-less and its dead pane was kept — a binding-less ghost that dead-session reconcile can never reach (no PTY id to prove dead). The keep is a visible-failure UX; gate it on isVisibleRef and close hidden panes. 2. A PTY exit landing while the tab is PARKED reached only the parked watcher's exit sidecar (hosts' onPtyExit requires a mounted TerminalPane), which only disposed the watcher. The leaf's stale binding then reattached on reveal and the daemon re-created the exited session id as a fresh shell — silent pane resurrection. The sidecar now collapses the dead leaf out of the stored layout via detachTerminalLayoutLeaf (the observed-exit teardown's data half). New e2e suite terminal-pane-close-layout-consistency.spec.ts sweeps close/shell-exit at every hidden/park lifecycle phase and asserts leaves(root) == bindings == live panes; all 7 scenarios pass. Unit regressions added for both fixes. Co-authored-by: Orca * Harden terminal delivery and snapshot recovery Co-authored-by: Orca * Fix inherited lint failures Co-authored-by: Orca * Align merged runtime recovery tests Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Neil Co-authored-by: Orca --- .github/workflows/release-cut.yml | 23 +- .github/workflows/release-mac-build.yml | 5 +- .gitignore | 3 + ...erm__addon-serialize@0.15.0-beta.287.patch | 55 + .../check-terminal-perf-report-budgets.mjs | 15 + ...heck-terminal-perf-report-budgets.test.mjs | 28 + .../generate-terminal-perf-html-report.mjs | 483 ++++ ...enerate-terminal-perf-html-report.test.mjs | 272 +++ config/scripts/release-rc-history.mjs | 6 +- .../run-multi-workspace-typing-bench.mjs | 63 + .../run-terminal-scale-perf-report-gate.mjs | 16 +- ...n-terminal-scale-perf-report-gate.test.mjs | 31 +- .../summarize-terminal-perf-report.mjs | 1 + config/scripts/terminal-perf-report-rows.mjs | 199 ++ .../reference/terminal-hidden-view-parking.md | 115 + .../reference/terminal-model-view-contract.md | 218 ++ docs/reference/terminal-query-authority.md | 326 +++ .../terminal-side-effect-authority.md | 252 ++ docs/terminal-main-owned-state.md | 11 + notes/garble-fuzz-divergences.md | 296 +++ notes/orca-performance-branch-guide.md | 532 +++++ notes/terminal-performance-initiative.md | 583 +++++ package.json | 5 +- pnpm-lock.yaml | 7 +- .../daemon-background-transient-facts.test.ts | 76 + .../daemon-background-transient-facts.ts | 106 + src/main/daemon/daemon-entry.ts | 4 +- src/main/daemon/daemon-errors.ts | 23 + src/main/daemon/daemon-health.test.ts | 60 +- src/main/daemon/daemon-health.ts | 91 +- src/main/daemon/daemon-init.test.ts | 232 +- src/main/daemon/daemon-init.ts | 84 +- src/main/daemon/daemon-pty-adapter.test.ts | 250 ++ src/main/daemon/daemon-pty-adapter.ts | 282 ++- src/main/daemon/daemon-pty-router.test.ts | 95 +- src/main/daemon/daemon-pty-router.ts | 42 +- src/main/daemon/daemon-server.test.ts | 66 + src/main/daemon/daemon-server.ts | 185 +- .../daemon/daemon-stream-backlog-probe.ts | 55 + .../daemon/daemon-stream-data-batcher.test.ts | 478 +++- src/main/daemon/daemon-stream-data-batcher.ts | 358 ++- src/main/daemon/daemon-stream-data-split.ts | 114 + src/main/daemon/daemon-stream-events.ts | 78 + .../daemon/daemon-stream-keep-tail-drop.ts | 182 ++ .../degraded-daemon-fallback-shutdown.ts | 25 + .../degraded-daemon-pty-provider.test.ts | 65 +- .../daemon/degraded-daemon-pty-provider.ts | 68 +- .../headless-emulator-fidelity.fuzz.test.ts | 430 ++++ src/main/daemon/headless-emulator.test.ts | 121 +- src/main/daemon/headless-emulator.ts | 396 ++-- src/main/daemon/history-manager.ts | 18 + src/main/daemon/history-reader.ts | 16 +- src/main/daemon/pty-subprocess.ts | 24 + src/main/daemon/reattach-snapshot.test.ts | 20 +- .../session-ingest-throughput.bench.test.ts | 130 + src/main/daemon/session.test.ts | 142 +- src/main/daemon/session.ts | 82 +- ...rminal-history-incremental-restore.test.ts | 7 +- src/main/daemon/terminal-host.ts | 29 +- .../terminal-mode-rehydrate-sequences.ts | 47 + src/main/daemon/terminal-mouse-mode-mirror.ts | 119 + .../daemon/terminal-osc-cwd-title-scanner.ts | 53 + .../daemon/terminal-snapshot-ansi-buffers.ts | 21 + ...minal-snapshot-serialize-roundtrip.test.ts | 254 ++ .../terminal-view-attribute-responder.ts | 191 ++ src/main/daemon/types.ts | 131 +- src/main/index.ts | 28 +- src/main/ipc/pty-hidden-delivery-gate.test.ts | 118 + src/main/ipc/pty-hidden-delivery-gate.ts | 154 ++ .../ipc/pty-producer-flow-control.test.ts | 137 ++ src/main/ipc/pty-producer-flow-control.ts | 105 + src/main/ipc/pty.test.ts | 2098 +++++++++++++++-- src/main/ipc/pty.ts | 1192 +++++++++- src/main/ipc/settings.test.ts | 21 +- src/main/ipc/settings.ts | 9 + src/main/providers/local-pty-provider.test.ts | 37 + src/main/providers/local-pty-provider.ts | 20 + src/main/providers/provider-dispatch.test.ts | 3 + src/main/providers/types.ts | 68 +- src/main/runtime/orca-runtime.test.ts | 659 ++++++ src/main/runtime/orca-runtime.ts | 1152 +++++++-- ...ned-tail-redraw-window.equivalence.test.ts | 90 + src/main/runtime/rpc/methods/terminal.ts | 357 ++- src/main/runtime/rpc/streaming.test.ts | 3 + .../terminal-multiplex-escape-tail.test.ts | 1 + .../runtime/rpc/terminal-multiplex.test.ts | 942 ++++++++ .../rpc/terminal-output-batching.test.ts | 3 + .../rpc/terminal-subscribe-buffer.test.ts | 205 +- src/main/runtime/runtime-rpc.test.ts | 209 ++ .../terminal-model-query-authority.test.ts | 140 ++ .../runtime/terminal-model-query-authority.ts | 111 + .../runtime/terminal-query-responder.test.ts | 827 +++++++ .../runtime/terminal-view-attribute-store.ts | 57 + .../ssh/ssh-relay-session-test-fixtures.ts | 48 + src/main/ssh/ssh-relay-session.test.ts | 133 +- src/main/ssh/ssh-relay-session.ts | 30 +- .../synthetic-title-frame-routing.test.ts | 24 + src/main/synthetic-title-frame-routing.ts | 14 + src/main/window/createMainWindow.test.ts | 104 + src/main/window/createMainWindow.ts | 17 + src/preload/api-types.ts | 92 +- src/preload/e2e-config.ts | 5 +- src/preload/index.ts | 121 +- src/renderer/src/App.tsx | 31 + src/renderer/src/components/Terminal.tsx | 227 +- .../TerminalPaneOverlayLayer.tsx | 27 + .../agent-task-complete-policy.ts | 66 + .../hidden-reveal-reconciliation.fuzz.test.ts | 558 +++++ .../parked-terminal-byte-watcher.test.ts | 813 +++++++ .../parked-terminal-byte-watcher.ts | 347 +++ .../parked-terminal-mode2031-responder.ts | 50 + .../terminal-pane/pty-connection-types.ts | 6 + .../terminal-pane/pty-connection.test.ts | 1597 +++++++++++-- .../terminal-pane/pty-connection.ts | 1172 ++++++--- .../pty-data-sidecar-subscriptions.ts | 6 + .../terminal-pane/pty-delivery-interest.ts | 42 + .../pty-dispatcher-delivery-interest.test.ts | 107 + .../pty-dispatcher-delivery-resync.test.ts | 92 + .../pty-dispatcher-pi-routing.test.ts | 4 +- .../pty-dispatcher-push-reattach.test.ts | 111 + .../terminal-pane/pty-dispatcher.ts | 211 +- .../pty-model-restore-channel.test.ts | 86 + .../pty-model-restore-channel.ts | 66 + .../pty-renderer-delivery-claims.test.ts | 62 + .../pty-renderer-delivery-claims.ts | 122 + .../terminal-pane/pty-transport-types.ts | 24 +- .../terminal-pane/pty-transport.test.ts | 67 + .../components/terminal-pane/pty-transport.ts | 43 +- .../terminal-pane/replay-guard.test.ts | 209 +- .../components/terminal-pane/replay-guard.ts | 128 +- .../stale-document-visibility.test.ts | 157 ++ .../stale-document-visibility.ts | 109 + .../terminal-pane/terminal-appearance.test.ts | 136 +- .../terminal-pane/terminal-appearance.ts | 149 +- .../terminal-capability-replies.ts | 70 +- .../terminal-command-lifecycle.ts | 92 +- .../terminal-delivery-watchdog.test.ts | 201 ++ .../terminal-delivery-watchdog.ts | 278 +++ .../terminal-freeze-breadcrumbs.ts | 34 + .../terminal-freeze-report.test.ts | 79 + .../terminal-pane/terminal-freeze-report.ts | 59 + .../terminal-hidden-delivery-gate.ts | 45 + ...terminal-hidden-restore-scrollback.test.ts | 14 + .../terminal-hidden-restore-scrollback.ts | 13 + .../terminal-hidden-view-parking.test.ts | 529 +++++ .../terminal-hidden-view-parking.ts | 285 +++ .../terminal-parked-tab-watchers.test.ts | 564 +++++ .../terminal-parked-tab-watchers.ts | 278 +++ .../terminal-parked-watcher-registry.ts | 107 + .../terminal-parking-e2e-overrides.test.ts | 82 + .../terminal-parking-e2e-overrides.ts | 34 + .../terminal-parser-handler-guard.test.ts | 108 + .../terminal-parser-handler-guard.ts | 44 + .../terminal-pty-ack-gate.test.ts | 123 + .../terminal-pane/terminal-pty-ack-gate.ts | 89 +- .../terminal-shutdown-layout-capture.test.ts | 56 +- .../terminal-shutdown-layout-capture.ts | 12 +- ...terminal-side-effect-facts-handler.test.ts | 509 ++++ .../terminal-side-effect-facts-handler.ts | 242 ++ .../terminal-title-tracker-parity.test.ts | 378 +++ ...terminal-view-attributes-publisher.test.ts | 249 ++ .../terminal-view-attributes-publisher.ts | 251 ++ ...inal-webgl-diagnostics-breadcrumbs.test.ts | 30 + .../use-terminal-pane-global-effects.ts | 12 +- .../use-terminal-pane-lifecycle.ts | 75 +- .../use-terminal-tab-cold-parking.ts | 243 ++ .../use-terminal-window-wake-recovery.test.ts | 114 + .../use-terminal-window-wake-recovery.ts | 25 +- .../xterm-write-buffer-stall.repro.test.ts | 79 + src/renderer/src/env.d.ts | 4 + .../lib/automation-session-observer.test.ts | 129 + .../src/lib/automation-session-observer.ts | 16 +- .../src/lib/crash-breadcrumb-recorder.ts | 25 + src/renderer/src/lib/crash-diagnostics.ts | 21 +- src/renderer/src/lib/github-links.ts | 112 +- .../launch-agent-background-session.test.ts | 55 +- .../lib/launch-agent-background-session.ts | 15 +- .../lib/pane-manager/pane-manager-registry.ts | 28 + .../pane-terminal-foreground-render-settle.ts | 28 +- .../pane-terminal-output-ack-credit.ts | 45 + ...-output-scheduler-throughput.bench.test.ts | 127 + .../pane-terminal-output-scheduler.test.ts | 298 ++- .../pane-terminal-output-scheduler.ts | 333 ++- .../lib/pane-manager/pane-webgl-renderer.ts | 5 + .../windows-pty-compatibility.test.ts | 36 +- .../pane-manager/windows-pty-compatibility.ts | 11 + .../xterm-write-callback-guard.test.ts | 110 + .../xterm-write-callback-guard.ts | 40 + ...runtime-terminal-frame-drop-resync.test.ts | 6 +- .../remote-runtime-terminal-multiplexer.ts | 157 +- .../runtime/runtime-terminal-stream.test.ts | 238 +- .../slices/agent-status-quit-capture.test.ts | 46 + src/renderer/src/store/slices/agent-status.ts | 33 +- .../src/store/slices/store-cascades.test.ts | 2 +- src/renderer/src/store/slices/terminals.ts | 9 + .../src/store/slices/worktree-helpers.ts | 2 +- src/renderer/src/web/web-preload-api.ts | 29 + src/shared/agent-detection.ts | 49 +- src/shared/agent-title-core.ts | 110 + src/shared/agent-title-identity.ts | 111 + src/shared/agent-title-status.ts | 203 ++ src/shared/agent-tui-ansi-fuzz-stream.ts | 279 +++ .../command-code-output-status.test.ts | 0 .../command-code-output-status.ts | 8 + .../command-code-prompt-text.ts | 0 src/shared/constants.ts | 4 + src/shared/e2e-config.ts | 13 +- src/shared/github-links.ts | 102 + src/shared/osc-title-scan-tail.test.ts | 17 + src/shared/osc-title-scan-tail.ts | 13 +- src/shared/pty-delivery-diagnostics.test.ts | 77 + src/shared/pty-delivery-diagnostics.ts | 107 + src/shared/pty-model-restore-marker.ts | 19 + src/shared/pty-renderer-delivery-health.ts | 45 + .../terminal-bell-detector.test.ts} | 2 +- .../terminal-bell-detector.ts} | 13 +- .../terminal-github-pr-link-detector.test.ts | 0 .../terminal-github-pr-link-detector.ts | 9 + .../terminal-osc133-command-finished.ts | 108 + .../terminal-output-side-effects.test.ts | 222 ++ src/shared/terminal-output-side-effects.ts | 326 +++ src/shared/terminal-reply-query-extraction.ts | 160 ++ src/shared/terminal-restore-parity-fixture.ts | 284 +++ src/shared/terminal-scrollback-policy.test.ts | 16 +- src/shared/terminal-scrollback-policy.ts | 17 + .../terminal-serialize-absolute-cursor.ts | 95 + src/shared/terminal-side-effect-facts.ts | 56 + src/shared/terminal-stream-protocol.test.ts | 15 + src/shared/terminal-stream-protocol.ts | 8 +- src/shared/terminal-view-attributes.test.ts | 119 + src/shared/terminal-view-attributes.ts | 188 ++ src/shared/terminal-webgl-diagnostics.ts | 26 + src/shared/types.ts | 22 + ...icial-opencode-hidden-pressure-scenario.ts | 130 +- ...ificial-opencode-hidden-pressure-script.ts | 52 + ...ificial-opencode-main-pressure-scenario.ts | 9 +- .../artificial-opencode-pane-interactions.ts | 119 + ...cial-opencode-revisit-pressure-scenario.ts | 311 +++ .../artificial-opencode-terminal-load.spec.ts | 279 +-- tests/e2e/global-setup.ts | 12 +- tests/e2e/global-teardown.ts | 2 +- tests/e2e/helpers/orca-app.ts | 90 +- tests/e2e/helpers/seeded-test-repo.ts | 67 + tests/e2e/helpers/terminal-input-probes.ts | 290 +++ ...erer-crash-recovery-terminal-input.spec.ts | 172 ++ .../restart-restore-terminal-input.spec.ts | 259 ++ tests/e2e/ssh-docker-relay-perf.spec.ts | 133 ++ .../sustained-agent-typing-load-scripts.ts | 135 ++ ...terminal-hidden-tui-visual-restore.spec.ts | 205 +- .../e2e/terminal-hidden-view-parking.spec.ts | 552 +++++ ...terminal-long-table-scroll-restore.spec.ts | 26 - ...nal-multi-workspace-typing-latency.spec.ts | 602 +++++ ...inal-pane-close-layout-consistency.spec.ts | 360 +++ tests/e2e/terminal-parked-memory.spec.ts | 358 +++ ...rminal-push-delivery-loss-recovery.spec.ts | 106 + ...nal-raw-emoji-table-scroll-restore.spec.ts | 8 +- tests/e2e/terminal-sleep-wake-restore.spec.ts | 220 ++ .../terminal-stuck-occlusion-recovery.spec.ts | 147 ++ tools/benchmarks/cpu-pressure-worker.mjs | 21 + .../terminal-headless-parse-bench.mjs | 70 + tools/benchmarks/terminal-pipeline-bench.mjs | 569 +++++ 261 files changed, 38055 insertions(+), 2409 deletions(-) create mode 100644 config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch create mode 100644 config/scripts/generate-terminal-perf-html-report.mjs create mode 100644 config/scripts/generate-terminal-perf-html-report.test.mjs create mode 100644 config/scripts/run-multi-workspace-typing-bench.mjs create mode 100644 config/scripts/terminal-perf-report-rows.mjs create mode 100644 docs/reference/terminal-hidden-view-parking.md create mode 100644 docs/reference/terminal-model-view-contract.md create mode 100644 docs/reference/terminal-query-authority.md create mode 100644 docs/reference/terminal-side-effect-authority.md create mode 100644 notes/garble-fuzz-divergences.md create mode 100644 notes/orca-performance-branch-guide.md create mode 100644 notes/terminal-performance-initiative.md create mode 100644 src/main/daemon/daemon-background-transient-facts.test.ts create mode 100644 src/main/daemon/daemon-background-transient-facts.ts create mode 100644 src/main/daemon/daemon-errors.ts create mode 100644 src/main/daemon/daemon-stream-backlog-probe.ts create mode 100644 src/main/daemon/daemon-stream-data-split.ts create mode 100644 src/main/daemon/daemon-stream-events.ts create mode 100644 src/main/daemon/daemon-stream-keep-tail-drop.ts create mode 100644 src/main/daemon/degraded-daemon-fallback-shutdown.ts create mode 100644 src/main/daemon/headless-emulator-fidelity.fuzz.test.ts create mode 100644 src/main/daemon/session-ingest-throughput.bench.test.ts create mode 100644 src/main/daemon/terminal-mode-rehydrate-sequences.ts create mode 100644 src/main/daemon/terminal-mouse-mode-mirror.ts create mode 100644 src/main/daemon/terminal-osc-cwd-title-scanner.ts create mode 100644 src/main/daemon/terminal-snapshot-ansi-buffers.ts create mode 100644 src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts create mode 100644 src/main/daemon/terminal-view-attribute-responder.ts create mode 100644 src/main/ipc/pty-hidden-delivery-gate.test.ts create mode 100644 src/main/ipc/pty-hidden-delivery-gate.ts create mode 100644 src/main/ipc/pty-producer-flow-control.test.ts create mode 100644 src/main/ipc/pty-producer-flow-control.ts create mode 100644 src/main/runtime/retained-tail-redraw-window.equivalence.test.ts create mode 100644 src/main/runtime/terminal-model-query-authority.test.ts create mode 100644 src/main/runtime/terminal-model-query-authority.ts create mode 100644 src/main/runtime/terminal-query-responder.test.ts create mode 100644 src/main/runtime/terminal-view-attribute-store.ts create mode 100644 src/main/ssh/ssh-relay-session-test-fixtures.ts create mode 100644 src/main/synthetic-title-frame-routing.test.ts create mode 100644 src/main/synthetic-title-frame-routing.ts create mode 100644 src/renderer/src/components/terminal-pane/agent-task-complete-policy.ts create mode 100644 src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts create mode 100644 src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts create mode 100644 src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts create mode 100644 src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-delivery-interest.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts create mode 100644 src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts create mode 100644 src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts create mode 100644 src/renderer/src/components/terminal-pane/stale-document-visibility.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-freeze-report.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts create mode 100644 src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts create mode 100644 src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts create mode 100644 src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts create mode 100644 src/renderer/src/lib/automation-session-observer.test.ts create mode 100644 src/renderer/src/lib/crash-breadcrumb-recorder.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts create mode 100644 src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts create mode 100644 src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts create mode 100644 src/renderer/src/lib/pane-manager/xterm-write-callback-guard.ts create mode 100644 src/shared/agent-title-core.ts create mode 100644 src/shared/agent-title-identity.ts create mode 100644 src/shared/agent-title-status.ts create mode 100644 src/shared/agent-tui-ansi-fuzz-stream.ts rename src/{renderer/src/components/terminal-pane => shared}/command-code-output-status.test.ts (100%) rename src/{renderer/src/components/terminal-pane => shared}/command-code-output-status.ts (95%) rename src/{renderer/src/components/terminal-pane => shared}/command-code-prompt-text.ts (100%) create mode 100644 src/shared/github-links.ts create mode 100644 src/shared/osc-title-scan-tail.test.ts create mode 100644 src/shared/pty-delivery-diagnostics.test.ts create mode 100644 src/shared/pty-delivery-diagnostics.ts create mode 100644 src/shared/pty-model-restore-marker.ts create mode 100644 src/shared/pty-renderer-delivery-health.ts rename src/{renderer/src/components/terminal-pane/bell-detector.test.ts => shared/terminal-bell-detector.test.ts} (93%) rename src/{renderer/src/components/terminal-pane/bell-detector.ts => shared/terminal-bell-detector.ts} (82%) rename src/{renderer/src/lib => shared}/terminal-github-pr-link-detector.test.ts (100%) rename src/{renderer/src/lib => shared}/terminal-github-pr-link-detector.ts (91%) create mode 100644 src/shared/terminal-osc133-command-finished.ts create mode 100644 src/shared/terminal-output-side-effects.test.ts create mode 100644 src/shared/terminal-output-side-effects.ts create mode 100644 src/shared/terminal-reply-query-extraction.ts create mode 100644 src/shared/terminal-restore-parity-fixture.ts create mode 100644 src/shared/terminal-serialize-absolute-cursor.ts create mode 100644 src/shared/terminal-side-effect-facts.ts create mode 100644 src/shared/terminal-view-attributes.test.ts create mode 100644 src/shared/terminal-view-attributes.ts create mode 100644 src/shared/terminal-webgl-diagnostics.ts create mode 100644 tests/e2e/artificial-opencode-hidden-pressure-script.ts create mode 100644 tests/e2e/artificial-opencode-pane-interactions.ts create mode 100644 tests/e2e/artificial-opencode-revisit-pressure-scenario.ts create mode 100644 tests/e2e/helpers/seeded-test-repo.ts create mode 100644 tests/e2e/helpers/terminal-input-probes.ts create mode 100644 tests/e2e/renderer-crash-recovery-terminal-input.spec.ts create mode 100644 tests/e2e/restart-restore-terminal-input.spec.ts create mode 100644 tests/e2e/sustained-agent-typing-load-scripts.ts create mode 100644 tests/e2e/terminal-hidden-view-parking.spec.ts create mode 100644 tests/e2e/terminal-multi-workspace-typing-latency.spec.ts create mode 100644 tests/e2e/terminal-pane-close-layout-consistency.spec.ts create mode 100644 tests/e2e/terminal-parked-memory.spec.ts create mode 100644 tests/e2e/terminal-push-delivery-loss-recovery.spec.ts create mode 100644 tests/e2e/terminal-sleep-wake-restore.spec.ts create mode 100644 tests/e2e/terminal-stuck-occlusion-recovery.spec.ts create mode 100644 tools/benchmarks/cpu-pressure-worker.mjs create mode 100644 tools/benchmarks/terminal-headless-parse-bench.mjs create mode 100644 tools/benchmarks/terminal-pipeline-bench.mjs diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index 77e1dbcb7f8..397772bfb7e 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -41,6 +41,11 @@ on: required: false default: false type: boolean + version_suffix: + description: Extra prerelease identifier appended to an rc version (e.g. "perf" -> 1.2.3-rc.4.perf). rc kind only. + required: false + type: string + default: '' permissions: contents: write @@ -192,6 +197,7 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} KIND: ${{ github.event_name == 'schedule' && 'rc' || inputs.kind }} + VERSION_SUFFIX: ${{ github.event_name == 'schedule' && '' || inputs.version_suffix }} run: | set -euo pipefail @@ -366,6 +372,18 @@ jobs: fi new="${base}-rc.$((highest_rc + 1))" fi + if [[ -n "${VERSION_SUFFIX:-}" ]]; then + # Why a dot-appended identifier (rc.N.perf): it sorts just + # above its own base rc.N but BELOW rc.N+1, so suffixed side- + # branch builds never outrank the main RC series and cannot + # hijack the update channel; clients find them by matching the + # identifier ("perf") in the prerelease components. + if [[ ! "$VERSION_SUFFIX" =~ ^[0-9A-Za-z]+$ ]]; then + echo "::error::version_suffix must be alphanumeric, got: $VERSION_SUFFIX" >&2 + exit 1 + fi + new="${new}.${VERSION_SUFFIX}" + fi ;; patch|minor|major) new="$(bump "$latest_stable" "$KIND")" @@ -808,7 +826,10 @@ jobs: TAG: ${{ needs.cut.outputs.tag }} run: | set -euo pipefail - if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + # Why the optional trailing identifier: suffixed side-branch RCs + # (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry + # identity as plain RCs. + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then identity=rc elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then identity=stable diff --git a/.github/workflows/release-mac-build.yml b/.github/workflows/release-mac-build.yml index e82502baa47..3638d40f00c 100644 --- a/.github/workflows/release-mac-build.yml +++ b/.github/workflows/release-mac-build.yml @@ -96,7 +96,10 @@ jobs: TAG: ${{ inputs.tag }} run: | set -euo pipefail - if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+$ ]]; then + # Why the optional trailing identifier: suffixed side-branch RCs + # (vX.Y.Z-rc.N.perf) are rc-channel prerelease builds — same telemetry + # identity as plain RCs. + if [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-rc\.[0-9]+(\.[0-9A-Za-z]+)?$ ]]; then identity=rc elif [[ "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then identity=stable diff --git a/.gitignore b/.gitignore index 50c46ec9f8d..ed81a687a69 100644 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,6 @@ src/renderer/src/i18n/locales/.zh-catalog-cache.json src/renderer/src/i18n/locales/.ko-catalog-cache.json src/renderer/src/i18n/locales/.ja-catalog-cache.json src/renderer/src/i18n/locales/.es-catalog-cache.json + +# Bench result JSONs are working artifacts; headline numbers live in notes/terminal-performance-initiative.md +tools/benchmarks/results/terminal-pipeline-*.json diff --git a/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch b/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch new file mode 100644 index 00000000000..eab0cd58452 --- /dev/null +++ b/config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch @@ -0,0 +1,55 @@ +diff --git a/lib/addon-serialize.js b/lib/addon-serialize.js +index d669293d06d801ef472d5c2454ce86876e5c321f..96bb66cdc05289b6a7ce5ed64fc833624c885c57 100644 +--- a/lib/addon-serialize.js ++++ b/lib/addon-serialize.js +@@ -1,2 +1,2 @@ +-!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=f;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function f(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,f=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,f=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(f(i,n)>8));if(of(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=f(i,u.relativeLuminance(o>>8));if(af(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t,e,r){return Math.max(e,Math.min(t,r))}class n{constructor(t){this._buffer=t}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,o=t.end.y,l=t.start.x,a=t.end.x;this._beforeSerialize(o-n,n,o);for(let e=n;e<=o;e++){const n=this._buffer.getLine(e);if(n){const o=e===t.start.y?l:0,u=e===t.end.y?a:n.length;for(let t=o;t0&&!l(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=e.getCell(e.length-1,this._thisRowLastChar),n=e.getCell(e.length-2,this._thisRowLastSecondChar),o=s.getCell(0,this._nextRowFirstChar),a=o.getWidth()>1;let u=!1;(o.getChars()&&a?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||0===i.getWidth())&&l(i,o)&&(u=!0),a&&(n.getChars()||0===n.getWidth())&&l(i,o)&&l(n,o)&&(u=!0)),u||(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0&&(r+="",r+=`[${e.length-this._nullCellCount}C`,r+=`[${this._nullCellCount}X`,r+=`[${e.length-this._nullCellCount}D`,r+=""),this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0)}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(h(t,e))return r;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){if(t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27),t.isBold()!==e.isBold()&&r.push(t.isBold()?1:22),a(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isDim()!==e.isDim()&&r.push(t.isDim()?2:22),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,this._cursorStyle);if(i?!l(this._cursorStyle,t):n.length>0){this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${n.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}i?this._nullCellCount+=t.getWidth():(this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._currentRow+=t.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth())}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(e=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let t=0;t{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,n=void 0===r?s:i(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-n,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new c(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new f(r,t,e),n=e.onlySelection??!1,o=e.range;if(o)return s.serialize({start:{x:o.startCol,y:(o.startLine,o.startLine)},end:{x:t.cols,y:(o.endLine,o.endLine)}});if(!n){const n=r.length,o=e.scrollback,l=void 0===o?n:i(o+t.rows,0,n);return s.serialize({start:{x:0,y:n-l},end:{x:t.cols,y:n-1}})}const l=this._terminal?.getSelectionPosition();return void 0!==l?s.serialize({start:{x:l.start.x,y:l.start.y},end:{x:l.end.x,y:l.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class f extends n{constructor(t,r,s){super(t),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(h(t,e))return;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=f})(),s})()); ++!function(t,e){"object"==typeof exports&&"object"==typeof module?module.exports=e():"function"==typeof define&&define.amd?define([],e):"object"==typeof exports?exports.SerializeAddon=e():t.SerializeAddon=e()}(globalThis,()=>(()=>{"use strict";var t={992(t,e,r){Object.defineProperty(e,"__esModule",{value:!0}),e.DEFAULT_ANSI_COLORS=void 0;const s=r(993);e.DEFAULT_ANSI_COLORS=Object.freeze((()=>{const t=[s.css.toColor("#2e3436"),s.css.toColor("#cc0000"),s.css.toColor("#4e9a06"),s.css.toColor("#c4a000"),s.css.toColor("#3465a4"),s.css.toColor("#75507b"),s.css.toColor("#06989a"),s.css.toColor("#d3d7cf"),s.css.toColor("#555753"),s.css.toColor("#ef2929"),s.css.toColor("#8ae234"),s.css.toColor("#fce94f"),s.css.toColor("#729fcf"),s.css.toColor("#ad7fa8"),s.css.toColor("#34e2e2"),s.css.toColor("#eeeeec")],e=[0,95,135,175,215,255];for(let r=0;r<216;r++){const i=e[r/36%6|0],n=e[r/6%6|0],o=e[r%6];t.push({css:s.channels.toCss(i,n,o),rgba:s.channels.toRgba(i,n,o)})}for(let e=0;e<24;e++){const r=8+10*e;t.push({css:s.channels.toCss(r,r,r),rgba:s.channels.toRgba(r,r,r)})}return t})())},993(t,e){Object.defineProperty(e,"__esModule",{value:!0}),e.rgba=e.rgb=e.css=e.color=e.channels=e.NULL_COLOR=void 0,e.toPaddedHex=c,e.contrastRatio=f;let r=0,s=0,i=0,n=0;var o,l,a,u,h;function c(t){const e=t.toString(16);return e.length<2?"0"+e:e}function f(t,e){return t>>0},t.toColor=function(e,r,s,i){return{css:t.toCss(e,r,s,i),rgba:t.toRgba(e,r,s,i)}}}(o||(e.channels=o={})),function(t){function e(t,e){return n=Math.round(255*e),[r,s,i]=h.toChannels(t.rgba),{css:o.toCss(r,s,i,n),rgba:o.toRgba(r,s,i,n)}}t.blend=function(t,e){if(n=(255&e.rgba)/255,1===n)return{css:e.css,rgba:e.rgba};const l=e.rgba>>24&255,a=e.rgba>>16&255,u=e.rgba>>8&255,h=t.rgba>>24&255,c=t.rgba>>16&255,f=t.rgba>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),{css:o.toCss(r,s,i),rgba:o.toRgba(r,s,i)}},t.isOpaque=function(t){return!(255&~t.rgba)},t.ensureContrastRatio=function(t,e,r){const s=h.ensureContrastRatio(t.rgba,e.rgba,r);if(s)return o.toColor(s>>24&255,s>>16&255,s>>8&255)},t.opaque=function(t){const e=(255|t.rgba)>>>0;return[r,s,i]=h.toChannels(e),{css:o.toCss(r,s,i),rgba:e}},t.opacity=e,t.multiplyOpacity=function(t,r){return n=255&t.rgba,e(t,n*r/255)},t.toColorRGB=function(t){return[t.rgba>>24&255,t.rgba>>16&255,t.rgba>>8&255]}}(l||(e.color=l={})),function(t){let e,l;try{const t=document.createElement("canvas");t.width=1,t.height=1;const r=t.getContext("2d",{willReadFrequently:!0});r&&(e=r,e.globalCompositeOperation="copy",l=e.createLinearGradient(0,0,1,1))}catch{}t.toColor=function(t){if(t.match(/#[\da-f]{3,8}/i))switch(t.length){case 4:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),o.toColor(r,s,i);case 5:return r=parseInt(t.slice(1,2).repeat(2),16),s=parseInt(t.slice(2,3).repeat(2),16),i=parseInt(t.slice(3,4).repeat(2),16),n=parseInt(t.slice(4,5).repeat(2),16),o.toColor(r,s,i,n);case 7:return{css:t,rgba:(parseInt(t.slice(1),16)<<8|255)>>>0};case 9:return{css:t,rgba:parseInt(t.slice(1),16)>>>0}}const a=t.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(a)return r=parseInt(a[1],10),s=parseInt(a[2],10),i=parseInt(a[3],10),n=Math.round(255*(void 0===a[5]?1:parseFloat(a[5]))),o.toColor(r,s,i,n);if("transparent"===t)return{css:"transparent",rgba:0};if(!e||!l)throw new Error("css.toColor: Unsupported css format");if(e.fillStyle=l,e.fillStyle=t,"string"!=typeof e.fillStyle)throw new Error("css.toColor: Unsupported css format");if(e.fillRect(0,0,1,1),[r,s,i,n]=e.getImageData(0,0,1,1).data,255!==n)throw new Error("css.toColor: Unsupported css format");return{rgba:o.toRgba(r,s,i,n),css:t}}}(a||(e.css=a={})),function(t){function e(t,e,r){const s=t/255,i=e/255,n=r/255;return.2126*(s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4))+.7152*(i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4))+.0722*(n<=.03928?n/12.92:Math.pow((n+.055)/1.055,2.4))}t.relativeLuminance=function(t){return e(t>>16&255,t>>8&255,255&t)},t.relativeLuminance2=e}(u||(e.rgb=u={})),function(t){function e(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h0||l>0||a>0);)o-=Math.max(0,Math.ceil(.1*o)),l-=Math.max(0,Math.ceil(.1*l)),a-=Math.max(0,Math.ceil(.1*a)),h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));return(o<<24|l<<16|a<<8|255)>>>0}function l(t,e,r){const s=t>>24&255,i=t>>16&255,n=t>>8&255;let o=e>>24&255,l=e>>16&255,a=e>>8&255,h=f(u.relativeLuminance2(o,l,a),u.relativeLuminance2(s,i,n));for(;h>>0}t.blend=function(t,e){if(n=(255&e)/255,1===n)return e;const l=e>>24&255,a=e>>16&255,u=e>>8&255,h=t>>24&255,c=t>>16&255,f=t>>8&255;return r=h+Math.round((l-h)*n),s=c+Math.round((a-c)*n),i=f+Math.round((u-f)*n),o.toRgba(r,s,i)},t.ensureContrastRatio=function(t,r,s){const i=u.relativeLuminance(t>>8),n=u.relativeLuminance(r>>8);if(f(i,n)>8));if(of(i,u.relativeLuminance(e>>8))?n:e}return n}const o=l(t,r,s),a=f(i,u.relativeLuminance(o>>8));if(af(i,u.relativeLuminance(n>>8))?o:n}return o}},t.reduceLuminance=e,t.increaseLuminance=l,t.toChannels=function(t){return[t>>24&255,t>>16&255,t>>8&255,255&t]}}(h||(e.rgba=h={}))}},e={};function r(s){var i=e[s];if(void 0!==i)return i.exports;var n=e[s]={exports:{}};return t[s](n,n.exports,r),n.exports}var s={};return(()=>{var t=s;Object.defineProperty(t,"__esModule",{value:!0}),t.HTMLSerializeHandler=t.SerializeAddon=void 0;const e=r(992);function i(t,e,r){return Math.max(e,Math.min(t,r))}class n{constructor(t){this._buffer=t}serialize(t,e){const r=this._buffer.getNullCell(),s=this._buffer.getNullCell();let i=r;const n=t.start.y,o=t.end.y,l=t.start.x,a=t.end.x;this._beforeSerialize(o-n,n,o);for(let e=n;e<=o;e++){const n=this._buffer.getLine(e);if(n){const o=e===t.start.y?l:0,u=e===t.end.y?a:n.length;for(let t=o;t0&&!l(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`[${this._nullCellCount}X`);let r="";if(!e){t-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);const e=this._buffer.getLine(t),s=this._buffer.getLine(t+1);if(s.isWrapped){r="";const i=e.getCell(e.length-1,this._thisRowLastChar),n=e.getCell(e.length-2,this._thisRowLastSecondChar),o=s.getCell(0,this._nextRowFirstChar),a=o.getWidth()>1;let u=!1;(o.getChars()&&a?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||0===i.getWidth())&&l(i,o)&&(u=!0),a&&(n.getChars()||0===n.getWidth())&&l(i,o)&&l(n,o)&&(u=!0)),u||(r="-".repeat(this._nullCellCount+1),r+="",this._nullCellCount>0&&(r+="",r+=`[${e.length-this._nullCellCount}C`,r+=`[${this._nullCellCount}X`,r+=`[${e.length-this._nullCellCount}D`,r+=""),this._lastContentCursorRow=t+1,this._lastContentCursorCol=0,this._lastCursorRow=t+1,this._lastCursorCol=0)}else r="\r\n",this._lastCursorRow=t+1,this._lastCursorCol=0}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(t,e){const r=[];if(h(t,e))return r;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n)if(t.isAttributeDefault())e.isAttributeDefault()||r.push(0);else{if(s){const e=t.getFgColor();t.isFgRGB()?r.push(38,2,e>>>16&255,e>>>8&255,255&e):t.isFgPalette()?e>=16?r.push(38,5,e):r.push(8&e?90+(7&e):30+(7&e)):r.push(39)}if(i){const e=t.getBgColor();t.isBgRGB()?r.push(48,2,e>>>16&255,e>>>8&255,255&e):t.isBgPalette()?e>=16?r.push(48,5,e):r.push(8&e?100+(7&e):40+(7&e)):r.push(49)}if(n){if(t.isInverse()!==e.isInverse()&&r.push(t.isInverse()?7:27),/* PATCH(orca): bold(1)/dim(2) share reset 22 - clear before re-set, as one group */((b,d)=>{if(b||d){const c=b&&!t.isBold()||d&&!t.isDim();c&&r.push(22),t.isBold()&&(b||c)&&r.push(1),t.isDim()&&(d||c)&&r.push(2)}})(t.isBold()!==e.isBold(),t.isDim()!==e.isDim()),a(t,e))t.isUnderline()!==e.isUnderline()&&r.push(t.isUnderline()?4:24);else{const e=t.getUnderlineStyle();if(0===e)r.push(24);else if(1===e&&t.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+e),!t.isUnderlineColorDefault()){const e=t.getUnderlineColor();t.isUnderlineColorRGB()?r.push("58:2::"+(e>>>16&255)+":"+(e>>>8&255)+":"+(255&e)):r.push("58:5:"+e)}}t.isOverline()!==e.isOverline()&&r.push(t.isOverline()?53:55),t.isBlink()!==e.isBlink()&&r.push(t.isBlink()?5:25),t.isInvisible()!==e.isInvisible()&&r.push(t.isInvisible()?8:28),t.isItalic()!==e.isItalic()&&r.push(t.isItalic()?3:23),t.isStrikethrough()!==e.isStrikethrough()&&r.push(t.isStrikethrough()?9:29)}}return r}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,this._cursorStyle);if(i?!l(this._cursorStyle,t):n.length>0){this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s,this._currentRow+=`[${n.join(";")}m`;const t=this._buffer.getLine(r);void 0!==t&&(t.getCell(s,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=s)}i?this._nullCellCount+=t.getWidth():(this._nullCellCount>0&&(l(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`[${this._nullCellCount}X`),this._currentRow+=`[${this._nullCellCount}C`,this._nullCellCount=0),this._currentRow+=t.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=s+t.getWidth())}_serializeString(t){let e=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(e=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let t=0;t{t>0?r+=`[${t}C`:t<0&&(r+=`[${-t}D`)};(t!==this._lastCursorRow||e!==this._lastCursorCol)&&((s=t-this._lastCursorRow)>0?r+=`[${s}B`:s<0&&(r+=`[${-s}A`),i(e-this._lastCursorCol))}var s;const i=this._terminal._core._inputHandler._curAttrData,n=this._diffStyle(i,this._cursorStyle);return n.length>0&&(r+=`[${n.join(";")}m`),r}}t.SerializeAddon=class{activate(t){this._terminal=t}_serializeBufferByScrollback(t,e,r){const s=e.length,n=void 0===r?s:i(r+t.rows,0,s);return this._serializeBufferByRange(t,e,{start:s-n,end:s-1},!1)}_serializeBufferByRange(t,e,r,s){return new c(e,t).serialize({start:{x:0,y:"number"==typeof r.start?r.start:r.start.line},end:{x:t.cols,y:"number"==typeof r.end?r.end:r.end.line}},s)}_serializeBufferAsHTML(t,e){const r=t.buffer.active,s=new f(r,t,e),n=e.onlySelection??!1,o=e.range;if(o)return s.serialize({start:{x:o.startCol,y:(o.startLine,o.startLine)},end:{x:t.cols,y:(o.endLine,o.endLine)}});if(!n){const n=r.length,o=e.scrollback,l=void 0===o?n:i(o+t.rows,0,n);return s.serialize({start:{x:0,y:n-l},end:{x:t.cols,y:n-1}})}const l=this._terminal?.getSelectionPosition();return void 0!==l?s.serialize({start:{x:l.start.x,y:l.start.y},end:{x:l.end.x,y:l.end.y}}):""}_serializeScrollRegion(t){const e=t._core.buffer,r=e.scrollTop,s=e.scrollBottom;return 0!==r||s!==t.rows-1?`[${r+1};${s+1}r`:""}_serializeModes(t){let e="";const r=t.modes;if(r.applicationCursorKeysMode&&(e+="[?1h"),r.applicationKeypadMode&&(e+="[?66h"),r.bracketedPasteMode&&(e+="[?2004h"),r.insertMode&&(e+=""),r.originMode&&(e+="[?6h"),r.reverseWraparoundMode&&(e+="[?45h"),r.sendFocusMode&&(e+="[?1004h"),!1===r.wraparoundMode&&(e+="[?7l"),"none"!==r.mouseTrackingMode)switch(r.mouseTrackingMode){case"x10":e+="[?9h";break;case"vt200":e+="[?1000h";break;case"drag":e+="[?1002h";break;case"any":e+="[?1003h"}return r.showCursor||(e+="[?25l"),e}serialize(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=t?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,t.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,t?.scrollback);return t?.excludeAltBuffer||"alternate"!==this._terminal.buffer.active.type||(e+=`[?1049h${this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0)}`),t?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(t){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,t??{})}dispose(){}};class f extends n{constructor(t,r,s){super(t),this._terminal=r,this._options=s,this._currentRow="",this._htmlContent="",r._core._themeService?this._ansiColors=r._core._themeService.colors.ansi:this._ansiColors=e.DEFAULT_ANSI_COLORS}_beforeSerialize(t,e,r){this._htmlContent+="\x3c!--StartFragment--\x3e
";let s="#000000",i="#ffffff";this._options.includeGlobalBackground&&(s=this._terminal.options.theme?.foreground??"#ffffff",i=this._terminal.options.theme?.background??"#000000");const n=[];n.push("color: "+s+";"),n.push("background-color: "+i+";"),n.push("font-family: "+this._terminal.options.fontFamily+";"),n.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
\x3c!--EndFragment--\x3e"}_rowEnd(t,e){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(t,e){const r=e?t.getFgColor():t.getBgColor();return(e?t.isFgRGB():t.isBgRGB())?"#"+[r>>16&255,r>>8&255,255&r].map(t=>t.toString(16).padStart(2,"0")).join(""):(e?t.isFgPalette():t.isBgPalette())?this._ansiColors[r].css:void 0}_getUnderlineColor(t){if(t.isUnderlineColorDefault())return;const e=t.getUnderlineColor();return t.isUnderlineColorRGB()?"#"+[e>>16&255,e>>8&255,255&e].map(t=>t.toString(16).padStart(2,"0")).join(""):this._ansiColors[e].css}_getUnderlineStyle(t){switch(t.getUnderlineStyle()){case 1:default:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed"}}_diffStyle(t,e){const r=[];if(h(t,e))return;const s=!o(t,e),i=!l(t,e),n=!u(t,e);if(s||i||n){const e=this._getHexColor(t,!0);e&&r.push("color: "+e+";");const s=this._getHexColor(t,!1);s&&r.push("background-color: "+s+";"),t.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),t.isBold()&&r.push("font-weight: bold;");const i=[];if(t.isUnderline()&&i.push(this._getUnderlineStyle(t)),t.isOverline()&&i.push("overline"),t.isStrikethrough()&&i.push("line-through"),t.isBlink()&&i.push("blink"),i.length>0&&r.push("text-decoration: "+i.join(" ")+";"),t.isUnderline()){const e=this._getUnderlineColor(t);e&&r.push("text-decoration-color: "+e+";")}return t.isInvisible()&&r.push("visibility: hidden;"),t.isItalic()&&r.push("font-style: italic;"),t.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(t,e,r,s){if(0===t.getWidth())return;const i=""===t.getChars(),n=this._diffStyle(t,e);n&&(this._currentRow+=0===n.length?"
":""),this._currentRow+=i?" ":function(t){switch(t){case"&":return"&";case"<":return"<"}return t}(t.getChars())}_serializeString(){return this._htmlContent}}t.HTMLSerializeHandler=f})(),s})()); + //# sourceMappingURL=addon-serialize.js.map +\ No newline at end of file +diff --git a/lib/addon-serialize.mjs b/lib/addon-serialize.mjs +index a5c4c4dd05b7efcc23d0e45661ffd4e42da80f21..475e45b3ed228027531deea5530bd03859f4a4e0 100644 +--- a/lib/addon-serialize.mjs ++++ b/lib/addon-serialize.mjs +@@ -15,5 +15,5 @@ + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + var m=0,b=0,_=0,p=0;var g;(t=>{function a(r,l,s,i){return i!==void 0?`#${w(r)}${w(l)}${w(s)}${w(i)}`:`#${w(r)}${w(l)}${w(s)}`}t.toCss=a;function n(r,l,s,i=255){return(r<<24|l<<16|s<<8|i)>>>0}t.toRgba=n;function e(r,l,s,i){return{css:t.toCss(r,l,s,i),rgba:t.toRgba(r,l,s,i)}}t.toColor=e})(g||={});var N;(i=>{function a(o,u){if(p=(u.rgba&255)/255,p===1)return{css:u.css,rgba:u.rgba};let f=u.rgba>>24&255,C=u.rgba>>16&255,c=u.rgba>>8&255,h=o.rgba>>24&255,d=o.rgba>>16&255,I=o.rgba>>8&255;m=h+Math.round((f-h)*p),b=d+Math.round((C-d)*p),_=I+Math.round((c-I)*p);let L=g.toCss(m,b,_),E=g.toRgba(m,b,_);return{css:L,rgba:E}}i.blend=a;function n(o){return(o.rgba&255)===255}i.isOpaque=n;function e(o,u,f){let C=B.ensureContrastRatio(o.rgba,u.rgba,f);if(C)return g.toColor(C>>24&255,C>>16&255,C>>8&255)}i.ensureContrastRatio=e;function t(o){let u=(o.rgba|255)>>>0;return[m,b,_]=B.toChannels(u),{css:g.toCss(m,b,_),rgba:u}}i.opaque=t;function r(o,u){return p=Math.round(u*255),[m,b,_]=B.toChannels(o.rgba),{css:g.toCss(m,b,_,p),rgba:g.toRgba(m,b,_,p)}}i.opacity=r;function l(o,u){return p=o.rgba&255,r(o,p*u/255)}i.multiplyOpacity=l;function s(o){return[o.rgba>>24&255,o.rgba>>16&255,o.rgba>>8&255]}i.toColorRGB=s})(N||={});var x;(t=>{let a,n;try{let r=document.createElement("canvas");r.width=1,r.height=1;let l=r.getContext("2d",{willReadFrequently:!0});l&&(a=l,a.globalCompositeOperation="copy",n=a.createLinearGradient(0,0,1,1))}catch{}function e(r){if(r.match(/#[\da-f]{3,8}/i))switch(r.length){case 4:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),g.toColor(m,b,_);case 5:return m=parseInt(r.slice(1,2).repeat(2),16),b=parseInt(r.slice(2,3).repeat(2),16),_=parseInt(r.slice(3,4).repeat(2),16),p=parseInt(r.slice(4,5).repeat(2),16),g.toColor(m,b,_,p);case 7:return{css:r,rgba:(parseInt(r.slice(1),16)<<8|255)>>>0};case 9:return{css:r,rgba:parseInt(r.slice(1),16)>>>0}}let l=r.match(/rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/);if(l)return m=parseInt(l[1],10),b=parseInt(l[2],10),_=parseInt(l[3],10),p=Math.round((l[5]===void 0?1:parseFloat(l[5]))*255),g.toColor(m,b,_,p);if(r==="transparent")return{css:"transparent",rgba:0};if(!a||!n)throw new Error("css.toColor: Unsupported css format");if(a.fillStyle=n,a.fillStyle=r,typeof a.fillStyle!="string")throw new Error("css.toColor: Unsupported css format");if(a.fillRect(0,0,1,1),[m,b,_,p]=a.getImageData(0,0,1,1).data,p!==255)throw new Error("css.toColor: Unsupported css format");return{rgba:g.toRgba(m,b,_,p),css:r}}t.toColor=e})(x||={});var v;(e=>{function a(t){return n(t>>16&255,t>>8&255,t&255)}e.relativeLuminance=a;function n(t,r,l){let s=t/255,i=r/255,o=l/255,u=s<=.03928?s/12.92:Math.pow((s+.055)/1.055,2.4),f=i<=.03928?i/12.92:Math.pow((i+.055)/1.055,2.4),C=o<=.03928?o/12.92:Math.pow((o+.055)/1.055,2.4);return u*.2126+f*.7152+C*.0722}e.relativeLuminance2=n})(v||={});var B;(l=>{function a(s,i){if(p=(i&255)/255,p===1)return i;let o=i>>24&255,u=i>>16&255,f=i>>8&255,C=s>>24&255,c=s>>16&255,h=s>>8&255;return m=C+Math.round((o-C)*p),b=c+Math.round((u-c)*p),_=h+Math.round((f-h)*p),g.toRgba(m,b,_)}l.blend=a;function n(s,i,o){let u=v.relativeLuminance(s>>8),f=v.relativeLuminance(i>>8);if(R(u,f)>8));if(I>8));return I>E?d:L}return d}let c=t(s,i,o),h=R(u,v.relativeLuminance(c>>8));if(h>8));return h>I?c:d}return c}}l.ensureContrastRatio=n;function e(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I0||h>0||d>0);)c-=Math.max(0,Math.ceil(c*.1)),h-=Math.max(0,Math.ceil(h*.1)),d-=Math.max(0,Math.ceil(d*.1)),I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));return(c<<24|h<<16|d<<8|255)>>>0}l.reduceLuminance=e;function t(s,i,o){let u=s>>24&255,f=s>>16&255,C=s>>8&255,c=i>>24&255,h=i>>16&255,d=i>>8&255,I=R(v.relativeLuminance2(c,h,d),v.relativeLuminance2(u,f,C));for(;I>>0}l.increaseLuminance=t;function r(s){return[s>>24&255,s>>16&255,s>>8&255,s&255]}l.toChannels=r})(B||={});function w(a){let n=a.toString(16);return n.length<2?"0"+n:n}function R(a,n){return a{let a=[x.toColor("#2e3436"),x.toColor("#cc0000"),x.toColor("#4e9a06"),x.toColor("#c4a000"),x.toColor("#3465a4"),x.toColor("#75507b"),x.toColor("#06989a"),x.toColor("#d3d7cf"),x.toColor("#555753"),x.toColor("#ef2929"),x.toColor("#8ae234"),x.toColor("#fce94f"),x.toColor("#729fcf"),x.toColor("#ad7fa8"),x.toColor("#34e2e2"),x.toColor("#eeeeec")],n=[0,95,135,175,215,255];for(let e=0;e<216;e++){let t=n[e/36%6|0],r=n[e/6%6|0],l=n[e%6];a.push({css:g.toCss(t,r,l),rgba:g.toRgba(t,r,l)})}for(let e=0;e<24;e++){let t=8+e*10;a.push({css:g.toCss(t,t,t),rgba:g.toRgba(t,t,t)})}return a})());function A(a,n,e){return Math.max(n,Math.min(a,e))}function z(a){switch(a){case"&":return"&";case"<":return"<"}return a}var S=class{constructor(n){this._buffer=n}serialize(n,e){let t=this._buffer.getNullCell(),r=this._buffer.getNullCell(),l=t,s=n.start.y,i=n.end.y,o=n.start.x,u=n.end.x;this._beforeSerialize(i-s,s,i);for(let f=s;f<=i;f++){let C=this._buffer.getLine(f);if(C){let c=f===n.start.y?o:0,h=f===n.end.y?u:C.length;for(let d=c;d0&&!F(this._cursorStyle,this._backgroundCell)&&(this._currentRow+=`\x1B[${this._nullCellCount}X`);let r="";if(!t){e-this._firstRow>=this._terminal.rows&&this._buffer.getLine(this._cursorStyleRow)?.getCell(this._cursorStyleCol,this._backgroundCell);let l=this._buffer.getLine(e),s=this._buffer.getLine(e+1);if(!s.isWrapped)r=`\r +-`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{r="";let i=l.getCell(l.length-1,this._thisRowLastChar),o=l.getCell(l.length-2,this._thisRowLastSecondChar),u=s.getCell(0,this._nextRowFirstChar),f=u.getWidth()>1,C=!1;(u.getChars()&&f?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||i.getWidth()===0)&&F(i,u)&&(C=!0),f&&(o.getChars()||o.getWidth()===0)&&F(i,u)&&F(o,u)&&(C=!0)),C||(r="-".repeat(this._nullCellCount+1),r+="\x1B[1D\x1B[1X",this._nullCellCount>0&&(r+="\x1B[A",r+=`\x1B[${l.length-this._nullCellCount}C`,r+=`\x1B[${this._nullCellCount}X`,r+=`\x1B[${l.length-this._nullCellCount}D`,r+="\x1B[B"),this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0)}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(e,t){let r=[];if(U(e,t))return r;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i)if(e.isAttributeDefault())t.isAttributeDefault()||r.push(0);else{if(l){let o=e.getFgColor();e.isFgRGB()?r.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?r.push(38,5,o):r.push(o&8?90+(o&7):30+(o&7)):r.push(39)}if(s){let o=e.getBgColor();e.isBgRGB()?r.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?r.push(48,5,o):r.push(o&8?100+(o&7):40+(o&7)):r.push(49)}if(i){if(e.isInverse()!==t.isInverse()&&r.push(e.isInverse()?7:27),e.isBold()!==t.isBold()&&r.push(e.isBold()?1:22),O(e,t))e.isUnderline()!==t.isUnderline()&&r.push(e.isUnderline()?4:24);else{let o=e.getUnderlineStyle();if(o===0)r.push(24);else if(o===1&&e.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+o),!e.isUnderlineColorDefault()){let u=e.getUnderlineColor();e.isUnderlineColorRGB()?r.push("58:2::"+(u>>>16&255)+":"+(u>>>8&255)+":"+(u&255)):r.push("58:5:"+u)}}e.isOverline()!==t.isOverline()&&r.push(e.isOverline()?53:55),e.isBlink()!==t.isBlink()&&r.push(e.isBlink()?5:25),e.isInvisible()!==t.isInvisible()&&r.push(e.isInvisible()?8:28),e.isItalic()!==t.isItalic()&&r.push(e.isItalic()?3:23),e.isDim()!==t.isDim()&&r.push(e.isDim()?2:22),e.isStrikethrough()!==t.isStrikethrough()&&r.push(e.isStrikethrough()?9:29)}}return r}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,this._cursorStyle);if(i?!F(this._cursorStyle,e):o.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l,this._currentRow+=`\x1B[${o.join(";")}m`;let f=this._buffer.getLine(r);f!==void 0&&(f.getCell(l,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=l)}i?this._nullCellCount+=e.getWidth():(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),this._currentRow+=e.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l+e.getWidth())}_serializeString(e){let t=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(t=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let i=0;i{c>0?r+=`\x1B[${c}C`:c<0&&(r+=`\x1B[${-c}D`)};u&&((c=>{c>0?r+=`\x1B[${c}B`:c<0&&(r+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let l=this._terminal._core._inputHandler._curAttrData,s=this._diffStyle(l,this._cursorStyle);return s.length>0&&(r+=`\x1B[${s.join(";")}m`),r}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,t){let r=e.length,l=t===void 0?r:A(t+n.rows,0,r);return this._serializeBufferByRange(n,e,{start:r-l,end:r-1},!1)}_serializeBufferByRange(n,e,t,r){return new y(e,n).serialize({start:{x:0,y:typeof t.start=="number"?t.start:t.start.line},end:{x:n.cols,y:typeof t.end=="number"?t.end:t.end.line}},r)}_serializeBufferAsHTML(n,e){let t=n.buffer.active,r=new D(t,n,e),l=e.onlySelection??!1,s=e.range;if(s)return r.serialize({start:{x:s.startCol,y:(typeof s.startLine=="number",s.startLine)},end:{x:n.cols,y:(typeof s.endLine=="number",s.endLine)}});if(!l){let o=t.length,u=e.scrollback,f=u===void 0?o:A(u+n.rows,0,o);return r.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?r.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,t=e.scrollTop,r=e.scrollBottom;return t!==0||r!==n.rows-1?`\x1B[${t+1};${r+1}r`:""}_serializeModes(n){let e="",t=n.modes;if(t.applicationCursorKeysMode&&(e+="\x1B[?1h"),t.applicationKeypadMode&&(e+="\x1B[?66h"),t.bracketedPasteMode&&(e+="\x1B[?2004h"),t.insertMode&&(e+="\x1B[4h"),t.originMode&&(e+="\x1B[?6h"),t.reverseWraparoundMode&&(e+="\x1B[?45h"),t.sendFocusMode&&(e+="\x1B[?1004h"),t.wraparoundMode===!1&&(e+="\x1B[?7l"),t.mouseTrackingMode!=="none")switch(t.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return t.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let t=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${t}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},D=class extends S{constructor(e,t,r){super(e);this._terminal=t;this._options=r;this._currentRow="";this._htmlContent="";t._core._themeService?this._ansiColors=t._core._themeService.colors.ansi:this._ansiColors=k}_beforeSerialize(e,t,r){this._htmlContent+="
";let l="#000000",s="#ffffff";(this._options.includeGlobalBackground??!1)&&(l=this._terminal.options.theme?.foreground??"#ffffff",s=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+l+";"),i.push("background-color: "+s+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,t){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,t){let r=t?e.getFgColor():e.getBgColor();if(t?e.isFgRGB():e.isBgRGB())return"#"+[r>>16&255,r>>8&255,r&255].map(s=>s.toString(16).padStart(2,"0")).join("");if(t?e.isFgPalette():e.isBgPalette())return this._ansiColors[r].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let t=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[t>>16&255,t>>8&255,t&255].map(l=>l.toString(16).padStart(2,"0")).join(""):this._ansiColors[t].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,t){let r=[];if(U(e,t))return;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i){let o=this._getHexColor(e,!0);o&&r.push("color: "+o+";");let u=this._getHexColor(e,!1);u&&r.push("background-color: "+u+";"),e.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&r.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&r.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let C=this._getUnderlineColor(e);C&&r.push("text-decoration-color: "+C+";")}return e.isInvisible()&&r.push("visibility: hidden;"),e.isItalic()&&r.push("font-style: italic;"),e.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,t);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=z(e.getChars())}_serializeString(){return this._htmlContent}};export{D as HTMLSerializeHandler,H as SerializeAddon}; ++`,this._lastCursorRow=e+1,this._lastCursorCol=0;else{r="";let i=l.getCell(l.length-1,this._thisRowLastChar),o=l.getCell(l.length-2,this._thisRowLastSecondChar),u=s.getCell(0,this._nextRowFirstChar),f=u.getWidth()>1,C=!1;(u.getChars()&&f?this._nullCellCount<=1:this._nullCellCount<=0)&&((i.getChars()||i.getWidth()===0)&&F(i,u)&&(C=!0),f&&(o.getChars()||o.getWidth()===0)&&F(i,u)&&F(o,u)&&(C=!0)),C||(r="-".repeat(this._nullCellCount+1),r+="\x1B[1D\x1B[1X",this._nullCellCount>0&&(r+="\x1B[A",r+=`\x1B[${l.length-this._nullCellCount}C`,r+=`\x1B[${this._nullCellCount}X`,r+=`\x1B[${l.length-this._nullCellCount}D`,r+="\x1B[B"),this._lastContentCursorRow=e+1,this._lastContentCursorCol=0,this._lastCursorRow=e+1,this._lastCursorCol=0)}}this._allRows[this._rowIndex]=this._currentRow,this._allRowSeparators[this._rowIndex++]=r,this._currentRow="",this._nullCellCount=0}_diffStyle(e,t){let r=[];if(U(e,t))return r;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i)if(e.isAttributeDefault())t.isAttributeDefault()||r.push(0);else{if(l){let o=e.getFgColor();e.isFgRGB()?r.push(38,2,o>>>16&255,o>>>8&255,o&255):e.isFgPalette()?o>=16?r.push(38,5,o):r.push(o&8?90+(o&7):30+(o&7)):r.push(39)}if(s){let o=e.getBgColor();e.isBgRGB()?r.push(48,2,o>>>16&255,o>>>8&255,o&255):e.isBgPalette()?o>=16?r.push(48,5,o):r.push(o&8?100+(o&7):40+(o&7)):r.push(49)}if(i){if(e.isInverse()!==t.isInverse()&&r.push(e.isInverse()?7:27),/* PATCH(orca): bold(1)/dim(2) share reset 22 - clear before re-set, as one group */((b,d)=>{if(b||d){const c=b&&!e.isBold()||d&&!e.isDim();c&&r.push(22),e.isBold()&&(b||c)&&r.push(1),e.isDim()&&(d||c)&&r.push(2)}})(e.isBold()!==t.isBold(),e.isDim()!==t.isDim()),O(e,t))e.isUnderline()!==t.isUnderline()&&r.push(e.isUnderline()?4:24);else{let o=e.getUnderlineStyle();if(o===0)r.push(24);else if(o===1&&e.isUnderlineColorDefault())r.push(4);else if(r.push("4:"+o),!e.isUnderlineColorDefault()){let u=e.getUnderlineColor();e.isUnderlineColorRGB()?r.push("58:2::"+(u>>>16&255)+":"+(u>>>8&255)+":"+(u&255)):r.push("58:5:"+u)}}e.isOverline()!==t.isOverline()&&r.push(e.isOverline()?53:55),e.isBlink()!==t.isBlink()&&r.push(e.isBlink()?5:25),e.isInvisible()!==t.isInvisible()&&r.push(e.isInvisible()?8:28),e.isItalic()!==t.isItalic()&&r.push(e.isItalic()?3:23),e.isStrikethrough()!==t.isStrikethrough()&&r.push(e.isStrikethrough()?9:29)}}return r}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,this._cursorStyle);if(i?!F(this._cursorStyle,e):o.length>0){this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)||(this._currentRow+=`\x1B[${this._nullCellCount}X`),this._currentRow+=`\x1B[${this._nullCellCount}C`,this._nullCellCount=0),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l,this._currentRow+=`\x1B[${o.join(";")}m`;let f=this._buffer.getLine(r);f!==void 0&&(f.getCell(l,this._cursorStyle),this._cursorStyleRow=r,this._cursorStyleCol=l)}i?this._nullCellCount+=e.getWidth():(this._nullCellCount>0&&(F(this._cursorStyle,this._backgroundCell)?this._currentRow+=`\x1B[${this._nullCellCount}C`:(this._currentRow+=`\x1B[${this._nullCellCount}X`,this._currentRow+=`\x1B[${this._nullCellCount}C`),this._nullCellCount=0),this._currentRow+=e.getChars(),this._lastContentCursorRow=this._lastCursorRow=r,this._lastContentCursorCol=this._lastCursorCol=l+e.getWidth())}_serializeString(e){let t=this._allRows.length;this._buffer.length-this._firstRow<=this._terminal.rows&&(t=this._lastContentCursorRow+1-this._firstRow,this._lastCursorCol=this._lastContentCursorCol,this._lastCursorRow=this._lastContentCursorRow);let r="";for(let i=0;i{c>0?r+=`\x1B[${c}C`:c<0&&(r+=`\x1B[${-c}D`)};u&&((c=>{c>0?r+=`\x1B[${c}B`:c<0&&(r+=`\x1B[${-c}A`)})(i-this._lastCursorRow),f(o-this._lastCursorCol))}let l=this._terminal._core._inputHandler._curAttrData,s=this._diffStyle(l,this._cursorStyle);return s.length>0&&(r+=`\x1B[${s.join(";")}m`),r}},H=class{activate(n){this._terminal=n}_serializeBufferByScrollback(n,e,t){let r=e.length,l=t===void 0?r:A(t+n.rows,0,r);return this._serializeBufferByRange(n,e,{start:r-l,end:r-1},!1)}_serializeBufferByRange(n,e,t,r){return new y(e,n).serialize({start:{x:0,y:typeof t.start=="number"?t.start:t.start.line},end:{x:n.cols,y:typeof t.end=="number"?t.end:t.end.line}},r)}_serializeBufferAsHTML(n,e){let t=n.buffer.active,r=new D(t,n,e),l=e.onlySelection??!1,s=e.range;if(s)return r.serialize({start:{x:s.startCol,y:(typeof s.startLine=="number",s.startLine)},end:{x:n.cols,y:(typeof s.endLine=="number",s.endLine)}});if(!l){let o=t.length,u=e.scrollback,f=u===void 0?o:A(u+n.rows,0,o);return r.serialize({start:{x:0,y:o-f},end:{x:n.cols,y:o-1}})}let i=this._terminal?.getSelectionPosition();return i!==void 0?r.serialize({start:{x:i.start.x,y:i.start.y},end:{x:i.end.x,y:i.end.y}}):""}_serializeScrollRegion(n){let e=n._core.buffer,t=e.scrollTop,r=e.scrollBottom;return t!==0||r!==n.rows-1?`\x1B[${t+1};${r+1}r`:""}_serializeModes(n){let e="",t=n.modes;if(t.applicationCursorKeysMode&&(e+="\x1B[?1h"),t.applicationKeypadMode&&(e+="\x1B[?66h"),t.bracketedPasteMode&&(e+="\x1B[?2004h"),t.insertMode&&(e+="\x1B[4h"),t.originMode&&(e+="\x1B[?6h"),t.reverseWraparoundMode&&(e+="\x1B[?45h"),t.sendFocusMode&&(e+="\x1B[?1004h"),t.wraparoundMode===!1&&(e+="\x1B[?7l"),t.mouseTrackingMode!=="none")switch(t.mouseTrackingMode){case"x10":e+="\x1B[?9h";break;case"vt200":e+="\x1B[?1000h";break;case"drag":e+="\x1B[?1002h";break;case"any":e+="\x1B[?1003h";break}return t.showCursor||(e+="\x1B[?25l"),e}serialize(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");let e=n?.range?this._serializeBufferByRange(this._terminal,this._terminal.buffer.normal,n.range,!0):this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.normal,n?.scrollback);if(!n?.excludeAltBuffer&&this._terminal.buffer.active.type==="alternate"){let t=this._serializeBufferByScrollback(this._terminal,this._terminal.buffer.alternate,void 0);e+=`\x1B[?1049h\x1B[H${t}`}return n?.excludeModes||(e+=this._serializeModes(this._terminal),e+=this._serializeScrollRegion(this._terminal)),e}serializeAsHTML(n){if(!this._terminal)throw new Error("Cannot use addon until it has been loaded");return this._serializeBufferAsHTML(this._terminal,n??{})}dispose(){}},D=class extends S{constructor(e,t,r){super(e);this._terminal=t;this._options=r;this._currentRow="";this._htmlContent="";t._core._themeService?this._ansiColors=t._core._themeService.colors.ansi:this._ansiColors=k}_beforeSerialize(e,t,r){this._htmlContent+="
";let l="#000000",s="#ffffff";(this._options.includeGlobalBackground??!1)&&(l=this._terminal.options.theme?.foreground??"#ffffff",s=this._terminal.options.theme?.background??"#000000");let i=[];i.push("color: "+l+";"),i.push("background-color: "+s+";"),i.push("font-family: "+this._terminal.options.fontFamily+";"),i.push("font-size: "+this._terminal.options.fontSize+"px;"),this._htmlContent+="
"}_afterSerialize(){this._htmlContent+="
",this._htmlContent+="
"}_rowEnd(e,t){this._htmlContent+="
"+this._currentRow+"
",this._currentRow=""}_getHexColor(e,t){let r=t?e.getFgColor():e.getBgColor();if(t?e.isFgRGB():e.isBgRGB())return"#"+[r>>16&255,r>>8&255,r&255].map(s=>s.toString(16).padStart(2,"0")).join("");if(t?e.isFgPalette():e.isBgPalette())return this._ansiColors[r].css}_getUnderlineColor(e){if(e.isUnderlineColorDefault())return;let t=e.getUnderlineColor();return e.isUnderlineColorRGB()?"#"+[t>>16&255,t>>8&255,t&255].map(l=>l.toString(16).padStart(2,"0")).join(""):this._ansiColors[t].css}_getUnderlineStyle(e){switch(e.getUnderlineStyle()){case 1:return"underline";case 2:return"underline double";case 3:return"underline wavy";case 4:return"underline dotted";case 5:return"underline dashed";default:return"underline"}}_diffStyle(e,t){let r=[];if(U(e,t))return;let l=!T(e,t),s=!F(e,t),i=!M(e,t);if(l||s||i){let o=this._getHexColor(e,!0);o&&r.push("color: "+o+";");let u=this._getHexColor(e,!1);u&&r.push("background-color: "+u+";"),e.isInverse()&&r.push("color: #000000; background-color: #BFBFBF;"),e.isBold()&&r.push("font-weight: bold;");let f=[];if(e.isUnderline()&&f.push(this._getUnderlineStyle(e)),e.isOverline()&&f.push("overline"),e.isStrikethrough()&&f.push("line-through"),e.isBlink()&&f.push("blink"),f.length>0&&r.push("text-decoration: "+f.join(" ")+";"),e.isUnderline()){let C=this._getUnderlineColor(e);C&&r.push("text-decoration-color: "+C+";")}return e.isInvisible()&&r.push("visibility: hidden;"),e.isItalic()&&r.push("font-style: italic;"),e.isDim()&&r.push("opacity: 0.5;"),r}}_nextCell(e,t,r,l){if(e.getWidth()===0)return;let i=e.getChars()==="",o=this._diffStyle(e,t);o&&(this._currentRow+=o.length===0?"
":""),i?this._currentRow+=" ":this._currentRow+=z(e.getChars())}_serializeString(){return this._htmlContent}};export{D as HTMLSerializeHandler,H as SerializeAddon}; + //# sourceMappingURL=addon-serialize.mjs.map +diff --git a/src/SerializeAddon.ts b/src/SerializeAddon.ts +index e1728feb219c362dfa2ecb602ff99f830d520757..672fdd5b86d73d8694446138835bbeccd43cfaae 100644 +--- a/src/SerializeAddon.ts ++++ b/src/SerializeAddon.ts +@@ -310,7 +310,20 @@ class StringSerializeHandler extends BaseSerializeHandler { + } + if (flagsChanged) { + if (cell.isInverse() !== oldCell.isInverse()) { sgrSeq.push(cell.isInverse() ? 7 : 27); } +- if (cell.isBold() !== oldCell.isBold()) { sgrSeq.push(cell.isBold() ? 1 : 22); } ++ // PATCH(orca): bold (1) and dim (2) share the single reset param 22, so ++ // they must be diffed as one intensity group with the clearing 22 emitted ++ // BEFORE any re-set. Upstream's independent per-flag diff could emit ++ // "1;22" (bold set, then wiped by dim's clear — \x1b[2mA\x1b[22m\x1b[1mB ++ // loses B's bold on round-trip) or a bare "22" that drops a still-set ++ // bold/dim, garbling Orca's hidden-terminal snapshot restores. ++ const boldChanged = cell.isBold() !== oldCell.isBold(); ++ const dimChanged = cell.isDim() !== oldCell.isDim(); ++ if (boldChanged || dimChanged) { ++ const clearsIntensity = (boldChanged && !cell.isBold()) || (dimChanged && !cell.isDim()); ++ if (clearsIntensity) { sgrSeq.push(22); } ++ if (cell.isBold() && (boldChanged || clearsIntensity)) { sgrSeq.push(1); } ++ if (cell.isDim() && (dimChanged || clearsIntensity)) { sgrSeq.push(2); } ++ } + if (!equalUnderline(cell, oldCell)) { + const style = cell.getUnderlineStyle(); + if (style === UnderlineStyle.NONE) { +@@ -337,7 +350,7 @@ class StringSerializeHandler extends BaseSerializeHandler { + if (cell.isBlink() !== oldCell.isBlink()) { sgrSeq.push(cell.isBlink() ? 5 : 25); } + if (cell.isInvisible() !== oldCell.isInvisible()) { sgrSeq.push(cell.isInvisible() ? 8 : 28); } + if (cell.isItalic() !== oldCell.isItalic()) { sgrSeq.push(cell.isItalic() ? 3 : 23); } +- if (cell.isDim() !== oldCell.isDim()) { sgrSeq.push(cell.isDim() ? 2 : 22); } ++ // PATCH(orca): dim handled in the intensity group above. + if (cell.isStrikethrough() !== oldCell.isStrikethrough()) { sgrSeq.push(cell.isStrikethrough() ? 9 : 29); } + } + } diff --git a/config/scripts/check-terminal-perf-report-budgets.mjs b/config/scripts/check-terminal-perf-report-budgets.mjs index 693822373f0..dd7185189d8 100644 --- a/config/scripts/check-terminal-perf-report-budgets.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.mjs @@ -18,6 +18,7 @@ if (reportPaths.length === 0) { const BUDGETS = { maxMedianKeyLatencyMs: 75, maxWorstKeyLatencyMs: 300, + maxRevisitLatencyMs: 300, maxTimerDriftMs: 150, maxScrollLatencyMs: 150, maxRestoreLatencyMs: 1000, @@ -80,6 +81,12 @@ function validateRow(row) { BUDGETS.maxWorstKeyLatencyMs, 'ms' ) + addBudgetCheck( + 'revisit latency', + parseMs(row.revisit, 'revisit', row, failures), + BUDGETS.maxRevisitLatencyMs, + 'ms' + ) addBudgetCheck( 'timer drift', parseMs(row.maxTimerDrift, 'maxTimerDrift', row, failures), @@ -113,6 +120,14 @@ function validateRow(row) { parseCount(row.rendererDroppedBacklogs, 'rendererDroppedBacklogs', row, failures), BUDGETS.maxRendererDroppedBacklogs ) + // Why: parked-memory rows carry heap/view-count metrics with no latency + // budget; recognize them so memory-only scenarios pass the gate instead of + // tripping the "no recognized budget metrics" guard. + for (const fieldName of ['heapUsedMB', 'liveTerminals', 'livePaneManagers']) { + if (parseCount(row[fieldName], fieldName, row, failures) != null) { + checkedMetricCount += 1 + } + } if (checkedMetricCount === 0) { failures.push(`${row.source} ${row.scenario}: no recognized budget metrics found`) } diff --git a/config/scripts/check-terminal-perf-report-budgets.test.mjs b/config/scripts/check-terminal-perf-report-budgets.test.mjs index d1fd378b498..e6129975151 100644 --- a/config/scripts/check-terminal-perf-report-budgets.test.mjs +++ b/config/scripts/check-terminal-perf-report-budgets.test.mjs @@ -58,6 +58,7 @@ describe('check-terminal-perf-report-budgets', () => { 'frames=180', 'median=2.9ms', 'worst=5.9ms', + 'revisit=42.0ms', 'maxTimerDrift=12.1ms', 'scroll=149.9ms', 'restore=642.0ms', @@ -82,6 +83,7 @@ describe('check-terminal-perf-report-budgets', () => { 'frames=60', 'median=76.0ms', 'worst=301.0ms', + 'revisit=301.0ms', 'maxTimerDrift=151.0ms', 'scroll=151.0ms', 'restore=1001.0ms', @@ -96,6 +98,7 @@ describe('check-terminal-perf-report-budgets', () => { expect(result.status).toBe(1) expect(result.stderr).toContain('median typing latency 76ms exceeded budget 75ms') expect(result.stderr).toContain('worst typing latency 301ms exceeded budget 300ms') + expect(result.stderr).toContain('revisit latency 301ms exceeded budget 300ms') expect(result.stderr).toContain('timer drift 151ms exceeded budget 150ms') expect(result.stderr).toContain('scroll latency 151ms exceeded budget 150ms') expect(result.stderr).toContain('restore latency 1001ms exceeded budget 1000ms') @@ -116,6 +119,31 @@ describe('check-terminal-perf-report-budgets', () => { expect(result.stderr).toContain('no recognized budget metrics found') }) + it('accepts revisit-only marker rows as budgeted perf evidence', () => { + const reportPath = writeReport('panes=19 revisit=25.7ms heldAckChars=2097184') + + const output = execFileSync(process.execPath, [scriptPath, reportPath], { + cwd: process.cwd(), + encoding: 'utf8' + }) + + expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).') + }) + + it('accepts parked-memory rows that carry only heap and view-count metrics', () => { + const reportPath = writeReport( + 'panes=8 parkedTabs=8 heapUsedMB=87.8 liveTerminals=1 livePaneManagers=1', + 'opencode-parked-memory' + ) + + const output = execFileSync(process.execPath, [scriptPath, reportPath], { + cwd: process.cwd(), + encoding: 'utf8' + }) + + expect(output).toContain('Terminal perf budget check passed for 1 annotation row(s).') + }) + it('fails OpenCode annotation rows that contain no budget metrics', () => { const reportPath = writeReport('panes=1 frames=60') diff --git a/config/scripts/generate-terminal-perf-html-report.mjs b/config/scripts/generate-terminal-perf-html-report.mjs new file mode 100644 index 00000000000..577592169bc --- /dev/null +++ b/config/scripts/generate-terminal-perf-html-report.mjs @@ -0,0 +1,483 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import { + budgetFailures, + collectTerminalPerfRows, + compareScenarios, + escapeHtml, + formatLargeValue, + formatMs, + readJsonReport, + scenarioTitle +} from './terminal-perf-report-rows.mjs' +import { basename, dirname } from 'node:path' + +const DEFAULT_OUTPUT_PATH = 'test-results/terminal-perf-impact-report.html' + +// Why: every tracked metric is lower-is-better, so delta coloring and the +// regression table share one direction rule. +const MS_METRICS = [ + { key: 'medianMs', label: 'Typing median', chart: true }, + { key: 'worstMs', label: 'Typing worst', chart: true }, + { key: 'scrollMs', label: 'Active scroll', chart: true }, + { key: 'restoreMs', label: 'Restore', chart: true }, + { key: 'revisitMs', label: 'Revisit marker', chart: true }, + { key: 'maxTimerDriftMs', label: 'Timer drift', chart: false } +] + +const COUNT_METRICS = [ + { key: 'rendererPeakQueuedChars', label: 'Renderer peak queued chars' }, + { key: 'mainPeakInFlightChars', label: 'Main in-flight chars' }, + { key: 'mainPeakPendingChars', label: 'Main pending chars' }, + { key: 'hiddenSkippedChars', label: 'Hidden skipped chars' }, + { key: 'rendererDroppedBacklogs', label: 'Renderer dropped backlogs' }, + // Why: parked-memory scenarios are table-only — heap/view counts have no + // ms trend story, so they stay out of the charts. + { key: 'heapUsedMB', label: 'Renderer JS heap (MB)' }, + { key: 'liveTerminals', label: 'Live xterm instances' }, + { key: 'livePaneManagers', label: 'Live pane managers' } +] + +const SERIES_COLORS = { + medianMs: '#2563eb', + worstMs: '#dc2626', + scrollMs: '#d97706', + restoreMs: '#7c3aed', + revisitMs: '#0d9488' +} + +const LABELED_INPUT_RE = /^([\w .#@()+-]+)=(.+)$/ + +export function parseHtmlReportArgs(argv, env = process.env) { + const args = [...argv] + if (args[0] === '--') { + args.shift() + } + + const inputs = [] + let outputPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_OUTPUT_PATH + for (let index = 0; index < args.length; index += 1) { + const arg = args[index] + if (arg === '--output' || arg === '-o') { + const next = args[index + 1] + if (!next || next.startsWith('-')) { + throw new Error(`${arg} requires a path`) + } + outputPath = next + index += 1 + continue + } + if (arg.startsWith('--output=')) { + outputPath = arg.slice('--output='.length) + continue + } + const labeled = arg.match(LABELED_INPUT_RE) + if (labeled) { + inputs.push({ label: labeled[1], path: labeled[2] }) + } else { + inputs.push({ label: basename(arg).replace(/\.json$/i, ''), path: arg }) + } + } + + if (inputs.length === 0) { + throw new Error( + 'Usage: node config/scripts/generate-terminal-perf-html-report.mjs [label=]... --output ' + ) + } + return { inputs, outputPath } +} + +// ── Trend data ──────────────────────────────────────────────────────────── + +function buildMatrix(revisions) { + const scenarios = new Map() + for (const revision of revisions) { + for (const row of revision.rows) { + if (!scenarios.has(row.scenario)) { + scenarios.set(row.scenario, new Map()) + } + scenarios.get(row.scenario).set(revision.label, row) + } + } + const orderedScenarios = [...scenarios.keys()].sort(compareScenarios) + return { scenarios, orderedScenarios } +} + +function niceCeil(value) { + if (value <= 0) { + return 1 + } + const magnitude = 10 ** Math.floor(Math.log10(value)) + for (const step of [1, 2, 2.5, 5, 10]) { + if (value <= step * magnitude) { + return step * magnitude + } + } + return 10 * magnitude +} + +// ── Rendering ───────────────────────────────────────────────────────────── + +function renderTrendChart({ scenario, byRevision, revisions, title }) { + const metrics = MS_METRICS.filter( + (metric) => + metric.chart && + revisions.some((revision) => byRevision.get(revision.label)?.[metric.key] != null) + ) + if (metrics.length === 0) { + return '' + } + const width = 560 + const height = 230 + const pad = { left: 52, right: 14, top: 30, bottom: 38 } + const plotW = width - pad.left - pad.right + const plotH = height - pad.top - pad.bottom + const maxValue = Math.max( + 1, + ...metrics.flatMap((metric) => + revisions.map((revision) => byRevision.get(revision.label)?.[metric.key] ?? 0) + ) + ) + const yMax = niceCeil(maxValue * 1.15) + const xFor = (index) => + pad.left + (revisions.length === 1 ? plotW / 2 : (plotW * index) / (revisions.length - 1)) + const yFor = (value) => pad.top + plotH - (plotH * value) / yMax + + const parts = [] + parts.push( + `` + ) + parts.push(`${escapeHtml(title)}`) + // Horizontal gridlines + y labels + const ticks = 4 + for (let tick = 0; tick <= ticks; tick += 1) { + const value = (yMax * tick) / ticks + const y = yFor(value) + parts.push( + `` + ) + parts.push( + `${value % 1 === 0 ? value : value.toFixed(1)}` + ) + } + // X labels + revisions.forEach((revision, index) => { + parts.push( + `${escapeHtml(revision.label)}` + ) + }) + // Series + for (const metric of metrics) { + const color = SERIES_COLORS[metric.key] ?? '#475569' + const points = revisions + .map((revision, index) => ({ index, value: byRevision.get(revision.label)?.[metric.key] })) + .filter((point) => point.value != null) + if (points.length === 0) { + continue + } + const path = points + .map( + (point, order) => + `${order === 0 ? 'M' : 'L'}${xFor(point.index).toFixed(1)},${yFor(point.value).toFixed(1)}` + ) + .join(' ') + parts.push(``) + for (const point of points) { + const x = xFor(point.index) + const y = yFor(point.value) + parts.push(``) + parts.push( + `${point.value % 1 === 0 ? point.value : point.value.toFixed(1)}` + ) + } + } + parts.push('') + + const legend = metrics + .map((metric) => { + const color = SERIES_COLORS[metric.key] ?? '#475569' + return `${escapeHtml(metric.label)}` + }) + .join('') + return `
${parts.join('')}
${legend} ms — lower is better
` +} + +function deltaCell(baseline, latest, { lowerIsBetter = true, zeroBudget = false } = {}) { + if (baseline == null || latest == null) { + return '—' + } + const diff = latest - baseline + const pct = baseline === 0 ? null : (diff / baseline) * 100 + let cls = 'neutral' + if (zeroBudget) { + cls = latest > 0 ? 'worse' : 'better' + } else if (pct != null && Math.abs(pct) >= 5) { + cls = diff < 0 === lowerIsBetter ? 'better' : 'worse' + } else if (baseline === 0 && diff !== 0) { + cls = diff < 0 === lowerIsBetter ? 'better' : 'worse' + } + const pctLabel = + pct == null ? (diff === 0 ? '±0%' : 'new') : `${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%` + const diffLabel = `${diff >= 0 ? '+' : ''}${Math.abs(diff) >= 100 ? Math.round(diff) : diff.toFixed(1)}` + return `${escapeHtml(pctLabel)} (${escapeHtml(diffLabel)})` +} + +function renderScenarioTable({ scenario, byRevision, revisions, title }) { + const metricRows = [] + const allMetrics = [...MS_METRICS, ...COUNT_METRICS] + for (const metric of allMetrics) { + const values = revisions.map((revision) => byRevision.get(revision.label)?.[metric.key]) + if (values.every((value) => value == null)) { + continue + } + const isMs = MS_METRICS.includes(metric) + const format = isMs ? formatMs : formatLargeValue + const cells = values + .map((value) => `${value == null ? '—' : escapeHtml(format(value))}`) + .join('') + const baseline = values.find((value) => value != null) + const latest = values.toReversed().find((value) => value != null) + metricRows.push( + `${escapeHtml(metric.label)}${cells}${deltaCell(baseline, latest, { + zeroBudget: metric.key === 'rendererDroppedBacklogs' + })}` + ) + } + if (metricRows.length === 0) { + return '' + } + const headers = revisions.map((revision) => `${escapeHtml(revision.label)}`).join('') + return `
+

${escapeHtml(title)} ${escapeHtml(scenario)}

+ +${headers} +${metricRows.join('')} +
MetricΔ first → last
+
` +} + +function renderHeadline(revisions, matrix) { + if (revisions.length < 2) { + return '' + } + const first = revisions[0] + const last = revisions.at(-1) + const cards = [] + for (const scenario of matrix.orderedScenarios) { + const byRevision = matrix.scenarios.get(scenario) + const baseRow = byRevision.get(first.label) + const lastRow = byRevision.get(last.label) + if (!baseRow || !lastRow || baseRow.medianMs == null || lastRow.medianMs == null) { + continue + } + const diff = lastRow.medianMs - baseRow.medianMs + const pct = baseRow.medianMs === 0 ? 0 : (diff / baseRow.medianMs) * 100 + const cls = Math.abs(pct) < 5 ? 'neutral' : diff < 0 ? 'better' : 'worse' + cards.push(`
+
${escapeHtml(scenarioTitle(scenario, lastRow))}
+
${escapeHtml(formatMs(baseRow.medianMs))} → ${escapeHtml(formatMs(lastRow.medianMs))}
+
typing median, ${escapeHtml(first.label)} → ${escapeHtml(last.label)} (${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%)
+
`) + } + if (cards.length === 0) { + return '' + } + return `

Baseline vs latest

${cards.join('')}
` +} + +function renderBudgets(latestRevision) { + const failures = [] + for (const row of latestRevision.rows) { + for (const failure of budgetFailures(row)) { + failures.push(`${row.scenario}: ${failure}`) + } + } + const status = + failures.length === 0 ? 'Pass' : 'Fail' + const failureList = + failures.length === 0 + ? '' + : `
    ${failures.map((failure) => `
  • ${escapeHtml(failure)}
  • `).join('')}
` + return `

Budget status — ${escapeHtml(latestRevision.label)}

+

${latestRevision.rows.length} scenario rows checked: ${status}

${failureList}
` +} + +function renderInputsMeta(revisions) { + const items = revisions + .map((revision) => { + const stats = revision.stats + const statsLabel = stats + ? ` — ${stats.expected ?? 0} passed, ${stats.unexpected ?? 0} failed, ${stats.flaky ?? 0} flaky` + : '' + const failNote = + stats && stats.unexpected > 0 + ? ' (failed assertions at this revision; metrics still recorded)' + : '' + return `
  • ${escapeHtml(revision.label)} — ${revision.rows.length} scenario rows (${escapeHtml(revision.path)})${escapeHtml(statsLabel)}${failNote}
  • ` + }) + .join('') + return `
      ${items}
    ` +} + +function renderRawDetails(revisions) { + return revisions + .map((revision) => { + const rows = revision.rows + .map( + (row) => + `${escapeHtml(row.scenario)}${row.panes ?? '—'}${escapeHtml(formatMs(row.medianMs))}${escapeHtml(formatMs(row.worstMs))}${escapeHtml(formatMs(row.scrollMs))}${escapeHtml(formatMs(row.restoreMs))}${escapeHtml(formatMs(row.revisitMs))}${escapeHtml(formatLargeValue(row.rendererPeakQueuedChars))}${escapeHtml(formatLargeValue(row.hiddenSkippedChars))}${row.rendererDroppedBacklogs ?? '—'}` + ) + .join('') + return `
    Raw rows — ${escapeHtml(revision.label)} + + +${rows}
    ScenarioPanesMedianWorstScrollRestoreRevisitRenderer peakHidden skippedDrops
    ` + }) + .join('') +} + +const PAGE_CSS = ` +:root { color-scheme: light; } +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; margin: 24px auto; max-width: 1240px; padding: 0 16px; color: #0f172a; background: #f8fafc; } +h1 { font-size: 24px; margin-bottom: 4px; } +h2 { font-size: 18px; margin: 28px 0 10px; } +h3 { font-size: 15px; margin: 18px 0 6px; } +.meta { color: #64748b; font-size: 13px; } +.inputs { font-size: 13px; color: #334155; padding-left: 20px; } +.meta-warn { color: #b45309; } +.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; } +.card { background: #fff; border: 1px solid #e2e8f0; border-left-width: 4px; border-radius: 8px; padding: 10px 12px; } +.card.better { border-left-color: #16a34a; } +.card.worse { border-left-color: #dc2626; } +.card.neutral { border-left-color: #94a3b8; } +.card-title { font-size: 12px; color: #64748b; } +.card-value { font-size: 18px; font-weight: 600; margin: 2px 0; } +.card-sub { font-size: 11px; color: #94a3b8; } +.charts { display: grid; grid-template-columns: repeat(auto-fill, minmax(560px, 1fr)); gap: 14px; } +.chart-card { margin: 0; background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 8px; } +.trend-chart { width: 100%; height: auto; } +.chart-title { font-size: 13px; font-weight: 600; fill: #0f172a; } +.gridline { stroke: #e2e8f0; stroke-width: 1; } +.axis-label { font-size: 10px; fill: #64748b; } +.point-label { font-size: 10px; font-weight: 600; } +.legend { font-size: 11px; color: #475569; margin-top: 2px; display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } +.legend-item { display: inline-flex; align-items: center; gap: 4px; } +.legend-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; } +.legend-unit { color: #94a3b8; margin-left: auto; } +.scenario-block { background: #fff; border: 1px solid #e2e8f0; border-radius: 8px; padding: 10px 14px; margin: 10px 0; } +.scenario-id { font-size: 11px; color: #94a3b8; font-weight: 400; margin-left: 6px; } +table.trend-table { border-collapse: collapse; width: 100%; font-size: 12px; } +table.trend-table th, table.trend-table td { border-bottom: 1px solid #e2e8f0; padding: 5px 8px; text-align: right; white-space: nowrap; } +table.trend-table th:first-child, table.trend-table td:first-child { text-align: left; } +table.trend-table thead th { color: #475569; font-weight: 600; background: #f1f5f9; } +td.delta.better { color: #15803d; font-weight: 600; } +td.delta.worse { color: #b91c1c; font-weight: 600; } +td.delta.neutral { color: #64748b; } +.delta-abs { font-weight: 400; color: #94a3b8; } +.pass { color: #15803d; font-weight: 700; } +.fail { color: #b91c1c; font-weight: 700; } +details { margin: 8px 0; } +summary { cursor: pointer; font-size: 13px; color: #334155; } +` + +function renderHtml({ generatedAt, revisions }) { + const matrix = buildMatrix(revisions) + const charts = + revisions.length >= 2 + ? matrix.orderedScenarios + .map((scenario) => { + const byRevision = matrix.scenarios.get(scenario) + const anyRow = [...byRevision.values()][0] + return renderTrendChart({ + scenario, + byRevision, + revisions, + title: scenarioTitle(scenario, anyRow) + }) + }) + .filter(Boolean) + .join('') + : '' + const tables = matrix.orderedScenarios + .map((scenario) => { + const byRevision = matrix.scenarios.get(scenario) + const anyRow = [...byRevision.values()][0] + return renderScenarioTable({ + scenario, + byRevision, + revisions, + title: scenarioTitle(scenario, anyRow) + }) + }) + .filter(Boolean) + .join('') + + return ` + + + + +Terminal Performance Over Time + + + +

    Terminal Performance Over Time

    +

    Generated ${escapeHtml(generatedAt)} from ${revisions.length} benchmark run(s), ordered oldest (baseline) to newest. All metrics: lower is better.

    +${renderInputsMeta(revisions)} +${renderHeadline(revisions, matrix)} +${charts ? `

    Trends across revisions

    ${charts}
    ` : ''} +

    Metric detail by scenario

    ${tables}
    +${renderBudgets(revisions.at(-1))} +

    Raw data

    ${renderRawDetails(revisions)}
    + + +` +} + +export function generateTerminalPerfHtmlReport({ + inputs, + inputPaths, + outputPath, + now = new Date() +}) { + // Why: older callers (the scale report gate) pass bare inputPaths. + const resolvedInputs = + inputs ?? + (inputPaths ?? []).map((path) => ({ + label: basename(path).replace(/\.json$/i, ''), + path + })) + const revisions = resolvedInputs.map(({ label, path }) => { + const report = readJsonReport(path) + return { + label, + path, + stats: report.stats ?? null, + rows: collectTerminalPerfRows(report, label) + } + }) + const totalRows = revisions.reduce((sum, revision) => sum + revision.rows.length, 0) + if (totalRows === 0) { + throw new Error('No opencode terminal perf annotations found in the provided reports') + } + const html = renderHtml({ generatedAt: now.toISOString(), revisions }) + mkdirSync(dirname(outputPath), { recursive: true }) + writeFileSync(outputPath, html) + const latestFailures = revisions + .at(-1) + .rows.reduce((sum, row) => sum + budgetFailures(row).length, 0) + return { outputPath, rowCount: totalRows, budgetFailureCount: latestFailures } +} + +const isMain = process.argv[1] && import.meta.filename === process.argv[1] +if (isMain) { + try { + const { inputs, outputPath } = parseHtmlReportArgs(process.argv.slice(2)) + const result = generateTerminalPerfHtmlReport({ inputs, outputPath }) + console.log( + `Terminal perf HTML report saved to ${result.outputPath} (${result.rowCount} rows, ${result.budgetFailureCount} budget failures).` + ) + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)) + process.exit(1) + } +} diff --git a/config/scripts/generate-terminal-perf-html-report.test.mjs b/config/scripts/generate-terminal-perf-html-report.test.mjs new file mode 100644 index 00000000000..a804bb6639d --- /dev/null +++ b/config/scripts/generate-terminal-perf-html-report.test.mjs @@ -0,0 +1,272 @@ +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + generateTerminalPerfHtmlReport, + parseHtmlReportArgs +} from './generate-terminal-perf-html-report.mjs' + +const tempDirs = [] + +function makeTempDir() { + const dir = mkdtempSync(join(tmpdir(), 'orca-terminal-perf-html-')) + tempDirs.push(dir) + return dir +} + +function writeReport( + annotationDescription, + annotationType = 'opencode-scale-same-workspace-25', + reportName = 'report.json' +) { + const dir = makeTempDir() + const reportPath = join(dir, reportName) + writeFileSync( + reportPath, + JSON.stringify({ + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: annotationType, + description: annotationDescription + }, + { + type: 'browser-unrelated', + description: 'median=999.0ms' + } + ] + } + ] + } + ] + } + ] + }) + ) + return reportPath +} + +afterEach(() => { + while (tempDirs.length > 0) { + rmSync(tempDirs.pop(), { force: true, recursive: true }) + } +}) + +describe('generate-terminal-perf-html-report', () => { + it('parses labeled and bare input paths plus output flags', () => { + expect(parseHtmlReportArgs(['--', 'a.json', 'b.json', '--output', 'out.html'])).toEqual({ + inputs: [ + { label: 'a', path: 'a.json' }, + { label: 'b', path: 'b.json' } + ], + outputPath: 'out.html' + }) + expect(parseHtmlReportArgs(['main=runs/0-main.json', '#5038 final=runs/4-final.json'])).toEqual( + { + inputs: [ + { label: 'main', path: 'runs/0-main.json' }, + { label: '#5038 final', path: 'runs/4-final.json' } + ], + outputPath: 'test-results/terminal-perf-impact-report.html' + } + ) + expect( + parseHtmlReportArgs(['a.json'], { ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'env.html' }) + ).toEqual({ + inputs: [{ label: 'a', path: 'a.json' }], + outputPath: 'env.html' + }) + expect(() => parseHtmlReportArgs(['--output'])).toThrow('--output requires a path') + expect(() => parseHtmlReportArgs([])).toThrow('Usage:') + }) + + it('writes a single-run report with scenario tables and budget status', () => { + const reportPath = writeReport( + [ + 'panes=25', + 'frames=60', + 'median=12.4ms', + 'worst=44.8ms', + 'revisit=28.6ms', + 'scroll=61.0ms', + 'restore=320.0ms', + 'maxTimerDrift=8.0ms', + 'rendererPeakQueuedChars=2048', + 'mainPeakInFlightChars=4096', + 'heldAckChars=1024', + 'hiddenSkippedChars=512', + 'rendererDroppedBacklogs=0' + ].join(' ') + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ + inputPaths: [reportPath], + outputPath, + now: new Date('2026-06-09T10:00:00.000Z') + }) + + const html = readFileSync(outputPath, 'utf8') + expect(result).toEqual({ budgetFailureCount: 0, outputPath, rowCount: 1 }) + expect(html).toContain('') + expect(html).toContain('Terminal Performance Over Time') + expect(html).toContain('2026-06-09T10:00:00.000Z') + expect(html).toContain('Same workspace panes — 25 panes') + expect(html).toContain('opencode-scale-same-workspace-25') + expect(html).toContain('28.6ms') + expect(html).toContain('Pass') + // Why: one run has no over-time story; the trend section must not render. + expect(html).not.toContain('Trends across revisions') + expect(html).not.toContain('browser-unrelated') + }) + + it('renders parked-memory heap and live view counts as table metrics', () => { + const reportPath = writeReport( + 'panes=8 parkedTabs=8 heapUsedMB=142.5 liveTerminals=1 livePaneManagers=1', + 'opencode-parked-memory' + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath }) + + const html = readFileSync(outputPath, 'utf8') + // Why: heapUsedMB has no budget — a memory row alone must not fail gates. + expect(result.budgetFailureCount).toBe(0) + expect(html).toContain('Parked hidden terminal memory — 8 panes') + expect(html).toContain('Renderer JS heap (MB)') + expect(html).toContain('142.5') + expect(html).toContain('Live xterm instances') + expect(html).toContain('Live pane managers') + }) + + it('marks over-budget rows as failures for the latest run', () => { + const reportPath = writeReport( + [ + 'panes=100', + 'median=80.0ms', + 'worst=301.0ms', + 'revisit=301.0ms', + 'rendererPeakQueuedChars=2097153', + 'rendererDroppedBacklogs=1' + ].join(' '), + 'opencode-scale-cross-workspace-100' + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ inputPaths: [reportPath], outputPath }) + + const html = readFileSync(outputPath, 'utf8') + expect(result.budgetFailureCount).toBe(5) + expect(html).toContain('Fail') + expect(html).toContain('medianMs 80 > 75') + expect(html).toContain('Cross-workspace hidden panes') + }) + + it('renders ordered revisions with trend charts and baseline deltas', () => { + const mainReport = writeReport( + 'panes=25 median=50.0ms worst=120.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'main.json' + ) + const middleReport = writeReport( + 'panes=25 median=30.0ms worst=140.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'backpressure.json' + ) + const finalReport = writeReport( + 'panes=25 median=20.0ms worst=100.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'final.json' + ) + const outputPath = join(makeTempDir(), 'report.html') + + const result = generateTerminalPerfHtmlReport({ + inputs: [ + { label: 'main', path: mainReport }, + { label: 'backpressure', path: middleReport }, + { label: 'final', path: finalReport } + ], + outputPath + }) + + const html = readFileSync(outputPath, 'utf8') + expect(result.rowCount).toBe(3) + expect(html).toContain('Baseline vs latest') + expect(html).toContain('Trends across revisions') + expect(html).toContain('trend-chart') + expect(html).toContain('>main<') + expect(html).toContain('>backpressure<') + expect(html).toContain('>final<') + // Why: median 50 -> 20 is a 60% improvement and must read as better. + expect(html).toContain('delta better') + expect(html).toContain('-60%') + expect(html).toContain('50.0ms → 20.0ms') + }) + + it('renders missing scenarios at older revisions as gaps, not zeros', () => { + const mainReport = writeReport( + 'panes=25 median=50.0ms rendererDroppedBacklogs=0', + 'opencode-scale-same-workspace-25', + 'main.json' + ) + const finalReport = makeTempDir() + const finalPath = join(finalReport, 'final.json') + writeFileSync( + finalPath, + JSON.stringify({ + suites: [ + { + specs: [ + { + tests: [ + { + annotations: [ + { + type: 'opencode-scale-same-workspace-25', + description: 'panes=25 median=40.0ms rendererDroppedBacklogs=0' + }, + { + type: 'opencode-revisit-pressure', + description: 'panes=19 median=3.0ms revisit=4.4ms rendererDroppedBacklogs=0' + } + ] + } + ] + } + ] + } + ] + }) + ) + const outputPath = join(makeTempDir(), 'report.html') + + generateTerminalPerfHtmlReport({ + inputs: [ + { label: 'main', path: mainReport }, + { label: 'final', path: finalPath } + ], + outputPath + }) + + const html = readFileSync(outputPath, 'utf8') + expect(html).toContain('Revisit under pressure') + expect(html).toContain('—') + }) + + it('fails when reports contain no terminal perf annotations', () => { + const reportPath = writeReport('median=12.0ms', 'browser-unrelated') + + expect(() => + generateTerminalPerfHtmlReport({ + inputPaths: [reportPath], + outputPath: join(makeTempDir(), 'report.html') + }) + ).toThrow('No opencode terminal perf annotations found') + }) +}) diff --git a/config/scripts/release-rc-history.mjs b/config/scripts/release-rc-history.mjs index e3cdea2d870..c6a0e7b4782 100644 --- a/config/scripts/release-rc-history.mjs +++ b/config/scripts/release-rc-history.mjs @@ -25,7 +25,11 @@ export function rcNumberFromTag(base, tag) { } const suffix = tag.slice(prefix.length) - return /^\d+$/.test(suffix) ? Number(suffix) : null + // Why the optional .identifier: suffixed side-branch RCs (v1.2.3-rc.4.perf) + // must advance the shared rc counter, or the next suffixed cut recomputes + // an existing tag and the workflow refuses to re-cut over it. + const match = /^(\d+)(?:\.[0-9A-Za-z]+)?$/.exec(suffix) + return match ? Number(match[1]) : null } export function rcNumberFromReleaseSubject(base, subject) { diff --git a/config/scripts/run-multi-workspace-typing-bench.mjs b/config/scripts/run-multi-workspace-typing-bench.mjs new file mode 100644 index 00000000000..7208afb470a --- /dev/null +++ b/config/scripts/run-multi-workspace-typing-bench.mjs @@ -0,0 +1,63 @@ +/** + * Entry point for the multi-workspace typing-latency bench + * (tests/e2e/terminal-multi-workspace-typing-latency.spec.ts). + * + * Usage: + * pnpm bench:multi-workspace-typing [-- --panes 8 --rate-kbps 512 \ + * --keys 48 --cadence-ms 250 --cpu-workers 4 --label before-fix] + * + * Results land in tools/benchmarks/results/multi-workspace-typing-*.json. + * Run once per build/config with distinct --label values, then diff the + * totalMs/inputHalfMs/echoHalfMs percentiles. + */ +import { spawn } from 'node:child_process' + +const npxCommand = process.platform === 'win32' ? 'npx.cmd' : 'npx' + +const knobByFlag = { + '--panes': 'ORCA_TYPING_BENCH_LOAD_PANES', + '--rate-kbps': 'ORCA_TYPING_BENCH_RATE_KBPS', + '--keys': 'ORCA_TYPING_BENCH_KEYS', + '--cadence-ms': 'ORCA_TYPING_BENCH_KEY_CADENCE_MS', + '--cpu-workers': 'ORCA_TYPING_BENCH_CPU_WORKERS', + '--label': 'ORCA_TYPING_BENCH_LABEL' +} + +const env = { ...process.env, ORCA_TYPING_BENCH: '1' } +const passthroughArgs = [] +const argv = process.argv.slice(2) +for (let i = 0; i < argv.length; i++) { + if (argv[i] === '--') { + continue + } + const knob = knobByFlag[argv[i]] + if (knob) { + env[knob] = argv[++i] + } else { + passthroughArgs.push(argv[i]) + } +} + +const child = spawn( + npxCommand, + [ + 'playwright', + 'test', + 'tests/e2e/terminal-multi-workspace-typing-latency.spec.ts', + '--config', + 'tests/playwright.config.ts', + '--project', + 'electron-headless', + '--workers=1', + ...passthroughArgs + ], + { stdio: 'inherit', env } +) + +child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal) + return + } + process.exit(code ?? 1) +}) diff --git a/config/scripts/run-terminal-scale-perf-report-gate.mjs b/config/scripts/run-terminal-scale-perf-report-gate.mjs index eef60041da1..59b1b443fa9 100644 --- a/config/scripts/run-terminal-scale-perf-report-gate.mjs +++ b/config/scripts/run-terminal-scale-perf-report-gate.mjs @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' const DEFAULT_REPORT_PATH = 'test-results/terminal-scale-perf-report.json' +const DEFAULT_HTML_REPORT_PATH = 'test-results/terminal-perf-impact-report.html' export function parseReportGateArgs(argv, env = process.env) { const forwardedArgs = [...argv] @@ -114,7 +115,20 @@ export function runTerminalScalePerfReportGate({ spawnSyncImpl, env ) - return exitCode(budgetResult) + const budgetExitCode = exitCode(budgetResult) + if (budgetExitCode !== 0) { + return budgetExitCode + } + + const htmlReportPath = env.ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH || DEFAULT_HTML_REPORT_PATH + const htmlResult = runNodeScript( + 'config/scripts/generate-terminal-perf-html-report.mjs', + [reportPath, '--output', htmlReportPath], + 'inherit', + spawnSyncImpl, + env + ) + return exitCode(htmlResult) } if (process.argv[1] === import.meta.filename) { diff --git a/config/scripts/run-terminal-scale-perf-report-gate.test.mjs b/config/scripts/run-terminal-scale-perf-report-gate.test.mjs index 929e2d5b9ba..e4d7be8861e 100644 --- a/config/scripts/run-terminal-scale-perf-report-gate.test.mjs +++ b/config/scripts/run-terminal-scale-perf-report-gate.test.mjs @@ -77,7 +77,8 @@ describe('run-terminal-scale-perf-report-gate', () => { expect(calls.map((call) => call.args[0])).toEqual([ 'config/scripts/run-terminal-scale-perf-e2e.mjs', 'config/scripts/summarize-terminal-perf-report.mjs', - 'config/scripts/check-terminal-perf-report-budgets.mjs' + 'config/scripts/check-terminal-perf-report-budgets.mjs', + 'config/scripts/generate-terminal-perf-html-report.mjs' ]) expect(calls[0].args).toEqual([ 'config/scripts/run-terminal-scale-perf-e2e.mjs', @@ -92,6 +93,12 @@ describe('run-terminal-scale-perf-report-gate', () => { 'config/scripts/check-terminal-perf-report-budgets.mjs', reportPath ]) + expect(calls[3].args).toEqual([ + 'config/scripts/generate-terminal-perf-html-report.mjs', + reportPath, + '--output', + 'test-results/terminal-perf-impact-report.html' + ]) }) it('uses the report path from env when no flag is provided', () => { @@ -107,6 +114,28 @@ describe('run-terminal-scale-perf-report-gate', () => { expect(calls[1].args).toEqual(['config/scripts/summarize-terminal-perf-report.mjs', reportPath]) }) + it('uses the HTML report path from env when provided', () => { + const reportPath = tempReportPath() + const { calls, spawnSyncImpl } = makeSpawnSync() + + const status = runTerminalScalePerfReportGate({ + env: { + ...process.env, + ORCA_E2E_TERMINAL_PERF_HTML_REPORT_PATH: 'tmp/terminal-report.html', + ORCA_E2E_TERMINAL_PERF_REPORT_PATH: reportPath + }, + spawnSyncImpl + }) + + expect(status).toBe(0) + expect(calls[3].args).toEqual([ + 'config/scripts/generate-terminal-perf-html-report.mjs', + reportPath, + '--output', + 'tmp/terminal-report.html' + ]) + }) + it('preserves the report when Playwright clears the target report directory', () => { const reportPath = tempReportPath() const { spawnSyncImpl } = makeSpawnSync({ diff --git a/config/scripts/summarize-terminal-perf-report.mjs b/config/scripts/summarize-terminal-perf-report.mjs index 6d2adea54f9..2e4f77732b3 100644 --- a/config/scripts/summarize-terminal-perf-report.mjs +++ b/config/scripts/summarize-terminal-perf-report.mjs @@ -25,6 +25,7 @@ function printMarkdownTable(rows) { ['Frames', 'frames'], ['Median', 'median'], ['Worst', 'worst'], + ['Revisit', 'revisit'], ['Scroll', 'scroll'], ['Restore', 'restore'], ['Max Drift', 'maxTimerDrift'], diff --git a/config/scripts/terminal-perf-report-rows.mjs b/config/scripts/terminal-perf-report-rows.mjs new file mode 100644 index 00000000000..c1f562f3b9e --- /dev/null +++ b/config/scripts/terminal-perf-report-rows.mjs @@ -0,0 +1,199 @@ +import { readFileSync } from 'node:fs' + +const BUDGETS = { + medianMs: 75, + worstMs: 300, + revisitMs: 300, + maxTimerDriftMs: 150, + scrollMs: 150, + restoreMs: 1000, + rendererQueuedChars: 2 * 1024 * 1024, + rendererPeakQueuedChars: 2 * 1024 * 1024, + rendererDroppedBacklogs: 0 +} + +const SCENARIO_LABELS = [ + ['opencode-scale-same-workspace', 'Same workspace panes'], + ['opencode-scale-cross-workspace', 'Cross-workspace hidden panes'], + ['opencode-scale-pressure', 'ACK-backpressured PTYs'], + ['opencode-scale-hidden-pressure', 'Hidden real PTYs'], + ['opencode-cross-workspace-typing', 'Cross-workspace typing'], + ['opencode-main-pressure', 'Main renderer pressure'], + ['opencode-hidden-pressure', 'Hidden pressure'], + ['opencode-revisit-pressure', 'Revisit under pressure'], + // Why: the prefix also matches opencode-parked-memory-disabled, so both + // parked-memory scenarios group under one label. + ['opencode-parked-memory', 'Parked hidden terminal memory'] +] + +export function readJsonReport(path) { + const raw = readFileSync(path, 'utf8') + const start = raw.indexOf('{') + const end = raw.lastIndexOf('}') + if (start === -1 || end <= start) { + throw new Error(`${path}: no JSON object found`) + } + return JSON.parse(raw.slice(start, end + 1)) +} + +function parseAnnotationDescription(description) { + const values = {} + for (const part of description.split(/\s+/)) { + const index = part.indexOf('=') + if (index === -1) { + continue + } + values[part.slice(0, index)] = part.slice(index + 1) + } + return values +} + +export function collectTerminalPerfRows(report, source) { + const rows = [] + const visitSuite = (suite) => { + for (const spec of suite.specs ?? []) { + for (const test of spec.tests ?? []) { + for (const annotation of test.annotations ?? []) { + if (!annotation.type.startsWith('opencode-')) { + continue + } + rows.push( + normalizeRow({ + source, + scenario: annotation.type, + ...parseAnnotationDescription(annotation.description ?? '') + }) + ) + } + } + } + for (const child of suite.suites ?? []) { + visitSuite(child) + } + } + for (const suite of report.suites ?? []) { + visitSuite(suite) + } + return rows +} + +function parseMs(value) { + const match = String(value ?? '').match(/^(-?\d+(?:\.\d+)?)ms$/) + return match ? Number(match[1]) : null +} + +function parseCount(value) { + if (value == null || value === '') { + return null + } + const count = Number(value) + return Number.isFinite(count) ? count : null +} + +function normalizeRow(row) { + return { + ...row, + group: scenarioGroup(row.scenario), + panes: parseCount(row.panes), + frames: parseCount(row.frames), + medianMs: parseMs(row.median), + worstMs: parseMs(row.worst), + revisitMs: parseMs(row.revisit), + maxTimerDriftMs: parseMs(row.maxTimerDrift), + scrollMs: parseMs(row.scroll), + restoreMs: parseMs(row.restore), + rendererQueuedChars: parseCount(row.rendererQueuedChars), + rendererPeakQueuedChars: parseCount(row.rendererPeakQueuedChars), + rendererDroppedBacklogs: parseCount(row.rendererDroppedBacklogs), + mainPeakPendingChars: parseCount(row.mainPeakPendingChars), + mainPeakInFlightChars: parseCount(row.mainPeakInFlightChars), + heldAckChars: parseCount(row.heldAckChars), + hiddenSkippedChars: parseCount(row.hiddenSkippedChars), + // Why: parked-memory annotations report a fractional MB heap figure plus + // live renderer view counts; Number() keeps the MB float intact. + heapUsedMB: parseCount(row.heapUsedMB), + liveTerminals: parseCount(row.liveTerminals), + livePaneManagers: parseCount(row.livePaneManagers) + } +} + +export function scenarioGroup(scenario) { + for (const [prefix, label] of SCENARIO_LABELS) { + if (scenario.startsWith(prefix)) { + return label + } + } + return 'Other terminal scenarios' +} + +function scenarioSortKey(scenario) { + const prefixIndex = SCENARIO_LABELS.findIndex(([prefix]) => scenario.startsWith(prefix)) + const paneMatch = scenario.match(/-(\d+)$/) + return [ + prefixIndex === -1 ? SCENARIO_LABELS.length : prefixIndex, + paneMatch ? Number(paneMatch[1]) : 0, + scenario + ] +} + +export function compareScenarios(a, b) { + const ka = scenarioSortKey(a) + const kb = scenarioSortKey(b) + if (ka[0] !== kb[0]) { + return ka[0] - kb[0] + } + if (ka[1] !== kb[1]) { + return ka[1] - kb[1] + } + return ka[2] < kb[2] ? -1 : ka[2] > kb[2] ? 1 : 0 +} + +export function scenarioTitle(scenario, row) { + const group = scenarioGroup(scenario) + if (row?.panes != null) { + return `${group} — ${row.panes} panes` + } + return group +} + +export function budgetFailures(row) { + const failures = [] + for (const [key, budget] of Object.entries(BUDGETS)) { + const value = row[key] + if (value == null) { + continue + } + if (value > budget) { + failures.push(`${key} ${value} > ${budget}`) + } + } + return failures +} + +export function formatMs(value) { + if (value == null) { + return '—' + } + return `${value.toFixed(1)}ms` +} + +export function formatLargeValue(value) { + if (value == null) { + return '—' + } + if (value >= 1024 * 1024) { + return `${(value / (1024 * 1024)).toFixed(2)}M` + } + if (value >= 1024) { + return `${Math.round(value / 1024)}k` + } + return String(value) +} + +export function escapeHtml(value) { + return String(value) + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') +} diff --git a/docs/reference/terminal-hidden-view-parking.md b/docs/reference/terminal-hidden-view-parking.md new file mode 100644 index 00000000000..eeaf17877ea --- /dev/null +++ b/docs/reference/terminal-hidden-view-parking.md @@ -0,0 +1,115 @@ +# Terminal Hidden View Parking + +Status: Shipped — Phase 1 of the terminal model/view architecture, kill switch +`terminalHiddenViewParking` (default on). See +[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) for the +invariants this design extends and the full phase list. + +## Problem + +Hidden terminal panes keep a full renderer xterm instance alive (buffer, +scrollback, DOM, addons). At many-worktree scale this is the dominant renderer +memory cost, and it forces every hidden byte through renderer-side write/skip +decisions. The main-process model (daemon + runtime headless emulators) already +ingests every byte and can serve restorable snapshots, so the renderer view for +a long-hidden pane is redundant state. + +A previous attempt shipped and was reverted the same day. The post-mortem +finding: parking unmounted the pane component, which also tore down the +renderer's PTY byte parsers — and those parsers are the only source of bell +notifications, title-transition agent-complete notifications, and tab titles. +A parked worktree whose agent finished would never notify. This design keeps +those side effects alive while parked. + +## Design + +### Park policy (renderer) + +A pure policy module decides which hidden terminal tabs may park: + +- Cold-park hysteresis: a tab must be hidden for 30s before parking. +- Hot-retain working set: recently visible worktrees/tabs are retained + (5 minutes, bounded count) so quick tab switches never pay a re-hydrate. +- Eligibility excludes: visible panes, hidden-measuring startup probes, + activity-portal panes, tabs with pending startup commands or pending + activation spawns, floating-panel tabs, and any tab whose PTY is not + snapshot-backed (remote-runtime `remote:` PTYs and SSH PTYs are excluded). +- Kill switch: `settings.terminalHiddenViewParking === false` disables parking + entirely. + +### Park mechanics + +Parking a tab unmounts its `TerminalPane` React subtree (the overlay layer +renders null for parked tabs). This is the same teardown that tab-group moves +already exercise: transports detach but the PTY session, daemon model, and tab +state all survive. The xterm instance, its buffers, DOM, and WebGL/addon +resources are released. + +### Parked watcher (the piece the reverted attempt lacked) + +While a tab is parked, a pane-less watcher +(`parked-terminal-byte-watcher.ts`) keeps the pane's side effects alive. Its +consumption mode is decided once at watcher start: + +- **Main side-effect authority on (default):** the watcher is purely + fact-driven — it registers exactly one `pty:sideEffect` fact consumer and + parses no bytes. Titles, agent working/idle/exited transitions, BEL + attention, and PR links arrive as main-tracker facts and drive the same + policy callbacks a mounted pane uses. With the hidden-delivery gate also on, + the watcher marks the PTY hidden so main stops renderer byte delivery + entirely; the DECSET 2031 color-scheme subscribe arrives as main's + `2031-subscribe` fact and the watcher replies out-of-band via + `transport.sendInput`. +- **Kill switch off:** the watcher subscribes to raw bytes through the + dispatcher sidecar mechanism (the same mechanism background agent launches + use) and runs the transport-level byte parsers with no xterm — OSC 0/1/2 + titles (all-titles ordering, live-path normalization), the title-transition + agent tracker (completion notification, prompt-cache timer), the OSC-aware + stateful BEL detector, the GitHub PR link scan, and a dedicated DECSET 2031 + byte responder (`parked-terminal-mode2031-responder.ts`, whose + `subscribeToPtyData` registration doubles as the delivery-interest signal). + +The two modes drive one shared policy-callback block, so flipping the kill +switch never changes notification semantics. Main's synthetic +agent-title/permission frames feed the main tracker directly and arrive as +facts; the legacy synthetic `pty:data` copy exists only in kill-switch-off +mode. + +Out of scope while parked: OSC 52 clipboard writes. Terminal queries inside +hidden-dropped chunks are answered by main's model responder +([`terminal-query-authority.md`](./terminal-query-authority.md)); in +kill-switch-off byte mode only the 2031 reply is answered and Command Code +output is not scraped, matching the pre-gate status quo. + +### Reveal + +Revealing a parked tab remounts the pane subtree and rides the existing +reattach path: fresh xterm via `openTerminal` (unicode provider activation +before any write), daemon model snapshot > relay replay > cold restore +precedence, replay-guarded so snapshot-embedded queries never answer, then +`POST_REPLAY_REATTACH_RESET` hygiene, fit, and PTY resize. The watcher is +disposed before the pane handlers re-register. + +## Invariants + +1. PTY reads never stop; parking only changes renderer-side view lifetime. +2. Bell, agent-completion, title, and PR-link side effects keep working while + parked (watcher parity tests). +3. Reveal shows model-correct output (visual gates: hidden TUI restore, long + table, rendering golden) and accepts input immediately. +4. Sleep/wake, pane close, and PTY restart while parked must not leak watchers + or strand parked state. +5. Memory: parked tabs hold no xterm buffers; renderer memory scales with + visible panes. + +## Relation to later phases (all shipped) + +Side-effect authority in main (Phase 3) replaced the watcher's byte parsing +with the `pty:sideEffect` fact consumer; the hidden-delivery gate (Phase 4) +stops hidden byte delivery in main, moving the parked 2031 reply from the +byte sidecar to the `2031-subscribe` fact; the model query responder +(Phase 5) answers queries in hidden-dropped chunks. The watcher's byte-parser +mode survives only behind the kill switches. Parking still excludes +remote-runtime and SSH PTYs (no local snapshot to restore from); the watcher +would return as a byte parser only if remote-runtime tabs — whose bytes never +transit local main — ever became parkable. diff --git a/docs/reference/terminal-model-view-contract.md b/docs/reference/terminal-model-view-contract.md new file mode 100644 index 00000000000..79fb55575f3 --- /dev/null +++ b/docs/reference/terminal-model-view-contract.md @@ -0,0 +1,218 @@ +# Terminal Model/View Contract + +## Goal + +Terminal output should have one authoritative model path and many disposable +views. A renderer xterm is the fast interactive view, but it must not be the +only place hidden, remote, mobile, SSH, or CLI-visible terminal state exists. + +This contract defines the boundary the shipped terminal stack implements — and +that future terminal work must preserve — without changing the query-response +behavior that real shells and TUIs depend on. See [Architecture +Status](#architecture-status) for the shipped phases. + +## Terms + +- **PTY stream:** Ordered bytes read from a local PTY, daemon PTY, SSH relay PTY, + or remote runtime PTY. +- **Terminal model:** Main/runtime-owned state derived from PTY bytes. Today this + is mostly the headless emulator plus retained read transcript state. +- **Terminal view:** A renderer xterm, mobile subscriber, remote desktop + subscriber, or CLI read page consuming model state and live output. +- **Snapshot:** A bounded model serialization that can restore a view without + replaying an unbounded byte log. +- **Transcript:** The retained output contract for `orca terminal read`; it is + line/cursor oriented and distinct from a screen snapshot. + +## Non-Negotiable Invariants + +1. PTY reads do not stop to protect renderer performance. Backpressure may bound + delivery to views, but terminal state, notifications, titles, and agent + status keep advancing from the PTY stream. +2. Active visible terminal input/output stays on the lowest-latency path. Bulk + hidden or background output must not delay keystroke-sized foreground redraws. +3. Hidden views do not own unbounded output memory. Main's hidden-delivery + gate drops renderer-bound bytes for hidden-marked PTYs after model + ingestion and emits an out-of-band restore marker + (`pty:modelRestoreNeeded`) so the view restores from the model on reveal. + With the gate's kill switches off, hidden bytes ride a bounded renderer + queue whose overflow latches the same model restore. +4. Returning to a hidden or slept terminal must show model-correct output. A + stale or replaced view may be cleared and replayed from a snapshot, but it + must not show a warning fallback when model recovery is available. +5. Snapshots and live bytes have ordering metadata. A view restore must not + duplicate bytes already included in the snapshot or drop bytes that arrived + after it. Main buffer snapshots report the pending-delivery start sequence + (`pendingDeliveryStartSeq`) so the renderer reconciles live chunks racing a + restore without misreading foreign sequence domains as duplicates. +6. Terminal query authority is singular and structural: the party that + writes a chunk into a live terminal answers its queries. Visible renderer + and remote views keep xterm authority. Chunks dropped by the + hidden-delivery gate are answered exactly once by the main model + responder, from runtime-emulator state plus renderer-pushed view + attributes. Replayed, seeded, or snapshot bytes are answered by no one. + The daemon emulator never answers. (Amended by Phase 5 — see + [`terminal-query-authority.md`](./terminal-query-authority.md).) +7. The transcript contract stays separate from screen restore. `orca terminal + read` must preserve bounded previews, cursor pagination, partial-line rules, + truncation flags, and total counts even if view snapshots change shape. +8. Local, daemon, SSH, remote runtime, mobile, and CLI paths must either satisfy + the same model/view contract or explicitly report that model recovery is + unavailable. + +## Current Owners + +| Responsibility | Current owner | +| --- | --- | +| PTY byte source and local/SSH delivery | `src/main/ipc/pty.ts` | +| Hidden-delivery gate (hidden marks, delivery interest, drop accounting, restore markers) | `src/main/ipc/pty-hidden-delivery-gate.ts`, drop sites in `src/main/ipc/pty.ts` and `src/main/ssh/ssh-relay-session.ts` | +| Side-effect parsing and the `pty:sideEffect` facts channel | `src/shared/terminal-output-side-effects.ts` driven from `OrcaRuntimeService.onPtyData`; renderer policy in `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts` | +| Model query responder and view-attribute bridge | `src/main/runtime/terminal-model-query-authority.ts`, `src/main/daemon/terminal-view-attribute-responder.ts`, `src/main/runtime/terminal-view-attribute-store.ts` | +| Hidden view parking policy and parked watcher | `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts`, `parked-terminal-byte-watcher.ts` | +| Daemon PTY state and headless snapshots | `src/main/daemon/headless-emulator.ts` | +| Runtime headless state, retained reads, mobile/session tabs | `src/main/runtime/orca-runtime.ts` | +| Remote terminal subscribe/multiplex/ACK semantics | `src/main/runtime/rpc/methods/terminal.ts` | +| Renderer xterm view and hidden restore behavior | `src/renderer/src/components/terminal-pane/pty-connection.ts` | +| Remote desktop runtime xterm transport | `src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts` | + +## Snapshot Contract + +A model snapshot must include: + +- terminal dimensions used to produce the snapshot; +- enough ANSI state to rehydrate xterm before snapshot content; +- bounded screen and scrollback content; +- title and cwd metadata when known; +- source metadata that distinguishes headless/model snapshots from renderer + fallback snapshots; +- monotonic ordering metadata for live-output reconciliation when available. + +A snapshot must not: + +- include unbounded transcript history; +- answer terminal queries while replaying into the model; +- overwrite newer live view output with older model output; +- hide that recovery was unavailable for a PTY surface. + +## View Contract + +A renderer or remote view may: + +- write active visible output immediately; +- budget visible inactive output; +- stop receiving hidden output entirely while main's hidden-delivery gate owns + the bytes (model restore on reveal); +- request fresh snapshots for restore, mobile subscription, or explicit remote + snapshot recovery. + +A view must: + +- keep live-output buffers bounded while a snapshot is in flight; +- apply generation or sequence checks before replaying a snapshot; +- refresh/repaint after replay when xterm/WebGL needs an explicit paint; +- keep side effects such as title, bell, cwd, and agent status flowing from the + PTY/model path (the `pty:sideEffect` facts channel) even while renderer byte + delivery is budgeted, gated, or parked. + +## Transcript Contract + +The retained read transcript is not a screen dump. It must preserve: + +- uncursored bounded latest preview behavior; +- cursor reads over completed retained lines; +- `oldestCursor`, `nextCursor`, `latestCursor`, and `returnedLineCount`; +- partial-line duplication rules; +- `truncated`, `limited`, and total count metadata; +- bounded memory for long partial lines and large output bursts. + +Snapshot optimizations must be tested against this transcript contract instead +of assuming xterm scrollback serialization can replace it. + +## Required Contract Tests + +Before moving more runtime behavior behind the model/view boundary, add or +extend tests that prove: + +- headless snapshots rehydrate rich alternate-screen TUI state; +- the daemon emulator never answers DA, DSR, OSC 11, or theme-sensitive + queries (the `session.test.ts` pins are permanent); +- the main runtime responder answers queries only from live chunks the + hidden-delivery gate dropped — never delivered, replayed, seeded, or + remote-subscribed chunks; +- hidden renderer overflow restores from model state without duplicate live + output; +- sleep/wake and worktree revisit restore from model-correct state; +- SSH-backed PTYs follow the same snapshot and ordering semantics as local PTYs; +- remote runtime multiplex output remains ACK bounded and can request recovery + snapshots; +- mobile subscribers receive bounded snapshots without unbounded pending live + output; +- retained terminal reads remain pageable and bounded after large output. + +Current coverage is spread across: + +- `src/main/daemon/headless-emulator.test.ts` +- `src/main/daemon/session.test.ts` +- `src/main/ipc/pty.test.ts` (hidden-gate drops, restore markers, + `pendingDeliveryStartSeq`) +- `src/main/ipc/pty-hidden-delivery-gate.test.ts` +- `src/main/runtime/mobile-subscribe-integration.test.ts` +- `src/main/runtime/rpc/terminal-subscribe-buffer.test.ts` +- `src/main/runtime/rpc/terminal-multiplex.test.ts` +- `src/main/runtime/orca-runtime.test.ts` +- `src/main/runtime/terminal-query-responder.test.ts` +- `src/shared/terminal-output-side-effects.test.ts` +- `src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts` +- `src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts` +- `src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts` +- `src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts` +- `src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.test.ts` +- `tests/e2e/terminal-hidden-tui-visual-restore.spec.ts` +- `tests/e2e/terminal-hidden-view-parking.spec.ts` +- `tests/e2e/terminal-parked-memory.spec.ts` +- `tests/e2e/terminal-sleep-wake-restore.spec.ts` +- `tests/e2e/terminal-output-scheduler.spec.ts` +- `tests/e2e/artificial-opencode-terminal-load.spec.ts` + +## Architecture Status + +All six phases of the terminal model/view architecture are shipped; the kill +switches noted in parentheses default on: + +1. **Hidden view parking** — "Park hidden terminal views behind a byte + watcher": hidden terminal tabs unmount their xterm after a cold-park + hysteresis; a pane-less watcher keeps bell/title/agent/PR side effects + alive while parked (`terminalHiddenViewParking`). See + [`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md). +2. **Parked memory benchmarks** — "Benchmark parked hidden terminal memory": + renderer heap and live-terminal counts gate parking in the perf suite + (`tests/e2e/terminal-parked-memory.spec.ts`). +3. **Side-effect authority in main** — "Track terminal titles in main with + all-titles ordering", "Move terminal side-effect authority to a main facts + channel", "Complete terminal side-effect facts coverage", "Finish terminal + side-effect authority migration": every local/daemon/SSH PTY byte is + side-effect-parsed once in main and delivered as `pty:sideEffect` facts + (`terminalMainSideEffectAuthority`). See + [`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md). +4. **Hidden delivery gate** — "Gate PTY delivery to hidden terminal views": + main drops renderer-bound bytes for hidden-marked PTYs after model + ingestion; delivery-interest registrations exempt sidecar byte consumers, + out-of-band restore markers latch model restore, and + `pendingDeliveryStartSeq` reconciles live output racing a restore + (`terminalHiddenDeliveryGate`). +5. **Model query authority** — "Answer hidden terminal queries from the + model", "Bridge renderer view attributes to the model responder", "Align + query authority contract and spawn-time ownership": hidden-dropped queries + are answered by the runtime emulator plus renderer-pushed view attributes, + and hidden-at-spawn PTYs are marked before byte one + (`terminalModelQueryAuthority`). See + [`terminal-query-authority.md`](./terminal-query-authority.md). +6. **Skip grammar deletion** — "Delete the hidden renderer skip grammar": the + renderer's per-chunk hidden-skip eligibility grammar and the 10s codex + startup query window are deleted; the kill-switch-off fallback is the + bounded background queue with overflow-latched model restore. + +Treat every hidden/slept/revisited TUI glitch as a contract failure, not as a +local repaint quirk. Renderer fallback paths retire only when their kill +switches do, and only after the equivalent model path has platform and TUI +golden coverage. diff --git a/docs/reference/terminal-query-authority.md b/docs/reference/terminal-query-authority.md new file mode 100644 index 00000000000..e05b5768836 --- /dev/null +++ b/docs/reference/terminal-query-authority.md @@ -0,0 +1,326 @@ +# Terminal Query Authority + +Status: Shipped — Phase 5 of the terminal model/view architecture, kill +switch `terminalModelQueryAuthority` (default on). Builds on +[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) (this +phase **amends invariant 6**), +[`terminal-side-effect-authority.md`](./terminal-side-effect-authority.md) +(Phase 3), and the Phase-4 hidden-delivery gate +(`src/main/ipc/pty-hidden-delivery-gate.ts`). + +## Problem + +Phase 4 drops renderer-bound bytes for hidden-gated PTYs after model ingestion +(`src/main/ipc/pty.ts:1426,1515`, `src/main/ssh/ssh-relay-session.ts:931`). +Queries embedded in dropped bytes get no reply: DA1 (ConPTY 1.22+ blocks +waiting for it — `terminal-conpty-device-attributes.ts:22`), CPR probes hang +TUIs, OSC 10/11 leaves `claude /theme` blind while hidden. The pre-Phase-4 +hidden skip latch had the same hole (only mode 2031 and the 10s codex startup +window answered), so this is not a regression — it is the long-standing gap +this phase closes. + +Contract invariant 6 ("the model must never answer queries") was written +against a real bug: the daemon emulator replying ahead of the renderer with +default-xterm values (the OSC-11 default-black-background race, +`headless-emulator.ts:86-97`, pinned by `session.test.ts:163-190`). The danger +was never "the model answers" — it was **two answerers for the same bytes**, +one of them with wrong values. Phase 5 keeps the singularity and fixes the +values. + +## Decision: the delivery decision is the reply decision + +Main answers a query **iff main dropped the chunk that carried it**. The same +per-chunk hidden-gate predicate (`shouldDropHiddenRendererPtyData`) that +decides renderer delivery decides reply ownership, evaluated once, +synchronously, at ingestion: + +- Visible/unmarked PTY → chunk delivered → renderer xterm auto-replies via + `Terminal.onData` → `transport.sendInput`, unchanged. +- Hidden-marked, no delivery interest → chunk dropped → main answers from the + runtime headless emulator, via the provider input path (`provider.write`, + same path as `pty:write`; daemon shell-ready write gating and the SSH relay + write apply unchanged). +- Replayed/seeded/snapshot bytes → answered by no one (replay guards on both + sides). + +This is structurally exactly-one-responder: a chunk is delivered or dropped, +never both, and each side only answers bytes it actually parsed live. The +mark/unmark ordering, unhide-before-restore, and restore-marker IPC all exist +from Phase 4 and are reused, not duplicated. + +Rejected alternatives: + +- **Fact-based renderer replies per query class** (the mode-2031 pattern + generalized): needs a main-side detection grammar per query, a fact round + trip per reply, and the renderer cannot answer CPR/DECRPM anyway — the + emulator is the only state for a hidden pane. The 2031 fact stays because it + is subscription registration, not a state query. +- **Emulator always answers**: re-creates the OSC-11 double-reply race for + visible panes. Never. + +## Mechanism: forwarded emulator onData, not a new grammar + +`HeadlessEmulator` has `onData` wiring behind a per-write capture flag. +For static and model-state queries, xterm core **is** the query grammar: the +runtime emulator runs the same xterm version with equivalent options as the +renderer pane, so main's reply set equals the visible renderer's by +construction — verified empirically against the bundled headless build: +DA1/DA2, DSR 5n, CPR, DECRPM (including unknown-mode `0`), DECRQSS (including +DECSCUSR from cursor options), XTVERSION, kitty `CSI ? u` all reply; XTWINOPS +(`windowOptions` stays default-off) and XTGETTCAP stay silent, matching +visible behavior today. The headless build has **no theme service**: OSC +4/10/11/12 queries and DSR ?996n return nothing even with the `theme` option +set, so the view-attribute class is answered by responder-registered parser +handlers instead (below) — never by core defaults. + +Forwarding predicate, captured per chunk in `OrcaRuntimeService.onPtyData` and +attached to the emulator `writeChain` link (the mark can flip between +ingestion and an async write; the decision must not be re-read at reply time): + +1. gate enabled (`terminalMainSideEffectAuthority` and + `terminalHiddenDeliveryGate` both on) AND new kill switch + `terminalModelQueryAuthority !== false`; +2. the chunk was hidden-dropped for this PTY (`shouldDropHiddenRendererPtyData` + — same module state, same tick as the drop sites); +3. the write is live PTY data — never `seedHeadlessTerminal`, + `maybeHydrateHeadlessFromRenderer`, option pushes, or any snapshot replay + (main-side replay guard, mirror of the renderer's `replay-guard.ts`); +4. no remote view subscriber is attached to the PTY (runtime terminal-RPC + subscriber records / `mobileSubscribers`): a mobile/web/remote-desktop + xterm receiving the multiplexed stream answers with view authority, exactly + like a visible local pane. Legacy JSON `terminal.subscribe` streams **do** + register as view subscribers and suppress, even when the consumer is a + read-only watcher — deliberately conservative, because the stream may feed + an older live xterm view and a withheld reply (the pre-Phase-5 status quo) + is strictly safer than a double reply. Consumers that never register a + stream (CLI `terminal.read`, automation observers) do not suppress — they + also do not answer; that bounded no-reply case matches today's behavior. + +Everything the emulator emits outside a forwarding window is discarded, which +also swallows unsolicited core emissions (e.g. native 997 color-scheme pushes +triggered by option mutations). + +## Reply classes + +| Class | Queries | Answer source | +| --- | --- | --- | +| Static | DA1 `CSI c` (ConPTY override below), DA2, DSR 5n, XTVERSION, DECRQM unknown → `0`, kitty `CSI ? u` | xterm core constants + kitty flag state | +| Model-state | CPR `6n`/`?6n`, DECRPM mode table (?1 ?6 ?7 ?25 mouse ?1004 ?1006 ?1016 ?1049 ?2004 ?2026, insert), DECRQSS DECSTBM/DECSCA/SGR, kitty flags | emulator buffer/mode state — for a hidden pane it is the only state, hence authoritative | +| View-attribute | OSC 4/10/11/12 `;?` queries, DSR ?996n | responder parser handlers + renderer attribute push (below); **silent until first push** | +| View-attribute (via options) | DECRQSS DECSCUSR, DECRQM 12 | xterm core, from pushed `cursorStyle`/`cursorBlink` emulator options | +| Silent | XTWINOPS, XTGETTCAP, ?15n/?25n/?26n/?53n | nobody, visible or hidden | +| Mode 2031 | DECSET 2031 subscribe | unchanged in Phase 5: main emits the `2031-subscribe` fact, the renderer replies (`handleHiddenMode2031SubscribeFact`, `pty-connection.ts`; parked watcher fact callback). Emulator-native 2031/997 output is suppressed by the forwarding guard | + +### View-attribute bridge + +Renderer→main push, `pty:terminalViewAttributes` — one global snapshot, +not per-PTY: the composed terminal `ITheme` (from +`applyTerminalAppearance`, `terminal-appearance.ts`), +`terminalCursorStyle`, `terminalCursorBlink`, and the resolved color-scheme +mode (`resolveTerminalColorSchemeMode` — the same source as the existing +hidden 2031 reply). Pushed on renderer startup and on every theme/settings +apply. + +Main consumes it two ways: + +- `cursorStyle`/`cursorBlink` are applied to every runtime emulator's options + inside the replay guard; xterm core then answers DECRQSS DECSCUSR and + DECRQM 12 with renderer-true values (verified working headless). +- Palette and color-scheme replies come from responder-registered parser + handlers on the emulator (`registerOscHandler` 4/10/11/12, + `registerCsiHandler` for DSR ?996n), because the headless core cannot + answer them. The OSC handlers see SET payloads too, so runtime OSC + 4/10/11/12 mutations (and 104/110/111/112 resets) from the byte stream are + tracked per PTY and layered over the pushed base palette — matching what + the renderer's theme service reports for a visible pane. + +Staleness rules: replies use the last push; a theme flip is stale for at most +one IPC hop (subscribed TUIs are corrected by the 2031/997 flip push). +**Before the first push main answers no view-attribute query** — a fabricated +default would resurrect the default-black OSC-11 bug; silence is the +documented hidden status quo. + +### Kitty keyboard flags + +`vtExtensions.kittyKeyboard: true` is enabled in `HeadlessEmulator`, matching +`buildDefaultTerminalOptions` (`pane-terminal-options.ts:50`). Risk is low: +for the write-only daemon use, keyboard state never alters serialization; the +change only makes the emulator parse `CSI =/>/< u` pushes instead of ignoring +them, and lets the responder answer `CSI ? u` with the flags the hidden app +actually pushed. Snapshot parity: add `kittyKeyboardFlags` to `TerminalModes` +for emulator re-seed parity only. `rehydrateSequences` must **not** push kitty +flags into a renderer xterm — `POST_REPLAY_REATTACH_RESET`'s deliberate kitty +reset (stale CSI-u Ctrl+C hazard, `terminal-replay-cursor-state.test.ts`) +stays authoritative. Slice 3 wires the re-seed consumer: the daemon +warm-reattach snapshot threads `modes.kittyKeyboardFlags` through the spawn +result into `seedHeadlessTerminal`, which applies them to the fresh runtime +emulator via its own `CSI = flags ; 1 u` parse (outside any forwarding +window), so hidden `CSI ? u` reports the flags the hidden app actually +pushed. Paths without a snapshot (cold restore spawns a fresh shell) answer +`?0u`; protocol-conformant programs re-push. + +### ConPTY DA1 variant + +The provider kind is known main-side: mirror `isLocalNativeWindowsPty` +(`windows-pty-compatibility.ts:59`) from the spawn record (local/daemon +provider, `win32`, not WSL). For such PTYs register a CSI `c` override on the +emulator parser (the main-side twin of +`installConptyDeviceAttributesHandler`) replying `CSI ?61;4c`, still gated by +the forwarding predicate. The override is installed at emulator creation and +retrofitted when the spawn mark lands (daemon stream data can create the +emulator before the awaited spawn response marks the PTY). ConPTY blocking on +a missing DA1 is a spawn-time hazard; the hidden-at-spawn loss window is +closed by the slice-3 `initiallyHidden` spawn flag (races section). + +## Suppression: when main never replies + +- Visible or unmarked PTY (chunk was delivered). +- Renderer delivery interest registered (chunk was delivered to a sidecar). +- Remote-runtime (`remote:`) PTYs — never markable + (`isHiddenDeliveryGateManagedPty`), bytes never transit local main. +- Remote view subscriber attached (mobile/web/remote desktop owns replies). +- Seed/hydration/snapshot writes into the emulator, and option pushes. +- Kill switches off — no marks exist, and `terminalModelQueryAuthority` is an + independent off switch for the responder alone. +- The **daemon** emulator: never, under any setting. The responder lives in + main's runtime only; `session.test.ts:163-190` stays pinned verbatim. + +## Transition races + +Worst cases, per direction: + +- **visible→hidden**: chunks delivered between the visibility flip and the + mark landing in main are hidden-skipped by the renderer write path without + query scanning. No reply, no duplicate — identical to the pre-Phase-4 hidden + skip behavior, bounded by one renderer→main IPC hop. After the mark lands, + main answers everything it drops. +- **hidden→visible**: unmark consumes the drop latch and emits the restore + marker; the snapshot replay is replay-guarded, so queries main already + answered are never re-answered from the snapshot; post-unmark live chunks + are answered by xterm once (restore-queued live chunks reply late, not + twice). +- **Split queries across the drop/deliver boundary**: neither parser saw the + whole sequence → no reply; the restore marker resets renderer cross-chunk + state and replay hygiene resets the parser. At-most-once holds. + +Safe-side rule per class: duplicates are structurally impossible (one decision +point per chunk); where the race costs anything it costs a missing reply. +That is acceptable for state queries (DSR/CPR/DECRPM — TUIs re-probe or +tolerate silence, as they did for every hidden pane before this phase). The +one blocking-on-no-reply sequence, ConPTY DA1, only fires at spawn. A visible +pane answers it from the renderer xterm. A PTY spawned hidden previously had +no answerer until the renderer's hidden mark landed in main (one IPC hop +after spawn). Slice 3 closes that window with the `initiallyHidden` +spawn-record flag: the renderer declares hidden-at-spawn on `pty:spawn` +(never for remote-runtime transports), and main marks the PTY hidden before +the first byte — pre-spawn for +daemon-host sessions whose id is minted up front, immediately after +`provider.spawn` resolves otherwise — so the gate and responder own queries +from byte one. The pane's first visibility sync then re-marks or unmarks +through the existing Phase-4 machinery (unmark emits the restore marker for +any spawn-window drops). + +## Invariants + +1. Exactly one party may answer any query, chosen by the chunk's delivery + decision: delivered → the consuming live view's xterm; dropped → main's + model responder; replayed/seeded → no one. The decision is captured once, + synchronously, at ingestion. +2. Main answers only from live PTY bytes parsed by the runtime emulator — + never from snapshot, seed, hydration, or option-push writes. +3. View-attribute answers are renderer-true or absent: no reply is ever + fabricated from emulator defaults (the OSC-11 lesson). +4. The daemon emulator stays write-only; daemon subprocess query writes stay + zero (`session.test.ts` pins are permanent). +5. Reply parity is structural for static and model-state classes: same xterm + core, equivalent options, no hand-rolled grammar — the only overrides are + the documented ConPTY DA1 variant and the view-attribute parser handlers + the headless core cannot serve. +6. Remote views keep view authority; main yields whenever a remote view + subscriber is attached. + +**Contract amendment** — `terminal-model-view-contract.md` invariant 6 is +replaced by: + +> 6. Terminal query authority is singular and structural: the party that +> writes a chunk into a live terminal answers its queries. Visible renderer +> and remote views keep xterm authority. Chunks dropped by the +> hidden-delivery gate are answered exactly once by the main model +> responder, from runtime-emulator state plus renderer-pushed view +> attributes. Replayed, seeded, or snapshot bytes are answered by no one. +> The daemon emulator never answers. + +The contract's test bullet "headless tracking does not answer DA, DSR, OSC 11, +or theme-sensitive queries" splits into: daemon emulator never answers +(unchanged pins) / runtime responder answers only hidden-dropped chunks. The +side-effect authority matrix row "DECSET 2031 reply — query authority stays +with the view (contract invariant 6)" gains a pointer here; its reply path is +otherwise untouched in this phase. + +## Test strategy + +- Responder unit tests beside `orca-runtime.test.ts`: marked vs unmarked vs + interest-suppressed; each reply class; seed/hydrate silence; remote- + subscriber suppression; ConPTY DA1 variant; kill-switch off; mark flip + between ingestion and async emulator write (captured decision wins). +- Parity harness: shared query byte fixtures through a renderer-configured + xterm (onData capture) and through the responder; assert byte-identical + replies for static + model-state classes, and for view-attribute classes + after an attribute push. +- `session.test.ts:163-190`: assertions stay; the comment is updated to name + the main responder (not "the renderer") as the hidden answerer. +- E2E: hidden `claude /theme` reports the configured theme; hidden TUI + blocked on CPR/DA unblocks while gated; reveal shows no stray reply + fragments (`?1;2c`, `rgb:` …) on the prompt; Windows ConPTY golden and + `terminal-hidden-view-parking.spec.ts` stay green. + +## Cut-offs (shipped as three stacked slices) + +1. **Responder core.** Emulator onData wiring + per-write capture + main + replay guard; kitty flag enable (+ `TerminalModes.kittyKeyboardFlags`); + static + model-state classes; ConPTY DA1 override; remote-subscriber + suppression; `terminalModelQueryAuthority` switch; unit + parity tests. + Main-only — no renderer change. Ships the DA1/CPR/DECRPM unblock. +2. **View-attribute bridge.** `pty:terminalViewAttributes` push, cursor + option application under the guard, responder OSC/DSR parser handlers with + per-PTY palette-mutation tracking, silent-until-push rule, `/theme` e2e. +3. **Contract alignment.** Invariant-6 amendment in the contract doc, test + bullet split, `session.test.ts` comment, side-effect matrix pointer, and + the Phase 6 prerequisites below recorded as accepted. + +## Phase 6 (delete skip grammar + startup window): prerequisites from this design + +Phase 6 is shipped: the renderer hidden-skip eligibility grammar and the 10s +codex startup renderer-query window are deleted. Kill-switch-off hidden panes +fall back to the pre-grammar path — hidden bytes ride the bounded background +scheduler queue; overflow latches the model-snapshot restore — and never run +a per-chunk content scan. + +Accepted and shipped in slice 3 (except where noted): + +- **Mark-before-first-byte** (shipped): panes spawned without a visible view + are hidden-marked at spawn via the `initiallyHidden` flag on `pty:spawn` + (spawn-record flag, not a renderer round trip) so startup queries — + including ConPTY's blocking DA1 — are main-owned from byte zero. Phase 6 + removed the codex exclusion with the window: codex spawns are main-owned + from byte zero too, the responder answering their startup probes. +- **Attributes before spawn** (shipped): the renderer pushes composed view + attributes once at app start (right after settings load, before terminal + reconnect/spawn), so spawn-time view-attribute queries no longer fall into + the silent-until-push rule. Per-pane appearance applies keep re-publishing + through the same deduped publisher. +- **Daemon shell-ready write gating** (verified): responder replies through + `ptyController.write` → daemon `Session.write` are QUEUED pre-ready, never + dropped, and the queue flushes at the shell-ready marker or the 15s + `SHELL_READY_TIMEOUT_MS` bound (`session.ts`). The codex window was removed + with hosted ConPTY golden coverage, unit DA1 parity, and the kill switches + as the safety net; explicit spawn-time e2e on Windows daemon PTYs remains + worth adding. +- With the skip grammar deleted, every chunk is either written to a live + xterm or dropped — the delivered-but-skipped no-reply gap disappears and + the only remaining loss window is the mark IPC race. +- **2031 consolidation** (optional follow-up): move the subscription registry + into the responder (the headless core cannot serve 997 pushes any more than + it can ?996n) and push 997 flips from the attribute cache, retiring the + `2031-subscribe` fact reply, the parked responder, and the parked-tab + theme-flip gap. diff --git a/docs/reference/terminal-side-effect-authority.md b/docs/reference/terminal-side-effect-authority.md new file mode 100644 index 00000000000..852a607d5bf --- /dev/null +++ b/docs/reference/terminal-side-effect-authority.md @@ -0,0 +1,252 @@ +# Terminal Side-Effect Authority + +Status: Shipped — Phase 3 of the terminal model/view architecture, kill switch +`terminalMainSideEffectAuthority` (default on). Builds on +[`terminal-model-view-contract.md`](./terminal-model-view-contract.md) and +[`terminal-hidden-view-parking.md`](./terminal-hidden-view-parking.md) (Phase 1). + +## Problem + +Main parses every local/daemon/SSH PTY byte before renderer delivery +(`OrcaRuntimeService.onPtyData` in `src/main/runtime/orca-runtime.ts`: +side-effect tracker, OSC 9999 agent status, headless emulator, tails, URL +watchers; SSH feeds the same path from `wireUpPtyEvents` in +`src/main/ssh/ssh-relay-session.ts`). Before this phase, the side effects +users see — bell unread/notifications, title transitions, agent-complete +notifications, command lifecycle, PR links — were derived a second time by +renderer byte parsers. That duplication forced Phase 1's watcher to parse +bytes, forced main to fabricate synthetic OSC title frames over `pty:data` +just so renderer parsers could see them, and blocked Phase 4 from ever +stopping hidden byte delivery. Phase 3 made main the side-effect parser for +every PTY whose bytes transit local main; the renderer byte parsers +(`createPtyOutputProcessor` in `pty-transport.ts`, the parked watcher's byte +mode) survive only for remote-runtime PTYs and the kill-switch-off fallback. + +## Authority Matrix + +"Main" means parsed once in `onPtyData` and delivered as derived facts. +Remote-runtime PTYs (`remote:`) never transit local main; the renderer +(`remote-runtime-pty-transport.ts:74`) stays their parser permanently. + +| Side effect | local-daemon | SSH | remote-runtime | +| --- | --- | --- | --- | +| OSC 9999 agent status | main (parsed in `onPtyData`, emitted as `agentStatus:set`) | main | renderer (`shouldOwnAgentStatusInRenderer`, `pty-connection.ts`) | +| OSC 0/1/2 titles + working/idle/exited tracker + 3s stale-title timer | main | main | renderer | +| BEL attention (OSC-aware stateful detector) | main | main | renderer | +| OSC 133;D command-finished exit code | main | main | renderer | +| GitHub PR-link scan | main | main | renderer | +| Command Code output scrape | main (per-PTY detector beside the tracker → `command-code-working`/`command-code-done` facts; the renderer pane keeps the done settle timer — it must consult the live status row) | main | renderer | +| DECSET 2031 color-scheme reply | renderer view/watcher — the 2031 fact reply path is untouched by Phase 5; general query authority is now per-chunk structural ownership, see [`terminal-query-authority.md`](./terminal-query-authority.md) (contract invariant 6 as amended) | same | renderer | +| DECSET 2004 paste readiness (`agent-paste-draft.ts`) | renderer — input pacing, not a model side effect | renderer | renderer | + +## Main-Side Tracker + +- The side-effect core shared with the renderer processor lives in + `src/shared/terminal-output-side-effects.ts`: all-titles ordering via + `extractAllOscTitles` (coalesced working→idle transitions are why last-title + is insufficient — issue #1083), `normalizeTerminalTitle`, the literal + `cursor agent` title drop (`CURSOR_NATIVE_TITLE_LOWER`, + `src/shared/agent-detection.ts`), the `createAgentStatusTracker` + transitions, the stale-working-title 3s timer + (`STALE_WORKING_TITLE_TIMEOUT_MS`), and the stateful BEL detector + (`src/shared/terminal-bell-detector.ts`). +- One tracker per PTY on `OrcaRuntimeService`, lazily created like + `agentStatusOscProcessorsByPtyId`; disposed in `onPtyExit` (cancels the + stale-title timer). +- It replaced the chunk-level last-title extraction in `onPtyData`: titles + feed in byte order, so `lastOscTitle`/`lastAgentStatus`, tui-idle waiters, + and pending-message delivery see intermediate transitions instead of only + the chunk's last title. PTY/leaf records keep the **raw** last title + (worktree `ps` and mobile tab titles expect raw); emitted facts carry + `(normalizedTitle, rawTitle)` like `onTitleChange`. +- No deferred drain in main — the renderer's setTimeout(0) batching + (`sideEffectDrainTimer`, `pty-transport.ts`) protects xterm paint, which + does not exist in main. Main applies synchronously and batches the IPC per + flush. +- The stats `AgentDetector` (`src/main/stats/agent-detector.ts`) keeps its own + last-title scan, untouched: synthetic titles must never reach it. + +## Event Transport: `pty:sideEffect` + +One batched main→renderer channel (`window.api.pty.onSideEffect`, +`src/preload/index.ts`). It is **not** routed through the pty dispatcher: +the renderer fact-consumer registry +(`terminal-side-effect-facts-handler.ts`) subscribes directly via +`window.api.pty.onSideEffect` — one channel subscription per renderer, with +exactly one registered fact consumer per PTY. Events are **facts, not +decisions**: `title`, `bell`, `agent-working`, `agent-idle` (with title), +`agent-exited`, `command-finished` (exit code), `pr-link`. Each carries +`ptyId`, main-known attribution (worktreeId/tabId/paneKey from runtime leaf +records, same resolution as `emitTerminalAgentStatusEvents`), and the PTY +`outputSequence`. + +Ordering rules: + +1. Per-PTY in-order; facts from one chunk are emitted in byte order (status + payloads, then titles in sequence, then bell — the renderer drain's order). +2. Deliberately **not** synchronized with `pty:data`: side effects must keep + advancing while renderer delivery is ACK-gated (contract invariant 1). A + completion title may reach the store before the visible xterm paints the + final output; that is acceptable — attention/title state is out-of-band UI + state, and today's renderer drain already decouples by many batches under + timer throttling. +3. No attention replay: facts emitted while no renderer is subscribed are + dropped. On transport attach/park-handoff the renderer pulls a title-only + snapshot (`pty:sideEffectSnapshot`) marked `replay: true` — this reproduces + the eager-buffer behavior where replay restores titles but is barred from + bells/completions (`suppressAttentionEvents`, `pty-transport.ts`). The + store handler ignores a replay title older (by `outputSequence`) than the + last live title fact it applied. + +## Renderer Store Handler (policy stays in the renderer) + +Notification semantics, all preserved across the authority flip: + +- BEL marks worktree+tab unread unconditionally — including the focused pane + (`onBell`, `pty-connection.ts`); pane unread only behind + `experimentalTerminalAttention`; keydown clears unread + (`onTerminalKeyDown`, `pty-connection.ts`). +- BEL's OS notification is delayed 250 ms and yields to a pending + agent-task-complete (`scheduleTerminalBellNotification`, + `pty-connection.ts`). +- working→idle starts the Claude cache timer (null settings = not hydrated, + treat enabled) and schedules completion with 250 ms grace + 1500 ms max + wait + detail-wait store subscription + (`AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS` / + `AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS`, + `agent-task-complete-policy.ts`). +- Completion unread is suppressed only for the exact visible foreground pane + (`isVisibleForegroundPaneKey`, `use-notification-dispatch.ts`); BEL unread + has no such check. +- Dispatch-time liveness/staleness guards + (`dispatchTerminalNotification`, `use-notification-dispatch.ts`) and main's + 5 s per-worktree cooldown (`NOTIFICATION_COOLDOWN_MS`, + `src/main/ipc/notifications.ts`) remain the final gates. + +These need live renderer store state (PTY/layout maps, pane visibility, +settings, `agentStatusByPaneKey`, repo labels), so they stay in the renderer: +the pane-independent per-paneKey handler module +(`terminal-side-effect-facts-handler.ts`) consumes `pty:sideEffect` and +subsumes both `pty-connection.ts`'s callbacks and the parked watcher's +callback block (`sideEffectCallbacks`, `parked-terminal-byte-watcher.ts`) — +one policy path whether the tab is mounted, hidden, or parked. Main holds +**no** notification timers; only the stale-title timer (parser state) lives +in main. + +## Synthetic Frame Reroute + +`driveSyntheticTitleFromHook` and the spinner tick (`sendSyntheticTitle`, +`src/main/index.ts`) feed `runtime.ingestSyntheticTitleFrame(ptyId, data)`, +so synthetic agent-title/BEL frames enter the per-PTY tracker directly — +**not** `onPtyData`, so emulator state, tails, transcripts, and stats never +see them. The decorative-frame visibility gating +(`shouldSendSyntheticTitleFrame`) stands. The legacy synthetic `pty:data` +copy survives only in kill-switch-off mode, where renderer parsers still +need the bytes. The visible xterm renders nothing from titles, but +`pane.terminal.onTitleChange` feeds `registerPtyTitleSource` +(`pty-connection.ts`) → renderer serialize-snapshot `lastTitle` (mobile +parity); main prefers its own tracked title over renderer snapshot +`lastTitle` in both serialize paths. Under main authority synthetic frames +no longer produce phantom ACKs for bytes main never metered (`ackPtyData`, +`pty-dispatcher.ts`). + +## Migration Switch and Double-Fire Prevention + +Authority is structural per PTY kind — the predicate is "bytes transit local +main", exactly the `shouldOwnAgentStatusInRenderer` split +(`pty-connection.ts`). One renderer-consulted kill switch +(`settings.terminalMainSideEffectAuthority`, default on, mirroring +`terminalHiddenViewParking`): when on, IPC transports and the parked watcher +do not register byte parsers for local/SSH and the store handler consumes +`pty:sideEffect`; when off, renderer parsers register and `pty:sideEffect` +events are ignored. Main always parses and emits (its internal consumers need +the tracker regardless); main consults the same setting only to keep the +legacy synthetic-frame `pty:data` path alive while the switch is off. Exactly +one consumer per fact at any time — decided at transport/watcher creation, so +no per-chunk race. + +## Sidecar Consumers and Phase 4 + +Keep renderer byte access (input pacing / raw-output consumers, not side +effects): `agent-paste-draft.ts` (DECSET 2004 readiness), +`launch-agent-background-session.ts` (startup-injection pacing, onData +passthrough), `automation-session-observer.ts` (onData passthrough), and +`parked-terminal-mode2031-responder.ts` (DECSET 2031 theme replies for +parked tabs while the delivery gate is off). Their duplicated local OSC 9999 +store writes are gated off under main authority (the `onAgentStatus` +automation callbacks still fire; only the racing `setAgentStatus` store +writes drop). The Phase-4 hidden-delivery gate exempts PTYs with an active +`subscribeToPtyData` sidecar: registration is auto-surfaced to main as a +ref-counted delivery-interest signal (`pty-delivery-interest.ts`). With main +authoritative, the parked watcher is purely fact-driven: byte parsing exists +only in kill-switch-off mode, and the 2031 reply comes from the +`2031-subscribe` fact when the gate is on (the byte responder sidecar only +when it is off). The watcher file is deleted outright only when the kill +switch retires — it returns as a byte parser only if remote-runtime tabs +ever become parkable. + +## Invariants + +1. Every byte is side-effect-parsed exactly once, by exactly one authority, + chosen structurally per PTY kind. +2. Attention facts never replay: snapshot/eager/attach replays restore title + state only. +3. Notification policy (grace timers, yielding, suppression, dispatch guards) + lives with the renderer store; main emits facts with ordering metadata. +4. Side-effect facts keep flowing while renderer byte delivery is + backpressured, parked, or stopped by the hidden-delivery gate. +5. Synthetic agent frames feed the model tracker, never the emulator, tails, + transcripts, or stats. + +## Test Strategy + +- Parity harness (`terminal-title-tracker-parity.test.ts`): shared byte + fixtures (agent title cycles incl. coalesced chunks, BEL inside/spanning + OSC, CAN/SUB cancellation, cursor-agent literal, stale-title timeout under + fake timers, OSC 133;D, split PR URLs) run through the renderer + `createPtyOutputProcessor` and the main tracker; assert identical ordered + fact sequences. +- Unit: main tracker tests beside `orca-runtime.test.ts` (lastOscTitle + parity, tui-idle waiter transitions, synthetic ingestion); store-handler + tests reusing `parked-terminal-byte-watcher.test.ts` scenarios. +- Pinned tests that flip or retire: `pty-connection.test.ts` callback wiring, + `parked-terminal-byte-watcher.test.ts` (retires with the watcher); + `pty-transport*.test.ts` stay (processor remains for remote + kill switch). +- E2E gates that must stay green throughout: `terminal-attention.spec.ts`, + `droid-notification.spec.ts`, `terminal-hidden-view-parking.spec.ts`, + `terminal-parked-memory.spec.ts`; add main-authority bell/completion cases + (parked tab, focused-pane suppression, kill switch off). SSH parity is + exercised manually per the SSH test procedure before each slice ships. + +## Cut-Offs (shipped as four stacked slices) + +1. **Shared tracker in main.** Extract the processor core to shared, run the + per-PTY tracker in `onPtyData` replacing `extractLastOscTitle`, parity + tests. Main-internal consumers only; no IPC or renderer change. +2. **Authority flip.** `pty:sideEffect` channel, renderer store handler, + titles/bell/tracker authority to main for local+SSH behind the kill + switch; parked watcher stops byte parsing for those kinds. +3. **Inversion unwind.** Synthetic frames into the tracker, off `pty:data`; + OSC 133;D and PR-link facts; mobile `lastTitle` source preference. +4. **Long tail.** Command Code scrape to main, sidecar OSC 9999 dedup, parked + watcher shrunk to fact-driven mode (deletion waits on kill-switch + retirement), Phase 4 delivery-interest registration documented in the gate + design. + +## Open Items + +- **Daemon checkpoint `lastTitle` is write-only.** The daemon sleep/periodic + checkpoint (`daemon-pty-adapter.checkpointSessions` → daemon + `Session.getSnapshot`) persists the daemon emulator's `lastTitle`, which is + derived from real PTY bytes only — synthetic hook title frames never reach + the daemon process, so that field cannot carry hook-driven titles. Today no + restore path reads it back (`ColdRestoreInfo` drops it; reattach snapshots + surface only the ANSI payload), so there is nothing to fix. Main-side + consumers of the renderer serializer's `lastTitle` (mobile snapshot reads + and the headless hydration seed) prefer main's tracked title. If a future + consumer starts reading checkpoint `lastTitle`, it must route through the + same tracked-title preference. +- **Kill-switch retirement.** Once `terminalMainSideEffectAuthority` is + removed, the parked watcher's byte-parser mode, the renderer transport + parsers for local/SSH, and the legacy synthetic-frame `pty:data` copy all + become dead code and the watcher byte path can be deleted outright. diff --git a/docs/terminal-main-owned-state.md b/docs/terminal-main-owned-state.md index a9c3165e7cf..e3e7a125988 100644 --- a/docs/terminal-main-owned-state.md +++ b/docs/terminal-main-owned-state.md @@ -1,5 +1,9 @@ # Terminal Main-Owned State +This document covers the hidden-output recovery slice. The broader terminal +model/view boundary is defined in +[`reference/terminal-model-view-contract.md`](./reference/terminal-model-view-contract.md). + ## Problem Hidden and background terminal panes cannot rely on renderer memory as the only @@ -51,6 +55,13 @@ already reaches `OrcaRuntimeService.onPtyData` before renderer delivery for local, daemon, and SSH PTYs. That path keeps a headless xterm emulator updated and can serialize it. +Since the hidden-delivery gate shipped (`terminalHiddenDeliveryGate`, default +on — see the contract's Architecture Status), main drops hidden renderer-bound +bytes after model ingestion and emits an out-of-band restore marker, so a +gated hidden pane accumulates no renderer backlog at all. The overflow path +below is the fallback for kill-switch-off mode and for hidden PTYs with an +active delivery-interest sidecar. + The renderer scheduler keeps its 2 MB background cap. When the cap is exceeded: 1. The scheduler replaces the queued backlog with a small warning fallback. diff --git a/notes/garble-fuzz-divergences.md b/notes/garble-fuzz-divergences.md new file mode 100644 index 00000000000..dde77ac2450 --- /dev/null +++ b/notes/garble-fuzz-divergences.md @@ -0,0 +1,296 @@ +# Garble differential fuzz — divergence log + +Findings from the HeadlessEmulator-vs-renderer-twin differential fuzz +(`src/main/daemon/headless-emulator-fidelity.fuzz.test.ts`). Each divergence is +a case where restoring a hidden terminal from its main-side snapshot +(`serialize → replay`, exactly as `applyMainBufferSnapshot` does on reveal) +produces a screen that differs from an always-visible renderer terminal fed the +same bytes. Any such diff is a user-visible garble on reveal. + +## Method + +- Corpus: seeded agent-TUI byte streams (`buildAgentTuiStreamOps`), 3 pane + sizes, PTY-style random chunk splitting. +- Differential: production `HeadlessEmulator` snapshot replayed into a fresh + renderer-parity terminal, compared cell-by-cell (text, per-cell style, + cursor, modes, scrollback) against an always-visible renderer-parity twin. +- Parity confirmed: `createRendererParityTerminal` mirrors the renderer pane's + buffer-affecting options exactly — `scrollback: 5000`, `allowProposedApi`, + `vtExtensions.kittyKeyboard`, `Unicode11Addon`, Orca ZWJ provider (verified + against `buildDefaultTerminalOptions` in + `src/renderer/src/lib/pane-manager/pane-terminal-options.ts` and + `pane-dom-creation.ts`). Render-only options (`minimumContrastRatio`, + `drawBoldTextInBrightColors`, font, cursor, scrollbar) do not alter stored + cell attributes, so their omission is not a source of false diffs. + `windowsMode` is unset in both (matches renderer). Addon versions: + `@xterm/addon-serialize` / `@xterm/headless` / `@xterm/addon-unicode11` all + `*-beta.287` (headless `6.1.0-beta.287`). +- Scan: seeds 1..2000. Every divergence is either the known serialize-wrap bug + (predicate `bufferHasSerializeHostileWrappedRow`, tolerated + counted) or is + listed below. + +## Inventory + +| bug | found by | seeds | classification | +| --- | --- | --- | --- | +| A — serialize wrap null-cell | fidelity (suite 1) | 31, 157, 171, 207, 423, 426, 502, 801, 815, 826, 865, 881, 923, 977, 1004, 1119, 1142, 1238, 1241, 1318, 1351, 1374, 1532, 1601, 1657, 1728, 1770 (27 in 1..2000) | (a) real serialize bug, pre-documented + pinned — STILL OPEN | +| B — SGR bold loss (`1;22`) | fidelity (suite 1) | 435, 770, 1321 | (a) real serialize bug — FIXED by the addon patch (intensity-group SGR reorder, config/patches) | +| C — cursor off-by-one at right margin | fidelity (suite 1) | 454, 1696 | (a) real serialize bug — FIXED Orca-side (absolute-cursor epilogue, serializeWithAbsoluteCursor) | +| D — DECSC saved-cursor lost across reveal | reconciliation (suite 2) | seed 3 | (a) real snapshot limitation — FIXED (snapshot re-saves the DECSC register, readSavedCursorRegister) | +| E — snapshot boundary mid-escape-sequence | reconciliation (suite 2) | seed 4 (+~24% of corpus) | (a) real snapshot limitation — FIXED (pendingEscapeTailAnsi carried out-of-band, terminal-partial-escape-tail.ts) | + +Status update (fix/snapshot-decsc-midescape): B/C/D/E repros are UNSKIPPED and +their corpus tolerances removed — only Bug A remains tolerated + counted. Bug D +carries position only (saved SGR/charset are not re-established — the synthetic +ESC 7 saves the serializer's final pen). Bug E's pending tail is a separate +snapshot field written LAST by restorers because any later ESC (e.g. the +post-replay reset) would abort the dangling sequence; its bytes are already +counted by the snapshot seq, so tail-slice arithmetic is unchanged. + +All five bug classes are reproduced by dedicated minimal `test.skip` repros so +they cannot silently regress, AND each is tolerated + counted by its suite's +corpus loop so deep mode surfaces only genuinely NEW divergences: + +- Suite 1 (fidelity): Bug A via `bufferHasSerializeHostileWrappedRow`, Bug B via + `snapshotHasSelfCancellingBoldReset` (matches the `1;22` in the serialized + snapshot), Bug C via `isMarginWrapPendingCursorOffByOne` (cursor x-1 with a + full-width content row). Green at the default 300 and at `FUZZ_ITERATIONS=2000`. +- Suite 2 (reconciliation): Bug E via `prefixEndsMidSequence`. Bug D and the + Bug-C cursor cascade are kept out of the corpus by an append-only racing tail + (no DECSC/cursor motion) and pinned only as standalone repros. Green at the + default 200 and at `FUZZ_ITERATIONS=1000`. + +Each tolerance has a `< max(3, ITERATIONS*0.5)` guard so a predicate that starts +tripping on most seeds fails the suite instead of silently swallowing it. + +Seed 113 (called out in the handoff as a "DECSC/DECRC detour writing colored +text mid-line") does not diverge on the current harness. It is a `savedCursor +Detour` op seed; DECSC/DECRC SGR carry is correctly preserved by both the +emulator and the serializer here. It was most likely an earlier observation +folded into Bug C (the DECRC cases 1696 also involve `\x1b7`/`\x1b8`), or a +transient during harness construction. No live divergence at 113. + +--- + +## Bug A — SerializeAddon drops null cells at a soft-wrap boundary + +**Classification: (a) real `@xterm/addon-serialize` bug.** Pre-existing; found +and minimized by the prior agent, pinned by two `test.skip` repros in the fuzz +suite (V1 seed 31, V2 seed 157). Full mechanism documented in +`bufferHasSerializeHostileWrappedRow` and the suite's headline comment. + +- **V1 (cell loss):** a wrapped continuation row starting with a NULL cell + passes the addon's wrap-validity ternary, gets skipped with `CUF` which clamps + at the right margin, overwriting the previous row's last cell and shifting the + tail left by one. `cols=20: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K'`. +- **V2 (stray `-` filler):** a wrapped pair whose source row is entirely null + takes the forced-wrap "magic" path; cleanup emits `ESC[0C` (param 0 → 1) so + the ECH erase lands one cell right and the first filler `-` survives. + +**Impact:** any snapshot consumer (hidden reveal, parked-tab reveal, sleep/wake, +mobile subscribe replay) paints lost/shifted characters or stray `-` fillers +when a TUI erases inside a soft-wrapped line. Tolerated + counted by the suite; +unskip the repros when upstream fixes or a local serialize post-processor lands. + +--- + +## Bug B — SerializeAddon loses BOLD when serializing a dim→bold-only transition + +**Classification: (a) real `@xterm/addon-serialize` bug.** New finding. + +**Seeds:** 435, 770 (alt-screen), 1321 (minimal, 2 ops). + +**Minimal repro (isolated, no fuzz corpus needed), cols=20:** + +``` +live bytes: "\x1b[2mA\x1b[22m\x1b[1mB" +SerializeAddon → : "\x1b[2mA\x1b[1;22mB" +live cell B: bold=1 dim=0 (style flags 100000) +restored cell B: bold=0 dim=0 (style flags 000000) ← BOLD LOST +``` + +**Mechanism:** cell A is dim, cell B is bold-only. The serializer diffs the pen +from A (dim on) to B (bold on, dim off). To clear dim it appends SGR 22 — but in +xterm/ECMA-48 **SGR 22 resets *both* bold and dim** (`normalIntensity`). So the +emitted `\x1b[1;22m` sets bold then immediately clears it: the restored cell is +neither dim nor bold. Verified directly: writing `\x1b[1;22mX` yields `bold=0`. +(`\x1b[1;2m` — the same-cell dim+bold case — round-trips fine, so the bug is +specific to a dim-cell → bold-only-cell attribute transition.) + +**Why it garbles a real pane:** agent TUIs routinely draw a dim body line then a +bold status/spinner line (Claude Code, Codex). On the live screen the status +line is bold; after a hide→reveal snapshot restore it renders normal-weight. +The seed-1321 live row `⠦ bash: pnpm typecheck` is bold live, non-bold restored. + +**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` → +`it.skip('preserves bold when serializing a dim cell followed by a bold-only cell')`. +Tolerated + counted in the corpus via `snapshotHasSelfCancellingBoldReset`. + +--- + +## Bug C — SerializeAddon cursor restore is off-by-one when the last content row fills the right margin + +**Classification: (a) real `@xterm/addon-serialize` bug.** New finding. + +**Seeds:** 454 (minimal, plain CUP), 1696 (DECSC/DECRC + wide CJK). + +**Minimal repro (isolated, pure serializer replay), cols=10:** + +``` +live bytes: "0123456789\x1b[3;5H" (fill row 0 to the margin, CUP to r3c5) +SerializeAddon → : "0123456789\x1b[2B\x1b[6D" +live cursor: { x: 4, y: 2 } +restored cursor: { x: 3, y: 2 } ← ONE COLUMN SHORT +``` + +Control (`"012\x1b[3;5H"` — row 0 not full) serializes to `"012\x1b[2B\x1b[1C"` +and round-trips the cursor exactly, isolating the trigger to a full-width final +content row. + +**Mechanism:** after emitting a row filled to exactly `cols`, xterm is left in +the *wrap-pending* state (cursor visually on the last column, logically "one +past"). The serializer computes its final cursor-restore as relative +`CUD`/`CUB` moves from that ambiguous position; the horizontal delta is computed +one column short, so the restored cursor lands at `x-1`. Reproduced with pure +`serializeAddon.serialize()` replay into a fresh terminal — **no Orca preamble +or normalization involved**, confirming it is upstream, not Orca's snapshot +path. + +**Why it garbles a real pane:** the cursor is where the next keystroke echoes +and where the block/bar cursor is drawn. On reveal of a TUI whose bottom line +reached the right edge (wide status lines, long prompts), the cursor sits one +cell left of where the live pane had it — visible as a mispositioned prompt +caret or spinner, and subsequent input can overwrite the wrong cell. + +**Repro test:** `headless-emulator-fidelity.fuzz.test.ts` → +`it.skip('restores the cursor exactly when the last content row fills the right margin')`. +Tolerated + counted in the corpus via `isMarginWrapPendingCursorOffByOne`. + +--- + +## Bug D — snapshot does not preserve the DECSC saved-cursor register across a hide/reveal boundary + +**Classification: (a) real bug — a structural snapshot limitation.** New +finding, surfaced by the reveal-reconciliation fuzz (suite 2), not the fidelity +fuzz. + +**Minimal repro (cols=20):** + +``` +hidden bytes: "AB\x1b7\x1b[4;10HCD" (write AB, DECSC saves cursor at r0c2, + move to r3c9, write CD) +tail bytes: "\x1b8X" (DECRC restores the saved cursor, write X) + +live (always visible): rows ["ABX", " CD"] cursor { x: 3, y: 0 } +reveal (snapshot+tail): rows ["XB", " CD"] cursor { x: 1, y: 0 } + ^^ 'X' overwrote 'A' — DECRC landed at home, not r0c2 +snapshotAnsi: "AB\r\n\r\n\r\n\x1b[9CCD" (no saved-cursor state at all) +``` + +**Mechanism:** the snapshot is a serialized *screen* (SerializeAddon) plus a few +rehydrated modes. The VT100 DECSC/DECRC saved-cursor register (also `CSI s` / +`CSI u`) is runtime state that never appears in the serialized buffer, so it +cannot survive a snapshot. When a hidden TUI runs `\x1b7` (or `\x1b[s`) before +the reveal seq and the racing tail (or any post-reveal output) runs `\x1b8` (or +`\x1b[u`), the restore targets the fresh terminal's default saved position +(home) instead of where the TUI saved it — the next writes land at the wrong +cell and overwrite live content. + +**Why it garbles a real pane:** DECSC/DECRC is common in shell prompts and +status-line redraws (save cursor, jump to a corner to paint a clock/token +counter, restore). If the save happens while the pane is hidden and the restore +fires on reveal, the restored paint clobbers the wrong cells. Found by suite-2 +seed 3 (a `savedCursorDetour` op whose `\x1b7` fell in the hidden prefix and +whose `\x1b8` fell in the racing tail after chunk-splitting). + +**Handling:** suite 2 keeps its racing tail append-only (no DECSC/DECRC, cursor +motion, scroll regions, or alt frames) so the seq-reconciliation byte-stitch is +tested in isolation from this and the other terminal-state-loss garbles. Bug D +is instead pinned as a standalone repro, +`hidden-reveal-reconciliation.fuzz.test.ts` → +`it.skip('preserves the DECSC saved-cursor register across a hide/reveal …')`. + +**Fix (applied):** the snapshot epilogue re-establishes the register with +`CUP(saved) + ESC 7 + CUP(actual)` composed in `serializeWithAbsoluteCursor`, +reading the active buffer's core register via `readSavedCursorRegister` +(alt screen yields its own register). Position-only: saved SGR/charset are not +carried. + +--- + +## Bug E — snapshot boundary mid-escape-sequence drops the partial sequence + +**Classification: (a) real bug — a structural snapshot limitation.** New +finding, surfaced by the reveal-reconciliation fuzz (suite 2). + +**Minimal repro (cols=20):** + +``` +hidden prefix: "AB\x1b[3" (write AB, then ESC [ 3 — no final byte yet) +tail bytes: "mCD" ('m' completes ESC[3m = italic, then CD) + +live (always visible): rows ["ABCD"] (ESC[3m parsed atomically, CD italic) +reveal (snapshot+tail): rows ["ABmCD"] ← 'm' became a literal character +snapshotAnsi: "AB" (the partial ESC[3 is in the parser, gone) +``` + +**Mechanism:** a PTY read (one delivery record) can split an escape sequence. +If the pane is revealed while the emulator's parser sits mid-`ESC[…`, the +serialized SCREEN cannot carry the partial sequence (it lives in the parser +state machine, not the buffer). The racing tail supplies the sequence's +remaining bytes, but with the prefix gone the terminal parses them as literal +text. Reproduced end-to-end against the real `HeadlessEmulator.getSnapshot`. + +**Why it garbles a real pane:** any TUI whose output is heavy with escape +sequences (all of them) can have a read boundary fall mid-escape; if a reveal +lands in that window the continuation renders as stray literal bytes (a rogue +`m`, `H`, digits) injected into the visible text. + +**Reachability:** requires the reveal/snapshot to fire in the gap between the two +halves of a split escape. `main` writes each PTY read to the emulator and +records it as one delivery unit (`session.ts emitSubprocessOutput`), and the +snapshot is taken synchronously at a drain — so the window is a single delivered +record that ended mid-escape. Narrow but real. + +**Handling:** suite 2 tolerates + counts scenarios whose hidden prefix ends +mid-escape-sequence (`prefixEndsMidSequence`), the same way suite 1 tolerates the +serialize wrap bug — it fired on ~24% of the corpus, confirming the class is +common. Pinned by `hidden-reveal-reconciliation.fuzz.test.ts` → +`it.skip('completes an escape sequence split across the hide/reveal boundary')`. + +**Fix (applied):** the emulator tracks the unparsed trailing partial escape at +ingest (`terminal-partial-escape-tail.ts`, committed post-parse like the mouse +mirror) and ships it as `TerminalSnapshot.pendingEscapeTailAnsi`; restorers +write it LAST, after their post-replay resets, so the racing tail's +continuation completes it exactly as live. Snapshot seq already counted those +ingested bytes, so reconcile slicing is unchanged. + +--- + +## Known-legitimate normalization (NOT bugs) + +- **OSC 8 hyperlink underline** — classification (c). xterm marks OSC-8 link + cells underlined; SerializeAddon never re-emits OSC 8. Production restores the + link ranges out-of-band via `snapshot.oscLinks` + (`collectHeadlessOscLinkRanges`), so byte replay keeps the text but drops the + underline by design. Pinned by the passing + `it('drops OSC 8 underline from byte replay but preserves the range …')`. +- **P256→P16 color mode** — classification (c). SerializeAddon re-emits palette + indices 0–15 written as `38;5;N` using classic SGR 30–37/90–97, so a restored + cell reports `CM_P16` where live reported `CM_P256`. Both resolve through the + same 16 theme slots — no visual difference. Canonicalized by + `canonicalColorMode` in the parity fixture. + +--- + +## Corpus vs deep mode + +- **Suite 1** (`headless-emulator-fidelity.fuzz.test.ts`): default + `FUZZ_ITERATIONS=300` (~17s). `FUZZ_ITERATIONS=2000` (~113s) is green — Bugs A, + B, and C are each tolerated + counted by a predicate, so the corpus fails only + on a genuinely new divergence. +- **Suite 2** (`hidden-reveal-reconciliation.fuzz.test.ts`): default + `FUZZ_ITERATIONS=200` (~5s). `FUZZ_ITERATIONS=1000` is green — the racing tail + is append-only, so the only tolerated class is Bug E (`prefixEndsMidSequence`). +- Combined default runtime is ~19s (well under the 60s gate). +- `FUZZ_SEED=`: re-run exactly one seed for a repro (both suites). diff --git a/notes/orca-performance-branch-guide.md b/notes/orca-performance-branch-guide.md new file mode 100644 index 00000000000..870c3813911 --- /dev/null +++ b/notes/orca-performance-branch-guide.md @@ -0,0 +1,532 @@ +# orca-performance Branch Guide + +Agent-facing map of every optimization on this branch: what it does, why it exists, +where it lives, and the invariants you must not break when adding to it. The +chronological evidence trail (benchmarks, retractions, A/B protocols) is in +`notes/terminal-performance-initiative.md`; this doc is the _current-state_ view. + +**Context**: Orca's terminal was ~300× slower than Terminal.app under agent load +(DSR-under-load p50 134ms, p99 292ms on v1.4.91; agent-TUI throughput 2.0 MB/s). +As of v1.4.122-rc.1.perf: p50 13.3ms / p99 18.7ms, zero timeouts, throughput +11.8–15.5 MB/s — beats VS Code on 5 of 6 metrics. Goal line still open: 4.5ms +(10× Terminal.app). + +## The pipeline + +``` +shell → pty → daemon (persistence, headless model) → unix socket + → main (ipc/pty.ts: batching, delivery gate, flow control, snapshots) + → IPC → renderer (pty-dispatcher → pty-connection → output scheduler → xterm) +``` + +Main is on the hot path for every byte (unlike VS Code's ptyHost→renderer +MessagePort). The daemon owns sessions so they survive app restarts; it also runs +a headless xterm emulator per pty — the _model_ — which is the source of truth +for screen contents. The renderer terminal is a _view_ that can be discarded and +rebuilt from model snapshots. + +## Optimization inventory + +### 1. Renderer parse-path fixes (the original 16× on agent TUIs) + +- **Parse-clocked scheduler drains** (`pane-terminal-output-scheduler.ts`): drain + cadence follows xterm's actual parse completion instead of fixed timers, so the + queue never outruns the parser. +- **Windowed retained-tail redraw**: TUI repaints (erase-down + redraw) only + re-process a bounded window instead of the full retained tail. Guarded by + differential fuzz `retained-tail-redraw-window.equivalence.test.ts`. +- **Throttled wait-blocked check** (`orca-runtime.ts`): the per-chunk agent + wait-detection (two 256KB waitText builds + multi-pattern scans) now runs at + 50ms cadence with trailing edge + keyword pre-filter. Was ~85% of main's + per-chunk cost. + +### 2. term-speed-2 chain (model/view contract — the architecture) + +Revived from ~38 never-merged branches; kill-switched, default ON. Docs: +`docs/reference/terminal-model-view-contract.md`. + +- **Hidden view parking**: hidden tabs tear down their xterm view entirely + (memory: parked panes cost ~0). +- **Hidden delivery gate** (main): renderer-bound bytes for hidden ptys are + dropped at main — hidden panes receive nothing. Reveal rebuilds the view from a + model snapshot + live chunks after the snapshot's seq. +- **Side-effect authority**: main extracts side-effect facts (bell, title, cwd) + from the model so parked panes stay live in the UI without a view. +- **Model query authority**: main answers terminal queries (DSR/CPR, DA1, OSC + colors) deterministically from the model for hidden panes. +- **Seq/ordered-delivery bookkeeping**: every chunk carries a seq; reveal + reconciliation drops duplicates already covered by the snapshot baseline. + +### 3. Batching & scheduling cadence + +- **Batch windows 8ms → 2ms** in both `daemon-stream-data-batcher.ts` + (`STREAM_DATA_BATCH_INTERVAL_MS`) and `ipc/pty.ts` (`PTY_BATCH_INTERVAL_MS`). + At 9% utilization there is no queue — latency was literally the sum of fixed + batch windows. This one change took dev DSR-load 19→8ms. +- **MessageChannel zero-delay drains** (`pane-terminal-output-scheduler.ts`): + Chromium clamps nested `setTimeout(0)` to ~4ms; posted messages are macrotasks + without the clamp, preserving cooperative yield (input/paint still serviced). + Vitest keeps the timer path (fake timers can't advance channel posts). +- **Input write coalescing** (from main, #7205): renderer input writes coalesce + instead of queuing macrotask-per-keystroke. + +### 4. Backpressure (the correctness spine — read before touching delivery) + +Three cooperating layers, innermost first: + +- **ACK at parse-drain** (`deliverPtyDataWithDeferredAck`, scheduler + `ackCredit`): the renderer credits a chunk when xterm has _parsed_ it (or the + chunk is legitimately discarded), not when IPC delivered it. + **INVARIANT: every delivered chunk credits exactly once — parsed or + discarded.** Every scheduler/pty-connection discard path (backlog replacement, + disposed terminal, reconcile drop, split remainders) must fire the credit. +- **Cumulative ACKs + solicited resync** (`terminal-pty-ack-gate.ts`, + `applyCumulativeAck` in pty.ts): ACKs carry monotonic per-pty processed totals + (TCP-style); main max-merges, so lost ACKs self-heal. Data arriving for a + fully-gated pty triggers a resync probe instead of a timeout reset. The only + timer is a hygiene warn that mutates nothing. Main's 512KB per-pty in-flight + gate + 2MB pendingData cap sit on top. +- **Renderer-pull delivery watchdog** (`terminal-delivery-watchdog.ts`, + `pty:reportRendererDeliveryState` in pty.ts): recovers the field-confirmed + wedge where every main→renderer PUSH channel dies while invoke stays alive + (v1.4.121-rc.0 snapshot; electron#37067 class) — a state the push-ridden + resync probe can never reach. The 15s heartbeat costs one Map upsert per + received chunk and does no IPC while output flows; mutation stays + verified-state-only (the timer decides when to REPORT; the write-off derives + entirely from the renderer's cumulative received totals, never wall-clock, + and a received-but-unparsed window is never written off). Heal = re-attach + push listeners + pull restore markers through the modelRestoreNeeded router. + E2e blackhole harness: `__terminalDeliveryWatchdog`, + `terminal-push-delivery-loss-recovery.spec.ts`. +- **Stale-visibility proof for the hidden gate** (`stale-document-visibility.ts` + and the `shouldWritePtyOutputForeground` fallthrough in pty-connection.ts): + recovers the field-confirmed wedge where macOS occlusion tracking pins + `document.visibilityState` at `'hidden'` after display sleep and never fires + another visibilitychange (v1.4.124-rc.2.perf snapshot: 78MB hidden-gate + dropped across 2 pane-level-visible ptys, transport healthy). Real user + input (keydown/pointerdown/window focus) while the document claims hidden is + a physical contradiction — it latches an override, runs each pane's existing + visibilitychange resync (gate unhide + hidden-output restore), and a genuine + visibilitychange hands authority back. No timers; recovery is purely + event-proven, and the failure bias is safe (a wrong override only restores + pre-gate delivery cost, never drops bytes). Hot-path cost: zero when + visible (same single comparison); one property read per user-interaction + event. E2e: `terminal-stuck-occlusion-recovery.spec.ts` (pins both the + freeze repro and the keystroke recovery, plus the + `hiddenDeliveryGatedVisiblePtyCount` field discriminator). +- **One-paste freeze report** (`terminal-freeze-report.ts`, prod-installed + `await window.__orcaTerminalFreezeReport()`): a single DevTools command that + returns renderer state (visibilityState + stale override, pty:data listener + count, watchdog totals), main's snapshot with a per-pty delivery table + (sent/acked/pending, hidden vs visible-set membership, last send/ACK ages, + window focus flags, power suspend/resume ages, app version), and bounded + breadcrumb rings from BOTH processes (`pty-delivery-diagnostics.ts` shared + ring: 100 entries, same-kind coalescing) recording gate marks, visibility + trust changes, watchdog stalls/heals, restore markers, heal write-offs, + renderer lifecycle resets. Pty ids are redacted to their `@@` suffix — + daemon session ids embed worktree paths. Recording happens only on rare + transitions; the table/report is built only when read. This exists so a + field freeze report never needs a follow-up ask. +- **Hidden/parked exit teardown completeness** (pty-connection.ts kept-exit + guard + `terminal-parked-tab-watchers.ts` exit sidecar): two invariants that + keep a split pane's death near the hidden/park boundary from stranding state + (field incident: a closed setup-split leaf persisted in `root` with no + binding and remounted as a permanently blank pane, unreachable by + dead-session reconcile — it skips ptyId-null panes by design). + (1) The "keep a fresh split whose newborn PTY died" branch is **gated on + `isVisibleRef`** — hidden panes' bytes are gate-withheld, so "no output" + proves nothing there; a hidden newborn death must `closePane`, or the kept + pane becomes a binding-less ghost. (2) A PTY exit that lands **while parked** + reaches ONLY the parked watcher's exit sidecar (hosts' `onPtyExit` needs a + mounted TerminalPane), so the sidecar itself collapses the dead leaf out of + the stored layout via `detachTerminalLayoutLeaf` — a stale binding left + behind reattaches on reveal and the daemon re-creates the exited session id + as a fresh shell (silent pane resurrection). E2e: + `terminal-pane-close-layout-consistency.spec.ts` sweeps close/exit at every + lifecycle phase and asserts leaves(root) == bindings == live panes. +- **Producer flow control** (protocol v19 `pausePty`/`resumePty`, 256KB pause / + 32KB resume watermarks, keyed off **pendingData only** — never renderer + counters; kill switch `PRODUCER_FLOW_CONTROL_ENABLED`, ipc/pty.ts): when main's + buffer grows, the _shell_ blocks. For main-hosted ptys pause is synchronous + (drops impossible); for daemon ptys the pause notify has ~20-30ms socket + latency, so wire-speed bursts can still cross the 2MB cap (known follow-up: + daemon-side self-pacing watermark). +- **Shallow stream-socket write gate + per-session fairness** + (`daemon-stream-data-batcher.ts`, 128KB gate / 64KB safe-split slices / + 4KB small-session bypass / 32MB write-through valve; kill switch + `ORCA_DAEMON_SHALLOW_SOCKET_GATE=0`): the stream socket is one FIFO for + every session — bytes already written can never be overtaken, so a deep + user-space buffer buries a visible pane's echo behind other panes' bulk + (measured 192MB / 6+s under 12 flooding hidden agents). Bulk writes stop at + the gate and hold in the batcher, where the interactive flushSession path + and the deterministic small-session bypass still jump them; socket `drain` + refills. This layer alone bounds echo latency by the shallow depth. +- **Background keep-tail stream thinning + daemon fact authority** + (`daemon-stream-keep-tail-drop.ts` 1MB cap / 512KB keep-tail, + `daemon-background-transient-facts.ts`; kill switch + `ORCA_DAEMON_BACKGROUND_STREAM_DROP=0`): hidden-gated ptys are exempt from + pendingData flow control (main drops their bytes after ingestion), which + let N background agents run unbounded ahead of main. Main mirrors the + hidden-delivery gate to the daemon via the wire-tolerated + `setSessionBackground` notification (introduced in v19; authoritative + thinning requires v20 snapshots, so preserved v19 sessions are explicitly + unthinned; older daemons swallow it) — but a live remote view subscriber + (mobile/web) vetoes backgrounding (`hasRemoteTerminalViewSubscriber`). + Backgrounded sessions' queued output is keep-tail dropped (oldest bytes + replaced by an in-order `dataGap` event; reply-eliciting query bytes are + salvaged so hidden programs never hang on DSR/DA replies); producers are NEVER paused, + so reveal stays instant with zero catch-up. Un-background neither discards + nor force-flushes the queued tail — restore paths read MAIN's model + (hidden-output recovery buffer), so a discarded tail loses a finished + program's last output forever (caught by the ACK-backpressure e2e), and a + 16-pane force-flush dumps ~12MB onto the socket ahead of the reveal's own + bytes; the ordered drain loop delivers it within the budget below. Two + aggregate bounds make that budget real: (1) a GLOBAL background keep + budget (~2MB): per-session keep-tails shrink (512KB → floor 64KB) as more + backgrounded sessions hold queued data, and tighten retroactively when the + count grows — without this, N sub-cap sessions queue N×cap and a worktree + switch waits seconds behind the aggregate (measured 9MB → 2.5s hidden + restore vs the 1.5s budget, probe-verified drain overlap); (2) a + kernel-flush refill sentinel: a held flush pass arms one ~90B empty data + event whose write callback re-flushes when the kernel accepts the + in-flight bytes — without it, held bulk advances one gate-depth per + 'drain' (user-space empty) per event-loop turn (~8MB/s ceiling on a busy + daemon). NOTE: an empty `socket.write('')`'s callback fires immediately + even with megabytes buffered (verified) — the sentinel must be a real + protocol no-op line. Notifications are + structurally lossless: while backgrounded, the DAEMON runs the same shared + scanners main uses (`terminal-output-side-effects.ts`: bell / OSC 133 + command-finished / pr-link / DECSET 2031) over every byte BEFORE drop + decisions and relays facts as in-order `transientFact` events; ordered + `sessionBackgroundMarker` events hand scan authority back and forth + (main suppresses just those four scanners in between), and the emulator's + `partialEscapeTailAnsi` seeds each side's fresh scanner carry so a + sequence split across the handoff neither phantom-fires nor goes missing. + Titles/agent-status stay main-side (they converge from the kept tail and + fuse with synthetic spinner frames). On `dataGap`, main resets its + cross-chunk parse carries, drops the mobile headless mirror (rebuilds from + tail/seeds), and sends the model-restore-needed marker so any + renderer-side buffer heals from the snapshot. Visible ptys are never + touched by this layer. Backlog observability: + `ORCA_DAEMON_STREAM_BACKLOG_FILE=` JSONL + (`daemon-stream-backlog-probe.ts`; events incl. `backgroundKeepTailDrop`, + `setSessionBackground`, `mainBackgroundSync`, `heldWriteThrough`). + Causation A/B (`bench:multi-workspace-typing`): realistic steady rates + (8×192KB/s) don't reproduce even fix-off; burst rates on a loaded machine + do (8×512KB/s + 12 CPU spinners: fix-off p50 293ms/p90 647ms → fix-on p50 + 29ms); extreme 12×1MB/s: fix-off p50 6,146ms → 29ms. + +### 5. Flood resilience (why bulk output can't wedge or lie anymore) + +- **Restore-loop cut** (pty-connection.ts): the hidden-output-restore loop + abandons immediately when a foreground pane's live-chunk queue overflows + (3-iteration hard cap), and a 2s flood-suppression window stops main's own + backpressure drops (`droppedOutput`/`modelRestoreNeeded`) from re-arming + restore — bytes write through, ONE deferred repaint heals after the flood. + This killed a positive feedback loop (restore starves ACKs → main drops → + drop re-arms restore) that caused multi-second renderer stalls. +- **Query survival**: if the 2MB cap ever drops bulk output, embedded terminal + queries are extracted (`terminal-reply-query-extraction.ts`) and answered by + _synthesizing replies on the input path_ (CPR from live buffer, DA1 canned, + OSC via direct responder) — probes and TUIs never hang on a dropped reply. +- Drops are downstream of the model: the daemon ingests every byte, so the + post-flood repaint restores complete, correct content. + +### 6. Wake/sleep recovery + +- powerMonitor resume → `system:resumed` IPC → renderer wake recovery (fixes + WebGL-latch blank-after-sleep that DOM focus/visibilitychange missed). +- Cumulative ACKs make the historical "lost ACKs across suspend pin the global + window forever" wedge (BMW user bug) structurally impossible. + +### 7. Snapshot fidelity (the garble fixes — all fuzz-pinned) + +Reveal-from-snapshot multiplied exposure of serializer defects ~1000×. Five bugs +found by differential fuzzing; four fixed, one tolerated: + +- **B: SGR intensity ordering** — upstream `@xterm/addon-serialize` emitted + `1;22` (22 clears the bold 1 just set). Patched via pnpm patch + (`config/patches/@xterm__addon-serialize@*.patch`): clear-before-set for the + bold/dim group (+2 sibling bare-22 defects). +- **C: cursor off-by-one at wrap-pending margin** — bypassed entirely: + `serializeWithAbsoluteCursor` (`terminal-serialize-absolute-cursor.ts`) + appends absolute CUP from the source terminal's authoritative cursor + (skipped when wrap-pending, where CUP would corrupt). +- **D: DECSC saved-cursor register not serialized** — snapshot appends + `CUP(saved) + ESC 7 + CUP(actual)` when a register exists. +- **E: snapshot mid-escape-sequence** (fired on 24% of fuzz corpus) — + `terminal-partial-escape-tail.ts` is a fold-safe VT-parser-state scanner; the + unparsed tail ships as `TerminalSnapshot.pendingEscapeTailAnsi` and is written + LAST on restore so continuation bytes complete the sequence. Seq accounting + unchanged (the tail is a suffix of bytes ≤ snapshot seq). +- **A (tolerated)**: upstream wrap-null-cell serialize defect — fenced by + `bufferHasSerializeHostileWrappedRow`, the only remaining tolerance. + +## Correctness infrastructure (run these before merging delivery/restore changes) + +- `headless-emulator-fidelity.fuzz.test.ts` — differential: HeadlessEmulator vs + reference xterm, seeded TUI streams. `FUZZ_ITERATIONS=2000` for deep, + `FUZZ_SEED=n` to replay. +- `hidden-reveal-reconciliation.fuzz.test.ts` — property tests: random + hide/reveal boundaries × snapshot seq × racing chunks must equal an + always-visible reference. +- `terminal-snapshot-serialize-roundtrip.test.ts` — the garble repros (unskipped + = regression alarms). +- e2e: `terminal-hidden-view-parking` (incl. 25-cycle park/reveal drift test — + byte-identical vs control), `terminal-parked-memory`, + `terminal-sleep-wake-restore`. +- Scheduler credit-invariant + ack-gate deferred-credit + restore-flood tests + (pane-manager / terminal-pane suites). + +## Benchmarking protocol (hard-won rules) + +- Rig: `tools/benchmarks/terminal-pipeline-bench.mjs` — DSR idle + DSR under + 1MB/s agent-TUI load + DSR-fenced throughput on 4 fixtures. Run _inside_ the + terminal under test. +- Multi-workspace typing rig: `pnpm bench:multi-workspace-typing -- --panes 12 +--rate-kbps 1024 --keys 32 --cadence-ms 250 [--cpu-workers 8] --label ` + — real keystrokes (CDP) into a visible pane while N hidden-worktree panes + replay paced agent-TUI streams through real daemon ptys; decomposes each key + into input-half (keydown→pty, sidecar timestamps) and echo-half + (pty→screen). JSON in `tools/benchmarks/results/`. Noise band at 4×256KB/s: + p50 10-15ms, p90 ≤50ms. The latency signature lives in echo-half; renderer + timer drift staying ~15ms while echo-half grows means the backlog is + upstream of the renderer (daemon socket / main ingest). +- **Bench at 10MB** (`--size-mb 10`). The ACK-at-parse bug shipped because dev + benches used 3MB and never tripped the cap. +- Load-controlled A/B only: alternate builds within one session; dev carries ~2× + day-to-day variance. Never conclude from runs while agents/builds hammer the + machine (two false convictions came from this). +- Never set `ORCA_E2E_USER_DATA_DIR` for benches (arms the e2e ACK gate → hang). +- Packaged builds are truth; dev has ~2× overhead. + +## Release mechanics + +- Perf RCs: `release-cut.yml` workflow_dispatch, `kind=rc ref=orca-performance +version_suffix=perf` → tags like `v1.4.122-rc.1.perf`. The suffix sorts above + its base rc.N but below rc.N+1 (never hijacks the RC channel). The rc counter + (`release-rc-history.mjs`), telemetry identity classifier, and build guard are + all suffix-aware — a suffixed rc classifies as `rc`. +- cmd/ctrl-click "Check for Updates" fetches the latest perf-tagged release + (PR #7278; merged here) — perf-line users self-update after one manual install. + +## Syncing with main: MERGE, never rebase + +`orca-performance` is a long-lived, shared, continuously-pushed integration +branch — RCs are cut from it and agents branch off it. **Always +`git merge origin/main`; never rebase** (rebasing rewrites pushed history and +strands every RC tag, fix branch, and worktree based on the old commits). +Conflict pattern, established over ~6 syncs: + +1. **Our structure wins; main's semantics graft in.** This branch deliberately + restructures terminal code (shared scanners, single-policy handlers, + model/view split). When main adds a feature inside code we've restructured, + keep our shape and port their new behavior into it. Example: main inlined an + OSC 133 parser to add `onCommandStarted` (133;C); we kept the shared + `createOsc133CommandFinishedScanner` (main's side-effect tracker must parse + byte-identically) and added 133;C support to the shared scanner instead. +2. Preserve the invariants in **Guardrails** below through every resolution — + especially chunk-credit, `pendingData`-keyed flow control, and the + single `handleCommandFinished` policy (byte path AND sideEffect-fact path + route through it). +3. After resolving: `pnpm typecheck`, the terminal-pane + ipc/pty + daemon + suites, and both fuzz suites. Commit the merge with a message stating what + was kept from each side; push. If the push races a moved remote, merge the + remote tip — never `pull --rebase` a merge. +4. If a sync lands anything on the delivery/restore path, re-run a 10MB bench + before the next RC cut. + +## Known limits / next levers (in rough priority order) + +1. Daemon self-pacing: daemon-hosted ptys can cross the 2MB cap for ~20-30ms at + wire speed before `pausePty` bites. Fix: daemon enforces its own watermark + locally (VS Code does this server-side for remotes). +2. Cadence floor to the 4.5ms goal: xterm's 12ms parse slices and remaining + drain cadence dominate the 13.3ms prod p50. +3. utilityProcess router endgame: take main off the per-byte hot path + (VS Code's ptyHost→renderer MessagePort shape). +4. SerializeAddon full-buffer stalls at 50k-row scrollbacks (#5096 follow-up). +5. Peel PRs to main: throughput fixes → batch+MessageChannel → flow control → + term-speed-2 last. PR #7214 is the integration overview; #7260 (wake/ACK) + is open against main separately. + +## Guardrails for future agents + +- The chunk-credit invariant (§4) is the load-bearing one. If you add ANY path + that receives, defers, drops, or splits pty data in the renderer, prove it + credits exactly once. The credit-invariant unit tests are the gate. +- Flow control keys off `pendingData` only. Do not couple it to renderer + counters; the two layers compose because they are independent. +- Snapshot changes must keep seq semantics: a snapshot covers _exactly_ bytes + ≤ its seq (Bug E made this true; don't regress it). Chunks after restore are + reconciled by seq — off-by-N re-triggers duplicate-drop garble. +- Hidden panes must receive nothing (delivery gate) but side-effects and query + replies must stay live via the model. If you add a new query type, wire it + through model authority AND the drop-path synthesis. +- Never add timeout-based recovery that mutates counters (user requirement — + design decision from #7260). Deterministic resync or nothing; hygiene timers + may only log. +- Any change on the delivery/restore path: run both fuzz suites, the roundtrip + tests, the chain e2e trio, AND a 10MB bench before calling it done. + +## Audit 1 + +Completed 2026-07-10 against `orca-performance`. Scope: the daemon → main → +renderer terminal path, with particular attention to hidden delivery, parking, +stream thinning, snapshot fidelity, ACK/backpressure semantics, wake/reattach, +mobile/remote composition, SSH routing, and teardown. The audit treated model +state and user scrollback as correctness requirements, not expendable memory. + +### Findings and fixes + +1. **Hidden-gate handoff owners could undo one another.** A parked watcher and + an unmounting/remounting pane shared one boolean hidden mark; likewise a + retiring pane could report `visible=false` after its replacement had already + reported `visible=true`. Hidden claims are now reference-counted and + visibility is counted per owner (`pty-renderer-delivery-claims.ts`). Eager + pre-mount buffers no longer hold raw-byte delivery interest: they are not a + side-effect consumer, and model-backed hidden output can restore from a + snapshot. This removes the ownership and eager-interest races formerly + listed in Known limits item 6 without weakening parked side effects. The + remaining transient-visibility concern was checked separately: bind and + reconnect reports read `TerminalPane`'s synchronously refreshed + `isVisible && isWorktreeActive` ref, the global effect uses the same + expression, and owner counting prevents a retiring pane from overriding its + replacement. No hidden-worktree `visible=true` report site remains. + +2. **A natural/synthetic daemon exit could overtake final output.** When a + shallow-gated socket had queued data, `daemon-server.ts` wrote the exit event + directly. The final bytes could therefore arrive after `exit`. Exit is now an + ordered control event in `DaemonStreamDataBatcher`; both natural and + synthetic exits flush through the same FIFO. A deep-socket regression test + pins final-data-before-exit ordering. + +3. **Keep-tail thinning could permanently reduce scrollback to the retained + tail.** On `dataGap`, main discarded its headless model, then rebuilt it from + later tail bytes even though the daemon still owned the complete model. Live + daemon snapshots now carry `outputSequence`; the provider exposes an + authoritative `getBufferSnapshot`, accepts the requested scrollback depth, + and main requires that provider snapshot after a gap. Reconciliation starts + in the pre-snapshot absolute sequence domain and runtime sequence accounting + advances across dropped bytes. The daemon remains the source of complete + scrollback instead of making a transport optimization destructive. If that + authoritative RPC is temporarily unavailable, main now returns no snapshot + and lets the renderer retry; it never paints main's known-incomplete tail as + a full recovery. + +4. **Query-salvage copies corrupted the absolute sequence domain.** DSR/DA + bytes salvaged from a dropped region are copies of bytes already counted by + the daemon, not new output. Stream events now distinguish delivered text + from `sequenceChars`; salvaged query data advances by zero while the gap + advances by the original characters. Main can still parse/deliver the query + copy without shifting every later snapshot baseline. + +5. **“Parse-deferred” ACKs were submission-deferred, not parse-deferred.** ACK + credit fired when bytes entered `terminal.write`, before xterm's callback. + Split scheduler chunks also attached `onParsed` to the first slice. ACK + credit is now owned by `pane-terminal-output-ack-credit.ts`, fires after the + final xterm parse callback, and is released exactly once on throw, discard, + or terminal disposal. Submitted-but-unparsed credit is retained until parse + or disposal, so main's flow-control window measures parser work rather than + renderer submission. + +6. **Hidden restore ignored configured scrollback.** The renderer always asked + for 5,000 rows, so users configured for 10k–50k silently lost older history + on a hide/reveal rebuild. Restore now reads the pane's xterm scrollback + option and clamps it through the shared 0–50,000 policy + (`terminal-hidden-restore-scrollback.ts`). + +7. **Active alternate-screen snapshots discarded the normal shell buffer.** + SerializeAddon emits `normal buffer + ?1049h + alternate buffer`; the old + normalization sliced away everything before the last `?1049h`. A restored + TUI looked correct until it exited alternate mode, then returned to empty + history. Snapshots now carry the normal buffer separately in + `scrollbackAnsi`. Fresh reattach and mobile/remote snapshot streams compose + both buffers; an already-alt renderer exits alt, clears/rebuilds the normal + buffer, then re-enters and rebuilds alt. History replay also composes both + buffers, including legacy empty-field compatibility. + + Deep fuzz then found a second two-buffer issue: normal-buffer serialization + can leave its SGR pen active while the separately serialized alternate body + assumes default SGR. The rehydrate boundary now emits `SGR 0` before + `?1049h`, preventing a shell color from tinting restored TUI cells. The + regression proves the TUI is visible immediately and `?1049l` returns to the + original shell history. + +8. **Daemon provider wrappers forwarded only part of the recovery contract.** + A preserved current/legacy daemon could emit `dataGap` through a provider + wrapper, but `DegradedDaemonPtyProvider` omitted `getBufferSnapshot`, while + the ordinary multi-version `DaemonPtyRouter` omitted background hints, gap + events, snapshots, and explicit `sequenceChars`. Both wrappers now route the + complete contract to the provider that owns the session, including requested + 50,000-row recovery and zero-advance query-salvage events. + +9. **Sequence-safe recovery was added without advancing the daemon protocol.** + An already-running v19 daemon could accept background-thinning hints but + could not return the new `outputSequence`, making any resulting gap + impossible to reconcile safely. The authoritative snapshot contract is now + protocol v20. Preserved v19 sessions remain live but are explicitly marked + unthinned; their stale background hint is cleared on the ordered control + socket before `createOrAttach`, while fresh v20 sessions retain keep-tail + performance and full-model recovery. + +### Static audit conclusions + +- Model query authority still captures ownership synchronously at ingestion; + seed/hydration/snapshot writes remain reply-silent, remote view subscribers + retain view authority, and replies use the provider input path (including + daemon shell-ready queuing and SSH routing). +- Hidden/visibility/interest/background-sync/provider-snapshot state is cleared + by the centralized PTY teardown path. Parked watcher timers, byte sidecars, + fact consumers, exit subscriptions, hidden claims, and runtime-title slots + dispose on reveal/exit/worktree shutdown. +- Remote-runtime and SSH PTYs remain excluded from cold parking. SSH hidden + panes still have a main-owned headless model, so mounted hidden-gate restore + is valid; live remote viewers veto daemon background thinning. The new + two-buffer payload is recomposed before mobile/remote snapshot frames. +- Wake recovery keeps its focus/visibility/system-resume listener symmetry and + cancels its settled animation frame on cleanup. No timeout was added that + mutates ACK or delivery counters. +- The one documented upstream SerializeAddon null-cell/wrapped-row defect + remains tolerated. Deep reveal fuzz now uses the same narrow hostile-row + predicate as fidelity fuzz, rather than misclassifying that known serializer + defect as sequence-reconciliation loss. + +### Validation evidence + +- Focused ownership/connection/dispatcher tests: 422 passed. +- Daemon server/batcher/order tests passed, including deep queued-socket exit. +- Broad main/daemon/runtime/RPC/SSH run: 1,854 passed, 5 skipped. Three stale + mocks were updated to assert the new explicit `sequenceChars` argument; the + production behavior was already correct. +- Broad renderer terminal/pane/scheduler/runtime-stream run: 1,934 passed. +- Final restore/roundtrip/history/adapter/runtime/scheduler sweep: 1,315 passed. +- Post-protocol completion sweep: 1,106 affected main/daemon tests passed; + 484 renderer/restore tests passed with 2 expected skips. +- Scheduler throughput harness passed with `ORCA_TERMINAL_PERF_BENCH=1`. +- Required hidden-view parking, parked-memory, and sleep/wake E2E trio: + 7 passed on a fresh v20 Electron build, including byte-identical output + across 25 park/reveal cycles. +- `FUZZ_ITERATIONS=2000` headless-emulator fidelity: passed (120.19s). +- `FUZZ_ITERATIONS=2000` hidden reveal reconciliation: passed (69.77s). It + reproducibly found the SGR boundary bug at seed 16 and the known upstream + wrapped-null-cell case at seed 1221 before the final green run. +- Snapshot roundtrip, retained-tail equivalence, ACK gate, PTY connection, and + remote incomplete-escape regression suites passed. +- Fullscreen real-app headful flow: a real shell wrote normal history, entered + a TUI while its worktree was hidden, restored on reveal, then exited with + `?1049l` back to the original history. The BrowserWindow was fullscreen; + no click/focus occurred before evidence; the restored frame settled for + 1.5s before capture. Measurements: window 1710×1073 at DPR 2; xterm 133×63; + `fitAddon.proposeDimensions()` 133×63; cell width 8px; screen-to-xterm gap + 11px (the scrollbar/remainder, with grid and proposed dimensions equal). + Artifacts: `.tmp/terminal-audit-headful/fullscreen-alt-restore.png` and + `.tmp/terminal-audit-headful/fullscreen-alt-restore-metrics.json`. +- Visible, non-E2E current-build Orca 10MB agent-TUI bench: 10.14 MB/s + (986ms for 10.0MB), DSR idle p50/p90/p99 0.64/6.47/57.82ms, + DSR-under-load 6.72/9.86/13.87ms, zero timeouts. The pane was 115×39, + reported app version 1.4.131-rc.2, and ran on a v20 daemon/session. Result: + `tools/benchmarks/results/terminal-pipeline-audit-v20-20260710-2026-07-10T10-54-45-990Z.json`. +- `pnpm typecheck`, oxlint on every touched TypeScript file, + `pnpm check:max-lines-ratchet`, `git diff --check`, and the E2E production + build all passed. No max-lines bypass was added. diff --git a/notes/terminal-performance-initiative.md b/notes/terminal-performance-initiative.md new file mode 100644 index 00000000000..2ac56bbf9e4 --- /dev/null +++ b/notes/terminal-performance-initiative.md @@ -0,0 +1,583 @@ +# Terminal Performance Initiative + +Working plan for the `orca-performance` branch. Goal: make Orca's terminal as +performant as the architecture allows, with every claim backed by a number. +Started 2026-07-02. + +## Why (user-reported, from the team meeting) + +1. Typing in the terminal is sometimes laggy — occasionally seconds of delay. +2. Users say the terminal is slower than iTerm (unclear if typing or scrolling). +3. Scrolling in Claude Code / OpenCode is slow. +4. Idle memory is high (1–2 GB). +5. Battery usage is high. + +Goals: legit performance complaints ≤ 1/week; sampled P90 typing/scrolling +latency down significantly; lower memory with 0–1 agents. + +## Ground truth (verified against source, 2026-07-02) + +Research corpus: xterm.js 6 / VS Code / Ghostty internals study (verified +file:line claims) — see the archived digest and the "xterm.js vs Ghostty" +deep-dive. The Orca-specific findings below were re-verified against this +repo's code: + +- **Electron main sits on every terminal byte's path** (daemon → main → + renderer). VS Code ships the same xterm.js but bypasses main entirely: its + ptyHost is a UtilityProcess with a direct MessagePort to each renderer. +- **The PTY producer is never paused.** `acknowledgeDataEvent` is a no-op in + both `LocalPtyProvider` and `DaemonPtyAdapter`. Only main→renderer delivery + is watermarked (512 KB, `src/main/ipc/pty.ts:1374`); main's own buffer can + grow toward a 512 MB cap under flood. VS Code pauses the actual pty at 100k + unacked chars (kernel backpressure blocks the shell). +- Renderer terminals share one thread with the entire React app; xterm.js + parses in 12 ms slices at a documented 5–35 MB/s ceiling. +- Renderer scrollback default is 5,000 rows (`src/shared/terminal-scrollback-policy.ts`), + 5× VS Code's default; 12 B/cell plus per-line JS objects; O(all lines) + reflow on column resize. +- Latency physics: Ghostty ~4 ms median keypress latency, VS Code ~31 ms + (same-library reference), native class 5–10 ms. Realistic target: beat + VS Code, close on iTerm2, eliminate the stall/jank class entirely (P99 + dominates perception). + +## Current state + +Branch `orca-performance` (long-lived testing line, from main @ `8e8a08ac7`): + +1. `tools/benchmarks/terminal-pipeline-bench.mjs` — cross-terminal rig + (see Benchmark protocol below). +2. Merge of PR #7153 = #7150 (freeze/memory: backlog caps, wedge guards, + probe-certified replay release) + #7139 (cooperative drain: paced backlog + draining keeps typing responsive under floods). Post-merge on this base: + `pnpm typecheck` clean, 626 targeted tests green (scheduler, guards, + pty/pty-connection/pty-transport suites). #7153 itself is a disposable + testing PR; #7139 and #7150 land separately on main. + +## Workstreams + +### 1. Baseline benchmarks (now; human-in-terminal required) + +Run the rig in each terminal on the same machine — Orca pane, iTerm2, Ghostty, +Terminal.app, VS Code (T3Code if available): + +``` +node tools/benchmarks/terminal-pipeline-bench.mjs --label - +node tools/benchmarks/terminal-pipeline-bench.mjs report +``` + +These numbers answer "are we actually slower than iTerm, and where," and are +the before/after for everything below. + +### 2. Validate #7153 on orca-performance (this week, extended testing) + +Watch for: typing responsiveness under agent floods, bounded memory, +skip-notice + snapshot repaint on overflow, no permanent input loss. When +validated, land #7139 and #7150 as separate PRs on main. + +### 3. Revive term-speed-2 (the headline structural work) + +History: nwparker's ~38-branch chain (+20k lines) implementing the terminal +model/view contract — hidden view parking, hidden delivery gate, side-effect +authority in main, model query authority, skip-grammar deletion — all +kill-switched, documented in +`origin/nwparker/term-speed-2-architecture-docs:docs/reference/terminal-model-view-contract.md`. +It shipped only in v1.4.78-rc.1, a deliberate personal-testing build; it was +never rejected and never reached main. Directly targets complaints 3–5 +(hidden panes stop receiving bytes and unmount their xterm + WebGL atlases). + +Merge scout (2026-07-02, chain tip into orca-performance): 144 files, 34 +conflicted, 115 hunks. Hotspots: `pty-connection.ts` (31), `pty.ts` (16), +`daemon-pty-adapter.ts` (6), `orca-runtime.ts` (5). +`pane-terminal-output-scheduler.ts` does NOT conflict — #7139/#7150 and the +chain touch different layers; runtime interaction (drain pacing × hidden +gate) still needs deliberate testing. + +Execution: dedicated focused session; resolve on `revive/term-speed-2` off +orca-performance; keep both sides' kill switches; validate with typecheck + +the contract tests listed in the model-view-contract doc + #7153's suites; +merge back to orca-performance for extended testing. Estimated ~1 day of +careful resolution + validation. + +### 4. Remaining stall-bug fixes (parallel, independently shippable) + +The "seconds of delay" class = discrete thread-blocking events, not +steady-state latency: + +- PR #7105 (open): skip synchronous cold-restore replay for live daemon + sessions in doSpawn. +- `SerializeAddon.serialize()` audit: ~1.2 s renderer block at 50k scrollback + rows (#5096 follow-up, never done). Call sites include the mobile snapshot + path (`pty-connection.ts:2861`) and sleep/hibernate serialization. +- #2836 frozen-terminal leads: replay-guard latch, codex-stale gate, uncapped + buffers (repro harness exists). +- Checkpoint-RPC main-thread scrub (measured ~2–10 ms bursts per hot 5 s + tick; small, part of the same program). + +### 5. Producer-side PTY flow control + +Ack-driven pause/resume of the actual PTY through the daemon protocol +(node-pty supports it), watermarks per the xterm.js flow-control guide +(≤500 KB). Converts flood-induced buffered lag into shell blocking — the +correct physics. Sequence after #7139 lands (interacts with its drain pacing). + +Design (2026-07-03, implement after the term-speed-2 revival merges — +same files): + +- Signal source: main already tracks per-pty pending + in-flight + (`pendingData`, `rendererInFlightCharsByPty` in `ipc/pty.ts`). When a + pty's pending exceeds HIGH (256 KB), main asks the producer to pause; + below LOW (32 KB), resume. +- Producer side: two new protocol notifications (`pausePty`/`resumePty`, + protocol vNext, version-gated like `supportsIncrementalCheckpoints`); + daemon `Session` calls node-pty `pause()`/`resume()` — stops reading the + pty fd, kernel buffer fills, the shell blocks on write: true kernel + backpressure, identical physics to VS Code's 100k/5k design. + `LocalPtyProvider` calls pause/resume directly. +- Safety invariants: (1) failsafe auto-resume after 5 s regardless of + watermark, so a lost resume can never wedge a shell; (2) resume on + detach/exit/kill/daemon-reconnect; (3) pause must not suppress the + interactive-echo bypass — with the pipeline fixed (11.5 MB/s dev), the + HIGH watermark is only reachable during genuine floods where echo is + already queued; (4) PTY reads never stop for model/tail ingestion + (term-speed-2 invariant #1) — pause gates the fd read, so daemon-side + emulator state pauses with it, which is correct (state = what was read). +- Tests: watermark transition unit tests, lost-resume failsafe, kill/exit + cleanup, plus an e2e pressure scenario asserting bounded main memory and + a blocked producer (`yes` exits promptly on SIGINT while paused). + +### 6. Extend the measurement rig + +- True keypress→pixel latency: Typometer manual protocol (the DSR probe stops + at the parser reply, before paint). +- Idle memory + battery: per-process RSS breakdown + `powermetrics` sampling + at 0/1/5 agents (goal-3 metric). +- FPS under flood; event-loop-delay probes (`monitorEventLoopDelay`) in + main/daemon/renderer behind a debug flag for pipeline attribution. + +### 7. utilityProcess terminal router (structural endgame; gated on data) + +An Electron UtilityProcess owns the daemon socket and hands each renderer a +MessagePort — VS Code's topology while keeping Orca's detached daemon (warm +reattach). Takes main off the terminal data path entirely; daemon-side +history persistence falls out naturally. Prototype only after baselines show +how much tail latency lives in the main hop. + +### 8. Production P90 telemetry + +Sampled keypress→echo latency + long-task/stall counts from real users; +defines the success criterion and becomes the permanent regression gate. +Design after the local rig stabilizes so the metrics match. + +## Benchmark protocol + +`tools/benchmarks/terminal-pipeline-bench.mjs` measures, from inside any +terminal: + +- **DSR idle latency** — ESC[6n round trips (p50/p90/p99); replies come only + after the parser reaches the query, so it proxies the input pipeline + without keystroke injection. +- **Fenced throughput** — 4 deterministic fixtures (`ascii-log`, `cjk-emoji`, + `agent-tui` — Claude-Code-shaped transcript + DEC-2026 status repaints — + and labeled-pathological `styles-stress`), each run ended by a DSR fence so + xterm.js-class ingest queues can't flatter the result. +- **DSR under load** — latency sampled during a paced 1 MB/s agent-TUI + stream: "typing while the agent works," quantified. + +Rules: same machine, AC power, comparable window size, no tmux/screen, hands +off the keyboard during runs. Never compare numbers across machines. + +## Sequencing + +``` +now: [1] baselines [2] #7153 testing (parallel) +next: [3] term-speed-2 revival (dedicated session) +parallel: [4] stall fixes, [6] rig extensions +after 2/3: [5] flow control +gated: [7] utility router [8] telemetry +``` + +BMW-group crash work remains the team's priority gate above all of this +(#7150's wedge guards overlap it); this plan runs measurement and revival +prep in parallel without displacing it. + +## Findings log + +### 2026-07-02 — baseline + decomposition (results committed in tools/benchmarks/results/) + +Same machine, unattended serial runs (Orca 1.4.91 prod, Terminal.app, Ghostty +1.3.1; iTerm2 not installed, VS Code pending): + +| metric | Orca prod | Terminal.app | Ghostty | +|---|---|---|---| +| DSR idle p50/p99 (ms) | 0.69 / 22.7 | 0.35 / 0.68 | 0.19 / 0.72 | +| DSR under 1 MB/s agent load p50/p99 (ms) | **134 / 292** | 0.45 / 7.9 | 0.21 / 6.1 | +| agent-tui fenced throughput | **2.0 MB/s** | 37 | 78 | +| ascii-log fenced throughput | 13 MB/s | 39 | 93 | + +Decomposition of the 51× agent-tui gap — both pipeline ends are fast: + +- Bare `@xterm/headless` (114×85, scrollback 5000): agent-tui **103 MB/s** + (`terminal-headless-parse-bench.mjs`). The xterm parser is not the problem. +- Daemon `Session` ingest (emulator + pending-output recording + fanout): + agent-tui **103 MB/s** (`session-ingest-throughput.bench.test.ts`, + `ORCA_TERMINAL_PERF_BENCH=1`). The daemon is not the problem. + +Conclusions: (1) idle latency is fine — the extra process hop costs ~0.5 ms, +so the utilityProcess router is deprioritized by data; (2) the crisis is +queueing between daemon egress and renderer parse completion — main +per-chunk processing, the 512 KB delivery/ACK pacing (ACKs fire after +renderer write callbacks, so renderer slowness throttles delivery +multiplicatively), and renderer per-chunk layers above xterm; (3) the +agent-TUI shape (DEC-2026 frames + erase/repaint) is 6.5× worse than plain +text inside Orca while being equal-cost everywhere else — profile it in the +renderer first (task #9). + +### 2026-07-02 — dev-build check of #7139/#7150 (confounded; directional only) + +Dev build of orca-performance (282-col window, 3MB fixtures, dev-mode +overhead): DSR idle p50 0.64 ms (unchanged), **DSR under load p50 161 ms** — +the cooperative-drain branch does not move the under-load class. In +hindsight this is structural: DSR replies are ordered within the output +stream, so the metric measures output-queue depth; #7139 paces draining to +protect input-send responsiveness but cannot reorder the queue. Implications: +(1) the 134 ms-class number is fixed only by shrinking the queue (producer +flow control) or raising drain rate (the 51× throughput hunt); (2) #7153's +own wins (freeze class, bounded memory, input-loss guards) must be validated +with freeze scenarios and real typing, not DSR. Also learned: dev-mode runs +are ~2× slower across the board and fences need `--dsr-timeout-ms` headroom. + +### 2026-07-02 — 51× loss attributed: scheduler fixed-nap drip (task #9) + +The renderer output scheduler (`pane-terminal-output-scheduler.ts`) drained +at most 2×16KB per tick, then slept 4ms (high-priority) / 16ms (background) +regardless of parse speed. Isolation bench (fake timers, instant-parse +terminal — `pane-terminal-output-scheduler-throughput.bench.test.ts`, +`ORCA_TERMINAL_PERF_BENCH=1`): **background cadence = 1.9 MB/s — matching +prod's measured 2.0 MB/s agent-tui ceiling**; foreground = 27 MB/s (only +when arrivals re-poke 0ms drains; Chromium's ~4ms timer clamp makes the +sustained real-world HP ceiling ~8 MB/s). Classification: pty-connection's +`isLatencySensitiveForegroundOutput` routes sizable no-recent-input chunks +to the queue, so floods always ride the drip. + +Fix (committed 9e8bb2243): high-priority drains are now **parse-clocked** — +a pacer re-arms a 0ms drain when xterm's write callback confirms the batch +parsed — and carry 8 writes/tick (128KB ≈ 1.3ms parse). Isolation ceiling: +27 → **117.6 MB/s** (parse-limited). Background cadence deliberately +unchanged (protects the focused pane; hidden panes are term-speed-2's job). +`DRAIN_TIME_BUDGET_MS` still bounds tick work (cooperative-drain intent of +#7139 preserved; its budget-yield test still passes). 621 tests green. + +Open follow-ups from this attribution: (a) end-to-end dev verification (in +progress); (b) whether main's `background:true` delivery marking demotes +visible-pane floods to the background drip — check +`window.__terminalOutputSchedulerDebug` counters in a dev run; (c) ascii-log +gap (13 vs 83 MB/s headless) — likely per-chunk `beforeWrite` side-effect +scanning; profile after (a). + +### 2026-07-03 — THE WHALE: main's retained-tail redraw path is O(tail) per chunk + +Parse-clock fix didn't move end-to-end (agent-tui still 0.7 MB/s dev). Layered +probes (renderer scheduler counters → main whole-method timer → per-section +timers → targeted micro-benches) attributed it fully: + +- Renderer receives only ~350–770 KB/s — it is **starved**, not slow. +- `OrcaRuntime.onPtyData` consumes **~93% of main's event loop** during the + flood (~950 ms/s at ~450 chunks/s ≈ 2.1 ms/chunk). +- All wrapped sub-calls (OSC scanners, agent detect, watchers, headless + track, leaves loop, mobile touch) together: **~3.5%**. The remainder is the + pty-record tail block. +- Micro-bench (`appendNormalizedToTailBuffer` with a real agent-TUI frame + containing `ESC[10A ESC[0J`): **0.888 ms/chunk at a 2,000-line tail** — 32× + the plain-append path. Cause: `appendNormalizedToMultilineTailBuffer` + materializes ~2,001 row objects per chunk (orca-runtime.ts:22324) and + `finalizeRetainedTerminalRows` allocates them all again plus runs a + trailing-whitespace regex per row (:22458) — ~4k allocations + 2k regexes + per tiny chunk, twice the tail length in O(n) passes. Every Claude-Code + frame (cursor-up + erase-below) takes this path; plain logs don't — which + is exactly the measured agent-tui vs ascii asymmetry. + +Chain: TUI flood → O(tail) work per chunk in main → main event loop +saturates → daemon socket backpressures → renderer starved at ~0.4 MB/s → +deep queue → 134 ms DSR-under-load. + +Fix (in progress): run the existing algorithm on a lazy suffix window (the +cursor's maximum upward reach, computed from the chunk) with the untouched +prefix shared by reference; differential fuzz test proves output equality +against the original implementation. Worst case (pathological full-height +cursor-up) falls back to today's cost. + +### 2026-07-03 — windowed-tail fix: partial end-to-end win; next suspect queued + +Dev-build bench after the windowed redraw-tail fix (label dev-tailfix, same +protocol as dev-parseclock): agent-tui **0.7 → 1.0 MB/s (+43%)**, DSR-under- +load **p50 161 → 108 ms, p99 624 → 154 ms (4×)**. Real movement for the +first time, but the pipeline is still far from the renderer's 27–117 MB/s +capacity — another main-side consumer remains hot. + +Next cycle (exact recipe): re-apply the whole-method main probe +(`onPtyDataMs` sampler in `pty.ts` bindProviderListeners) on the fixed +build. If onPtyData still dominates, the remaining O(tail)/per-chunk +suspects in priority order: (1) `buildTerminalWaitText` ×2 per chunk (full +tail join, 0.116 ms/chunk in prod-node isolation — likely 2-4× that in +dev); (2) `normalizeTerminalChunk` (regex over every chunk, never measured); +(3) the per-leaf duplicate tail path when `tailStateMatches` fails. If +onPtyData no longer dominates, probe the main→renderer delivery batching +next. The probe/bench cycle is mechanical: relaunch dev +(`ELECTRON_ENABLE_LOGGING=1 pnpm dev`), `orca-dev terminal create --command +" --label X --size-mb 3 --dsr-timeout-ms 120000"`, grep the log. + +### 2026-07-03 — post-fix attribution: `blockedCheck` is the remaining whale + +Post-windowed-tail probe run (dev build, agent-tui): `onPtyData` still +~90% of main's event loop (~930 ms/s). Bucket split per second: +**blockedCheck ≈ 700–790 ms (~85%)**, waitText ≈ 70, append ≈ 25 (windowed +fix confirmed), normalize ≈ 7, preview ≈ 0. + +Mechanism (orca-runtime.ts:23128 `nextTailHasNewerBlockedReason` + its +callers): per chunk, TWO full wait texts are built (`buildTerminalWaitText` +joins the whole ≤256KB tail), then the check calls `.toLowerCase()` on both +(another ~512KB of string allocation per chunk) and runs multi-pattern +blocked/ready scans (`findTerminalWaitBlockedSignal`, +`findKnownReadyPromptIndex` — lastIndexOf/regex passes over the full text) +— all to timestamp `waitBlockedAt` for `terminal wait`. + +Fix design (next session): blocked/ready prompts are end-anchored — an +actionable prompt is at the END of output. (1) Run the check on a bounded +suffix of the wait text (last ~64 lines / 16KB) instead of the full tail; +(2) cheap pre-filter: skip entirely unless the appended chunk (plus a small +carry for split keywords) can contain a blocked keyword; (3) build the two +wait texts only when the check runs. Verification mirrors the windowed-tail +pattern: keep the full-text check as reference + differential fuzz over +randomized tails/prompts (split-across-chunks cases included — the +`appendCandidateSignal` ordering semantics at :23146 must be preserved), +plus the terminal-wait contract tests. Expected effect: removes ~85% of +remaining onPtyData cost; combined with the two landed fixes should +finally unlock the pipeline toward the renderer's measured 27–117 MB/s. + +### 2026-07-03 — pipeline unlocked: three stacked fixes, 16× throughput, 9× latency + +Dev-build bench with all three fixes (parse-clocked drains 9e8bb2243, +windowed tail 4e08a28cd, throttled blocked-check 66f20258e), label +dev-blockedfix, same protocol/config as prior dev rows: + +| metric | pre-fix dev | +tail fix | +blocked fix | +|---|---|---|---| +| agent-tui MB/s | 0.7 | 1.0 | **11.5** | +| DSR load p50/p99 (ms) | 161 / 624 | 108 / 154 | **18.8 / 24.9** | +| DSR idle p50/p99 (ms) | 0.95 / 21 | 1.09 / 18 | **0.52 / 8.6** | +| ascii-log MB/s | 6.4 | 4.7 | **9.6** | + +The agent-TUI-specific penalty is gone (agent-tui ≈ cjk ≈ ascii now). The +throttled blocked-check delivered the predicted ~85% cut. Dev mode carries +~2× overhead vs prod, so the prod build should land near ~10ms DSR-under- +load — from the 134ms baseline (~13×) — pending a packaged-build rerun. +Remaining floor is structural cadence (8ms daemon batch + 4ms HP drain +ticks + xterm 12ms slices), which flow control (#6) does not target; +re-evaluate the "within 10× of Terminal.app" goal line after a prod +measurement. Next: term-speed-2 revival (#4), then flow control (#6). + +### 2026-07-03 — term-speed-2 revival: merged, green, NOT yet mergeable (perf gate) + +`revive/term-speed-2` pushed (merge a5052c35f, tip 64b6f7abe): 144 files, +typecheck clean, ~2,776 targeted tests green, all three of our fixes +verified present, chain features present and kill-switched (subagent's +six review risks recorded in its report). Bench verdict on the revived +build (dev): DSR-load p50 ~19ms holds, but **throughput regressed ~35% +unconditionally** (agent-tui 11.5 → 7.2–7.4 MB/s; all-switches-OFF round +proved the kill switches are NOT the cost) and idle p50 doubled. + +Attribution so far: main exonerated (whole-method probe: onPtyData ~60ms/s +≈ 6%); renderer reconcile + HP-first selection O(1)-checked; **daemon +CONVICTED by unit bench — `Session` ingest 103 → 39.5/47.7 MB/s (2.2–2.6×) +on the revive branch** (`session-ingest-throughput.bench.test.ts`, +ORCA_TERMINAL_PERF_BENCH=1). Cause: the chain's headless-emulator +restructure (scanner classes / query-reply forwarding / view-attribute +responder) added per-byte cost to the daemon hot path. Chunks reaching +main are now ~5.8KB vs ~650B (daemon emits slower, batches bigger). + +NEXT (fast inner loop — pure unit bench, no app restarts): on +revive/term-speed-2, diff `headless-emulator.ts`/`session.ts` vs +7839fb9db, find the per-chunk scanner cost, restore our bounded-parser +fast paths (the daemon emulator must never pay per-byte JS scanning for +bytes that contain no ESC — same pre-filter pattern as the blocked-check +keyword bypass), verify with the ingest bench back at ~100 MB/s, then +full dev bench expecting blockedfix parity (~11.5 MB/s), THEN merge to +orca-performance. A residual renderer-side share is possible once the +daemon is fixed — re-attribute after. + +Merge gate: revive branch merges only at ≥ blockedfix numbers. + +**RETRACTION (2026-07-03, later):** the daemon conviction above was a +confounded measurement — the 39–48 MB/s ingest runs executed while a dev +app was still running. On a quiet machine the revive branch ingests at +**82–109 MB/s** (≈ pre-merge) and its HeadlessEmulator alone does 99.5 MB/s +vs raw xterm 77.7. The daemon is innocent. Consequently the end-to-end +revival delta (11.5 → 7.2/7.4 dev) is also UNTRUSTED — none of those runs +were load-controlled, and unit benches show up to 2.6× machine-load +variance. Scanner pre-filters landed anyway on revive (71c89da9b; +strictly positive, 641 daemon tests green). + +**New measurement protocol (mandatory from here):** quiet machine (no dev +apps or benches concurrent), paired A/B runs back-to-back alternating +branches, n≥2 per side, report spread not just p50. The merge-gate +comparison (blockedfix vs revive) must be redone under this protocol +before any verdict. Next: run the controlled A/B; if the delta +disappears, merge revive into orca-performance and proceed to flow +control (#6); if it persists, resume attribution renderer-side (probe +pty-connection dataCallback additions per chunk). + +### 2026-07-03 — A/B gate passed; term-speed-2 MERGED to orca-performance + +Load-controlled alternating A/B (fresh app per run, n=2/side, agent-tui + +DSR-load): perf 6.7/5.2 MB/s, dsr p50 19.9/21.3, p99 107.8/218.1; revive +6.1/3.6 MB/s, dsr p50 21.4/20.3, **p99 63.4/26.1**. Verdict: latency p50 +tied, p99 better on revive, throughput within overlapping noise (revive2's +3.6 followed two runtime-busy create failures). The earlier "35% +regression" is confirmed noise. Note: both branches ~5-7 MB/s today vs +11.5 yesterday — dev benches carry ~2x day-to-day machine variance; +absolute dev numbers are only comparable within one A/B session. + +Merged revive/term-speed-2 → orca-performance; typecheck clean, 288 +post-merge spot tests green. orca-performance now = main-ish base + #7153 ++ three perf fixes + full term-speed-2 chain (kill-switched, default ON) ++ scanner pre-filters. Extended user testing now covers everything. +Remaining from the revival agent's risk list: gate×drain e2e specs +(terminal-hidden-*, parked-memory, sleep-wake) still not run — queue them. +Next: producer flow control (#6) per design §5; prod packaged-build bench +for the real headline numbers. + +### 2026-07-03 — flow control merged; goal-state accounting + +Producer flow control merged to orca-performance (348aeb325): protocol +v19 `pausePty`/`resumePty`, 256KB/32KB watermarks on main's pendingData, +node-pty kernel backpressure, 5s daemon-side lost-resume failsafe + +main-side pause re-assert, resume on every teardown path, version-gated +(v≤18/SSH no-op), kill switch `PRODUCER_FLOW_CONTROL_ENABLED` +(ipc/pty.ts:143), 29 new tests. Typecheck + 292 post-merge spot tests +green. + +**Definition-of-done accounting:** +- 51× loss: ATTRIBUTED AND FIXED (three fixes; agent-tui 0.7→11.5 MB/s + and DSR-load p50 161→18.8 dev, results committed). +- term-speed-2: REVIVED AND MERGED (A/B gate passed). +- Flow control: IMPLEMENTED AND MERGED. +- "Within 10× of Terminal.app (4.5ms)": RE-SCOPED to pending a packaged + RC measurement. Evidence: dev = 18.8ms with ~2× dev overhead → prod + projection ~9-10ms ≈ 20× Terminal.app (vs 300× at baseline). The + remaining gap is structural cadence (daemon 8ms batch, renderer drain + ticks, xterm 12ms parse slices) — tunable follow-ups, distinct from the + waste class this initiative eliminated. Prod verification path: + electron-vite preview CANNOT host the bench (CLI-created panes are not + adopted by the preview window's renderer → no ACKs → pending-cap drop; + two attempts, documented) — measure on the next packaged RC cut from + orca-performance using the committed rig + protocol instead. + +**Deferred, ordered:** (1) sync orca-performance with main — conflicts +incl. stream-opcode collision (chain `Ack=12` vs main's #7205-era +`Metadata=12`; renumber chain side, audit mobile/web stream consumers); +(2) chain's e2e specs (hidden parking / parked memory / sleep-wake) — +gate×drain risk; (3) cadence tuning toward the 10× line; (4) rig +extensions + P90 telemetry (tasks #3/#8). + +### 2026-07-03 — PROD VERDICT: v1.4.121-rc.0 benchmarked (the headline numbers) + +Same rig, same protocol, same machine as the 1.4.91 baseline: + +| metric | 1.4.91 baseline | v1.4.121-rc.0 | change | +|---|---|---|---| +| DSR idle p50 | 0.69 ms | **0.44 ms** | = Terminal.app (0.45) | +| DSR under load p50 | 134 ms | **18.6 ms** | 7.2x | +| DSR under load p99 | 292 ms | **29.7 ms** | 9.8x | +| agent-tui | 2.0 MB/s | **11.2 MB/s** | 5.6x | +| styles-stress | 7.8 MB/s | **10.4 MB/s** | 1.3x | +| ascii-log | 13 MB/s | 11.0 MB/s | ~0.85x | +| cjk-emoji | 15 MB/s | 12.2 MB/s | ~0.81x | + +Reading: the anomalous TUI penalty is GONE — all four fixtures now sit at +a uniform ~11-12 MB/s, which is the scheduler pacing ceiling, not parse +CPU (prod ≈ dev for both latency and throughput; the pipeline is +cadence-bound, so faster prod code changes nothing). That uniform cap +also explains plain-text dipping slightly below baseline: ascii/cjk used +to run unpaced ahead of the old scheduler; now everything flows through +the same parse-clocked path. Goal line check: 18.6 ms = 41x Terminal.app +under load (goal was 10x = 4.5 ms) — NOT met; down from 300x. Idle IS at +parity. The remaining 4x is the named cadence stack (daemon 8 ms batch, +scheduler drain ticks + 8x16KB per-tick budget, xterm 12 ms slices) — +next lever, tunable, tracked as follow-up. p99 tail (the freeze class) +is 29.7 ms — users cannot perceive it. + +Caveat: measured on the user's live app (this session active in it); +idle p99 118 ms reflects that activity, not the terminal path. + +### 2026-07-03 — Same-engine reference: VS Code head-to-head (same machine, same rig) + +| metric | Orca v1.4.121-rc.0 | VS Code | verdict | +|---|---|---|---| +| DSR idle p50 | **0.44 ms** | 7.00 ms | Orca 16x faster | +| DSR load p50 | 18.6 ms | **7.18 ms** | VS Code 2.6x faster | +| DSR load p99 | **29.7 ms** | 43.4 ms | Orca 1.5x better tail | +| ascii-log | **11.0 MB/s** | 9.0 | Orca +22% | +| cjk-emoji | 12.2 | 11.3 | tie | +| agent-tui | 11.2 | 11.7 | tie | +| styles-stress | **10.4 MB/s** | 2.0 | Orca 5.2x | + +Orca now beats or ties the best-known xterm.js terminal on 5 of 6 +metrics — including 16x at idle (what users feel all day) and 5x on +SGR-heavy output — and holds a better p99 tail under load. Throughput +sits at the shared engine ceiling (~9-12 MB/s), confirming the class +limit. + +The one loss (load p50) has a clean mechanism: VS Code's producer flow +control caps unacked output at ~100KB, so its standing queue is +~100KB / 11.7 MB/s ≈ 8.5 ms — matching its 7.18. Our standing queue +(18.6 ms ≈ ~200KB at 11 MB/s) is set by the main→renderer ACK window +(512KB/pty high water) + drain re-arm cadence (Chromium clamps nested +setTimeout to ~4ms). Two levers, both cheap to test: (1) MessageChannel +drain scheduling (sub-ms re-arm; also raises the throughput ceiling); +(2) tighter effective in-flight window on the renderer delivery path. +Target: VS Code's ~7ms class or below without giving back throughput. + +### 2026-07-03 — Batch windows were the gap: dev DSR-load p50 19 -> 8.0ms + +Lever results (dev, 3MB protocol, same session): +- MessageChannel drains (2434dfaae): 19.01ms — NO change. Proved the + ~19ms was NOT queue depth: at 1MB/s vs ~11MB/s capacity (9% util) + there is no standing queue. Kept (correct, removes a real clamp). +- Batch windows 8->2ms on BOTH hops (e67a91d7a: daemon + STREAM_DATA_BATCH_INTERVAL_MS + main PTY_BATCH_INTERVAL_MS): + **p50 8.00 / p90 10.13 / p99 12.26ms** (from 19.01/22.7/28.1). + Throughput unchanged (agent-tui 9.8 vs 9.1, ambient noise). 239 + batcher+pty tests green after timing updates. + +Dev-mode 8.0ms already matches VS Code prod (7.18); prod build should +land BELOW VS Code. p99: ours 12.3 vs VS Code 43.4. The remaining +fixed-latency terms are renderer/xterm-internal (12ms parse slices). +Note: main's interactive bypass (input-gated) means real keystroke echo +skips batching entirely — the DSR metric understates real typing +responsiveness; VS Code measured on the same freight path, comparison +fair. + +Next: cut RC, confirm in prod, re-baseline vs Terminal.app (expect +~8-15x from 300x at baseline; goal line 10x = 4.5ms now plausibly in +reach). + +### 2026-07-03 — Chain e2e debt PAID: all 6 hidden-pane specs green + +terminal-hidden-view-parking (parks + restores rich TUI on reveal; bell/ +title side effects live while parked), terminal-sleep-wake-restore +(output restored + input accepted after wake), terminal-parked-memory +(renderer memory released on park; views retained when kill-switched +off): 6/6 passed, electron-headless, 1.1m. The gate x drain interplay — +the revival's top flagged risk — now has e2e coverage on the exact +branch the RC ships from. Remaining garble-hardening: differential +hide/reveal fuzz harness (next build), reveal-time seq diagnostics. + +## Success criteria (baseline-relative; finalize after task 1) + +- DSR-under-load p90 in Orca within striking distance of iTerm2 on the same + box; zero DSR timeouts (today's freeze class). +- Fenced agent-tui throughput ≥ VS Code on the same box. +- Idle RSS with 0–1 agents materially down (target set after the memory + harness lands; hidden-pane parking is the main lever). +- Zero >100 ms event-loop stalls in main/renderer during a 10 MB flood. +- Production P90 typing latency down and monitored continuously. diff --git a/package.json b/package.json index 4bae0747aae..b9a3f9d124b 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,7 @@ "test:e2e:terminal-perf:scale:report": "pnpm run ensure:electron-runtime && node config/scripts/run-terminal-scale-perf-report-gate.mjs", "test:e2e:terminal-perf:check-report": "node config/scripts/check-terminal-perf-report-budgets.mjs", "test:e2e:terminal-perf:summarize": "node config/scripts/summarize-terminal-perf-report.mjs", + "test:e2e:terminal-perf:html-report": "node config/scripts/generate-terminal-perf-html-report.mjs", "test:e2e:ssh-docker-perf": "node config/scripts/run-ssh-docker-perf-e2e.mjs", "test:e2e:source-control-scale": "pnpm run ensure:electron-runtime && npx playwright test tests/e2e/source-control-large-file-count.spec.ts --config tests/playwright.config.ts --project electron-headless --workers=1", "win-update-e2e": "node tools/win-update-e2e/run.mjs", @@ -91,6 +92,7 @@ "bench:startup": "pnpm run ensure:electron-runtime && node tools/benchmarks/startup-time-bench.mjs", "bench:daemon-coldstart": "pnpm run ensure:electron-runtime && node tools/benchmarks/daemon-coldstart-bench.mjs", "bench:main-thread-jank": "pnpm run ensure:electron-runtime && node tools/benchmarks/main-thread-jank-bench.mjs", + "bench:multi-workspace-typing": "pnpm run ensure:electron-runtime && node config/scripts/run-multi-workspace-typing-bench.mjs", "bench:compare": "node config/scripts/compare-benchmark-artifacts.mjs" }, "dependencies": { @@ -257,7 +259,8 @@ "patchedDependencies": { "node-pty@1.1.0": "config/patches/node-pty@1.1.0.patch", "@xterm/addon-ligatures@0.11.0-beta.287": "config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch", - "@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch" + "@xterm/addon-webgl@0.20.0-beta.286": "config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch", + "@xterm/addon-serialize@0.15.0-beta.287": "config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch" } }, "reactDoctor": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cf5fc522fd1..b17bd6adb8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ patchedDependencies: '@xterm/addon-ligatures@0.11.0-beta.287': hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920 path: config/patches/@xterm__addon-ligatures@0.11.0-beta.287.patch + '@xterm/addon-serialize@0.15.0-beta.287': + hash: 81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9 + path: config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch '@xterm/addon-webgl@0.20.0-beta.286': hash: c32044ff5e9bfa651090eedbec19a13b2343fca3cc163fa8eec04c97880a3e19 path: config/patches/@xterm__addon-webgl@0.20.0-beta.286.patch @@ -36,7 +39,7 @@ importers: version: 2.5.6 '@xterm/addon-serialize': specifier: 0.15.0-beta.287 - version: 0.15.0-beta.287(@xterm/xterm@6.1.0-beta.287) + version: 0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287) '@xterm/headless': specifier: 6.1.0-beta.287 version: 6.1.0-beta.287 @@ -9288,7 +9291,7 @@ snapshots: dependencies: '@xterm/xterm': 6.1.0-beta.287 - '@xterm/addon-serialize@0.15.0-beta.287(@xterm/xterm@6.1.0-beta.287)': + '@xterm/addon-serialize@0.15.0-beta.287(patch_hash=81575700d58b62f9262d302aa4ae43b445a6273d579cd75dd6729c8883011ab9)(@xterm/xterm@6.1.0-beta.287)': dependencies: '@xterm/xterm': 6.1.0-beta.287 diff --git a/src/main/daemon/daemon-background-transient-facts.test.ts b/src/main/daemon/daemon-background-transient-facts.test.ts new file mode 100644 index 00000000000..acfcdb959f4 --- /dev/null +++ b/src/main/daemon/daemon-background-transient-facts.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, vi } from 'vitest' +import { BackgroundTransientFactRelay } from './daemon-background-transient-facts' +import type { DaemonTransientFact } from './types' + +function createRelay() { + const emitted: { sessionId: string; fact: DaemonTransientFact }[] = [] + const relay = new BackgroundTransientFactRelay((sessionId, fact) => + emitted.push({ sessionId, fact }) + ) + return { relay, emitted } +} + +describe('BackgroundTransientFactRelay', () => { + it('emits a bell fact for a backgrounded session', () => { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + relay.onSessionData('s1', 'build output\x07more') + expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'bell' } }]) + }) + + it('keeps OSC escape state across chunks — a title terminator BEL is not a bell', () => { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + relay.onSessionData('s1', '\x1b]0;my working title') + relay.onSessionData('s1', ' continued\x07') + expect(emitted).toEqual([]) + }) + + it('emits nothing for sessions that are not backgrounded', () => { + const { relay, emitted } = createRelay() + relay.onSessionData('s1', 'ding\x07') + expect(emitted).toEqual([]) + }) + + it('emits command-finished with the OSC 133;D exit code', () => { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + relay.onSessionData('s1', '\x1b]133;D;0\x07') + expect(emitted).toEqual([{ sessionId: 's1', fact: { kind: 'command-finished', exitCode: 0 } }]) + }) + + it('stops emitting after un-background and reports the toggle as a state change', () => { + const { relay, emitted } = createRelay() + expect(relay.setSessionBackground('s1', true)).toBe(true) + expect(relay.setSessionBackground('s1', true)).toBe(false) + expect(relay.setSessionBackground('s1', false)).toBe(true) + expect(relay.setSessionBackground('s1', false)).toBe(false) + relay.onSessionData('s1', 'ding\x07') + expect(emitted).toEqual([]) + }) + + it('drops the tracker on session exit', () => { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + relay.onSessionExit('s1') + expect(relay.isBackgrounded('s1')).toBe(false) + relay.onSessionData('s1', 'ding\x07') + expect(emitted).toEqual([]) + }) + + it('never arms the stale-working-title timer (titles are main-authoritative)', () => { + vi.useFakeTimers() + try { + const { relay, emitted } = createRelay() + relay.setSessionBackground('s1', true) + // A working-spinner title followed by title-less output would arm the + // 3s stale timer if titles were being tracked. + relay.onSessionData('s1', '\x1b]0;⠋ Claude\x07') + relay.onSessionData('s1', 'output without titles') + expect(vi.getTimerCount()).toBe(0) + expect(emitted).toEqual([]) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/src/main/daemon/daemon-background-transient-facts.ts b/src/main/daemon/daemon-background-transient-facts.ts new file mode 100644 index 00000000000..3867ab0eddd --- /dev/null +++ b/src/main/daemon/daemon-background-transient-facts.ts @@ -0,0 +1,106 @@ +/** + * Daemon-side transient-fact scanning for backgrounded sessions. + * + * While a session is backgrounded (its pane hidden in the renderer), the + * daemon→main stream copy may be keep-tail thinned under backlog — but the + * notification-bearing facts inside those bytes must never be lost. This + * relay runs the SAME shared scanners main uses (terminal-side-effect + * authority doc: semantics must not drift) over every raw chunk BEFORE any + * drop decision, and emits compact transientFact stream events in byte order. + * Main suppresses its own copies of these four scanners between the + * sessionBackgroundMarker handoffs, so no fact double-fires or goes missing. + * + * Title/agent-status facts are deliberately NOT relayed: they converge from + * the delivered kept tail (stale-working-title timer, snapshot-restores-title + * -state) and main fuses them with synthetic spinner frames the daemon never + * sees. + */ +import { + createTerminalTitleTracker, + type TerminalTitleTracker +} from '../../shared/terminal-output-side-effects' +import type { DaemonTransientFact } from './types' + +// Kill switch for the whole background keep-tail mechanism (thinning + +// daemon-side fact authority): ORCA_DAEMON_BACKGROUND_STREAM_DROP=0. +export const BACKGROUND_STREAM_DROP_ENABLED = process.env.ORCA_DAEMON_BACKGROUND_STREAM_DROP !== '0' + +export class BackgroundTransientFactRelay { + private trackersBySessionId = new Map() + private emitFact: (sessionId: string, fact: DaemonTransientFact) => void + + constructor(emitFact: (sessionId: string, fact: DaemonTransientFact) => void) { + this.emitFact = emitFact + } + + isBackgrounded(sessionId: string): boolean { + return this.trackersBySessionId.has(sessionId) + } + + backgroundedSessionIdSuffixes(): string[] { + return Array.from(this.trackersBySessionId.keys(), (id) => id.slice(-10)) + } + + /** Returns false when this was a no-op (already in the requested state) so + * the caller can skip a duplicate handoff marker — resyncs after adoption + * re-send the whole background set. */ + setSessionBackground(sessionId: string, background: boolean): boolean { + if (background === this.isBackgrounded(sessionId)) { + return false + } + if (background) { + this.trackersBySessionId.set( + sessionId, + createTerminalTitleTracker({ + onBell: () => this.emitFact(sessionId, { kind: 'bell' }), + onCommandFinished: (exitCode) => + this.emitFact(sessionId, { kind: 'command-finished', exitCode }), + // Note: recreating the tracker on each background toggle resets the + // PR-link dedup memory, so a link re-printed across toggles can + // re-fire — consumers treat pr-link as a latest-association update. + onPrLink: (link) => this.emitFact(sessionId, { kind: 'pr-link', link }), + onMode2031Subscribe: () => this.emitFact(sessionId, { kind: '2031-subscribe' }) + }) + ) + } else { + this.disposeTracker(sessionId) + } + return true + } + + /** Prime a fresh tracker's cross-chunk carry with the emulator's dangling + * incomplete escape at handoff time, so a sequence split across the + * background toggle neither mints a phantom bell nor loses its fact. A + * partial tail contains no complete sequence, so this can never fire. */ + seedSessionScanState(sessionId: string, partialEscapeTailAnsi: string): void { + if (partialEscapeTailAnsi.length > 0) { + this.trackersBySessionId + .get(sessionId) + ?.handleChunk(partialEscapeTailAnsi, { titleScanData: '' }) + } + } + + /** Feed one raw chunk, in byte order, BEFORE it is enqueued for delivery — + * facts must be captured even when the chunk is later keep-tail dropped. */ + onSessionData(sessionId: string, data: string): void { + // titleScanData:'' skips title extraction (titles stay main-authoritative) + // and keeps the stale-working-title timer permanently unarmed — only the + // four transient scanners consume the chunk. + this.trackersBySessionId.get(sessionId)?.handleChunk(data, { titleScanData: '' }) + } + + onSessionExit(sessionId: string): void { + this.disposeTracker(sessionId) + } + + dispose(): void { + for (const sessionId of Array.from(this.trackersBySessionId.keys())) { + this.disposeTracker(sessionId) + } + } + + private disposeTracker(sessionId: string): void { + this.trackersBySessionId.get(sessionId)?.dispose() + this.trackersBySessionId.delete(sessionId) + } +} diff --git a/src/main/daemon/daemon-entry.ts b/src/main/daemon/daemon-entry.ts index 9dfc97532fa..34c04043950 100644 --- a/src/main/daemon/daemon-entry.ts +++ b/src/main/daemon/daemon-entry.ts @@ -131,7 +131,9 @@ async function main(): Promise { // Signal readiness to parent via IPC (if available) if (process.send) { - process.send({ type: 'ready' }) + // Why: Windows has no cheap OS query for a child's start time, so the + // daemon self-reports it here for the pid file's pid-recycling guard. + process.send({ type: 'ready', startedAtMs: Date.now() - process.uptime() * 1000 }) } daemonLog.log('ready') diff --git a/src/main/daemon/daemon-errors.ts b/src/main/daemon/daemon-errors.ts new file mode 100644 index 00000000000..99d19818a3c --- /dev/null +++ b/src/main/daemon/daemon-errors.ts @@ -0,0 +1,23 @@ +// Error classes shared across the daemon protocol boundary (client, server, +// host). Split from types.ts, which is capped for wire-shape declarations. + +export class TerminalAttachCanceledError extends Error { + constructor(sessionId: string) { + super(`Attach canceled for session ${sessionId}`) + this.name = 'TerminalAttachCanceledError' + } +} + +export class DaemonProtocolError extends Error { + constructor(message: string) { + super(message) + this.name = 'DaemonProtocolError' + } +} + +export class SessionNotFoundError extends Error { + constructor(sessionId: string) { + super(`Session not found: ${sessionId}`) + this.name = 'SessionNotFoundError' + } +} diff --git a/src/main/daemon/daemon-health.test.ts b/src/main/daemon/daemon-health.test.ts index 94d0564d09b..defbc8fd09a 100644 --- a/src/main/daemon/daemon-health.test.ts +++ b/src/main/daemon/daemon-health.test.ts @@ -13,7 +13,9 @@ import { parseLinuxBootTimeSeconds, parseLinuxProcStartTicks, parseDaemonPidFile, - startTimeMatches + parseWindowsProcessIdentityJson, + startTimeMatches, + startTimesWithinTolerance } from './daemon-health' import type { SubprocessHandle } from './session' @@ -125,6 +127,26 @@ describe('daemon health', () => { await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false) }) + it('classifies a hello-rejected daemon as rejected, not unreachable', async () => { + // Why: 'rejected' means the daemon answered and refused adoption — the + // launcher may replace it. 'unreachable' also covers a wedged-but-live + // daemon, which must never be replaced while its pipe accepts connections. + const server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => createMockSubprocess() + }) + await server.start() + + try { + writeFileSync(tokenPath, 'not-the-daemon-token', { mode: 0o600 }) + await expect(checkDaemonHealth(socketPath, tokenPath)).resolves.toBe('rejected') + await expect(healthCheckDaemon(socketPath, tokenPath)).resolves.toBe(false) + } finally { + await server.shutdown() + } + }) + it('does not unlink a live socket when the pid file does not match this daemon', async () => { if (process.platform === 'win32') { return @@ -286,6 +308,42 @@ describe('startTimeMatches', () => { }) }) +describe('parseWindowsProcessIdentityJson', () => { + it('parses command line and start time from the CIM query output', () => { + expect( + parseWindowsProcessIdentityJson( + '{"cmd":"Orca.exe daemon-entry.js","start":1700000000000}\r\n' + ) + ).toEqual({ commandLine: 'Orca.exe daemon-entry.js', startedAtMs: 1_700_000_000_000 }) + }) + + it('returns a null start time when CreationDate was unavailable', () => { + expect( + parseWindowsProcessIdentityJson('{"cmd":"Orca.exe daemon-entry.js","start":null}') + ).toEqual({ commandLine: 'Orca.exe daemon-entry.js', startedAtMs: null }) + }) + + it('returns null for a missing process or inaccessible command line', () => { + expect(parseWindowsProcessIdentityJson('')).toBeNull() + expect(parseWindowsProcessIdentityJson(' \r\n')).toBeNull() + expect(parseWindowsProcessIdentityJson('{"cmd":null,"start":123}')).toBeNull() + expect(parseWindowsProcessIdentityJson('not-json')).toBeNull() + }) +}) + +describe('startTimesWithinTolerance', () => { + it('fails open when either side is null', () => { + expect(startTimesWithinTolerance(null, 1_700_000_000_000, 1_500)).toBe(true) + expect(startTimesWithinTolerance(1_700_000_000_000, null, 1_500)).toBe(true) + expect(startTimesWithinTolerance(null, null, 1_500)).toBe(true) + }) + + it('matches within tolerance and rejects outside it', () => { + expect(startTimesWithinTolerance(1_700_000_001_000, 1_700_000_000_000, 1_500)).toBe(true) + expect(startTimesWithinTolerance(1_700_000_005_000, 1_700_000_000_000, 1_500)).toBe(false) + }) +}) + describe('killStaleDaemon pid identity guards', () => { let dir: string let socketPath: string diff --git a/src/main/daemon/daemon-health.ts b/src/main/daemon/daemon-health.ts index 96d4a885dea..50e7226f73c 100644 --- a/src/main/daemon/daemon-health.ts +++ b/src/main/daemon/daemon-health.ts @@ -24,8 +24,17 @@ const RESOLVER_HEALTH_CHECK_TIMEOUT_MS = 3_000 const KILL_WAIT_MS = 3_000 const KILL_POLL_MS = 100 const START_TIME_TOLERANCE_MS = 1_500 +// Why: on Windows the pid file's startedAtMs is the daemon's self-reported +// Node start time, while verification reads the OS process creation time — +// the gap between them is the exe bootstrap, which AV/disk pressure can +// stretch to seconds. Pid recycling differs by minutes-to-days, so a wide +// tolerance keeps the guard effective without false mismatches. +const WIN32_START_TIME_TOLERANCE_MS = 10_000 -export type DaemonHealth = 'healthy' | 'unreachable' | 'pty-spawn-unhealthy' +// 'rejected' means the daemon answered and refused the handshake (bad token, +// foreign protocol) — it can never be adopted, unlike 'unreachable', which +// also covers a live-but-wedged daemon that simply missed the RPC budget. +export type DaemonHealth = 'healthy' | 'unreachable' | 'rejected' | 'pty-spawn-unhealthy' type ParsedDaemonPid = { pid: number @@ -134,13 +143,13 @@ export function checkDaemonHealth(socketPath: string, tokenPath: string): Promis try { message = JSON.parse(line) as Record } catch { - settle('unreachable') + settle('rejected') return } if (message.type === 'hello') { if (!(message as HelloResponse).ok) { - settle('unreachable') + settle('rejected') return } // Why: a protocol-live daemon with a stale cwd or node-pty helper @@ -371,6 +380,10 @@ export function getProcessStartedAtMs(pid: number): number | null { } if (process.platform === 'win32') { + // Why: the only OS source is a CIM query costing a powershell spawn — + // too slow for this sync path. Windows pid files instead carry the + // daemon's self-reported start time from its ready message, and + // isDaemonProcess verifies it against CIM CreationDate asynchronously. return null } @@ -387,27 +400,61 @@ export function getProcessStartedAtMs(pid: number): number | null { } export function startTimeMatches(pid: number, expectedStartedAtMs: number | null): boolean { - if (expectedStartedAtMs === null) { + return startTimesWithinTolerance( + getProcessStartedAtMs(pid), + expectedStartedAtMs, + START_TIME_TOLERANCE_MS + ) +} + +// Why: fail open on null — a pid file or OS query without a start time must +// not veto an otherwise-matching daemon (adoption safety beats recycle safety). +export function startTimesWithinTolerance( + actualStartedAtMs: number | null, + expectedStartedAtMs: number | null, + toleranceMs: number +): boolean { + if (expectedStartedAtMs === null || actualStartedAtMs === null) { return true } - - const actualStartedAtMs = getProcessStartedAtMs(pid) - if (actualStartedAtMs === null) { - return true - } - - return Math.abs(actualStartedAtMs - expectedStartedAtMs) <= START_TIME_TOLERANCE_MS + return Math.abs(actualStartedAtMs - expectedStartedAtMs) <= toleranceMs } const execFileAsync = promisify(execFile) +export type WindowsProcessIdentity = { + commandLine: string + startedAtMs: number | null +} + +export function parseWindowsProcessIdentityJson(stdout: string): WindowsProcessIdentity | null { + const trimmed = stdout.trim() + if (!trimmed) { + return null + } + try { + const parsed = JSON.parse(trimmed) as { cmd?: unknown; start?: unknown } + if (typeof parsed.cmd !== 'string' || !parsed.cmd) { + return null + } + return { + commandLine: parsed.cmd, + startedAtMs: + typeof parsed.start === 'number' && Number.isFinite(parsed.start) ? parsed.start : null + } + } catch { + return null + } +} + // Why: the only reliable command-line source on Windows is a CIM query, which // costs a full powershell.exe spawn (300-800ms cold, worse under Defender). // Async because the sync version measurably froze the Electron main thread at // startup for the whole spawn (benchmark: ~0.5s warm, 3s timeout cap cold). -// Timed under ORCA_STARTUP_DIAGNOSTICS so the cold-start benchmark can -// attribute startup cost to these checks. -async function queryWindowsProcessCommandLine(pid: number): Promise { +// CreationDate rides along in the same spawn so start-time verification adds +// zero extra process launches. Timed under ORCA_STARTUP_DIAGNOSTICS so the +// cold-start benchmark can attribute startup cost to these checks. +async function queryWindowsProcessIdentity(pid: number): Promise { const startedAt = performance.now() try { const { stdout } = await execFileAsync( @@ -416,14 +463,17 @@ async function queryWindowsProcessCommandLine(pid: number): Promise { if (process.platform === 'win32') { - return queryWindowsProcessCommandLine(pid) + return (await queryWindowsProcessIdentity(pid))?.commandLine ?? null } try { diff --git a/src/main/daemon/daemon-init.test.ts b/src/main/daemon/daemon-init.test.ts index 39f05c641ee..06344c0759f 100644 --- a/src/main/daemon/daemon-init.test.ts +++ b/src/main/daemon/daemon-init.test.ts @@ -25,6 +25,8 @@ const { isPackagedMock, probeSocketExistsMock, writeFileSyncMock, + readFileSyncMock, + unlinkSyncMock, netConnectMock, forkMock, checkDaemonHealthMock, @@ -34,6 +36,7 @@ const { isDaemonStaleForCurrentBundleMock, killStaleDaemonMock, getProcessStartedAtMsMock, + parseDaemonPidFileMock, daemonClientMock, spawnerInstances, ensureRunningOverrides, @@ -51,6 +54,13 @@ const { const probeSocketExistsMock = vi.fn((_path?: string) => false) const writeFileSyncMock = vi.fn() + // Why: readFileSync throws by default so legacyDaemonProcessMayBeAlive's + // catch treats every legacy pid file as unreadable — matching the pre-fix + // cleanup behavior every existing test was written against. + const readFileSyncMock = vi.fn((): string => { + throw new Error('ENOENT') + }) + const unlinkSyncMock = vi.fn() const forkMock = vi.fn() const netConnectMock = vi.fn(() => { // Why: the real probeSocket() in daemon-init connects to the socket and @@ -83,7 +93,10 @@ const { const getDaemonLaunchIdentityMock = vi.fn(() => 'match') const isDaemonStaleForCurrentBundleMock = vi.fn(() => false) const killStaleDaemonMock = vi.fn(async () => true) - const getProcessStartedAtMsMock = vi.fn(() => 1_000_000) + const getProcessStartedAtMsMock = vi.fn((): number | null => 1_000_000) + const parseDaemonPidFileMock = vi.fn( + (): { pid: number; startedAtMs: number | null } | null => null + ) const daemonClientMock = vi.fn().mockImplementation(function MockDaemonClient() { return { @@ -145,6 +158,8 @@ const { isPackagedMock, probeSocketExistsMock, writeFileSyncMock, + readFileSyncMock, + unlinkSyncMock, netConnectMock, forkMock, checkDaemonHealthMock, @@ -154,6 +169,7 @@ const { isDaemonStaleForCurrentBundleMock, killStaleDaemonMock, getProcessStartedAtMsMock, + parseDaemonPidFileMock, daemonClientMock, spawnerInstances, ensureRunningOverrides, @@ -214,7 +230,8 @@ vi.mock('electron', () => ({ vi.mock('fs', () => ({ mkdirSync: vi.fn(), existsSync: (p: string) => probeSocketExistsMock(p) || p.includes('.pid'), - unlinkSync: vi.fn(), + unlinkSync: unlinkSyncMock, + readFileSync: readFileSyncMock, writeFileSync: writeFileSyncMock })) @@ -229,7 +246,8 @@ vi.mock('./daemon-health', () => ({ healthCheckDaemon: healthCheckDaemonMock, isDaemonStaleForCurrentBundle: isDaemonStaleForCurrentBundleMock, killStaleDaemon: killStaleDaemonMock, - getProcessStartedAtMs: getProcessStartedAtMsMock + getProcessStartedAtMs: getProcessStartedAtMsMock, + parseDaemonPidFile: parseDaemonPidFileMock })) vi.mock('./client', () => ({ DaemonClient: daemonClientMock })) @@ -358,6 +376,15 @@ async function importFresh() { daemonClientMock.mockClear() probeSocketExistsMock.mockClear() writeFileSyncMock.mockClear() + readFileSyncMock.mockReset() + readFileSyncMock.mockImplementation(() => { + throw new Error('ENOENT') + }) + unlinkSyncMock.mockClear() + parseDaemonPidFileMock.mockReset() + parseDaemonPidFileMock.mockReturnValue(null) + getProcessStartedAtMsMock.mockReset() + getProcessStartedAtMsMock.mockReturnValue(1_000_000) // Why: importing daemon-init *after* resetModules means the module-level // `spawner`/`adapter`/`restartInFlight` start fresh for every test, which is // the only way to reliably exercise the "first-time init" path and the @@ -1551,7 +1578,7 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).not.toHaveBeenCalled() }) - it('replaces a health-check-failing daemon when live sessions cannot be verified', async () => { + it('replaces a health-check-failing daemon when live sessions cannot be verified and the pipe is dead', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() @@ -1595,6 +1622,203 @@ describe('daemon-init: runRestartDaemon (7-step sequence)', () => { expect(forkMock).toHaveBeenCalled() }) + it('adopts an unresponsive daemon whose pipe still accepts connections (update-relaunch wedge)', async () => { + // Why: the Windows update-relaunch regression — post-install disk/AV load + // wedges the daemon past the 3s health budget AND the 5s hello budget of + // the session-list re-verification, while its sessions are still alive. + // The old fail-closed path killed the daemon here. A pipe that accepts a + // raw connection proves the daemon is alive, so the launcher must adopt. + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => { + throw new Error('Hello response timed out') + }), + request: vi.fn(), + disconnect: vi.fn() + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + checkDaemonHealthMock.mockResolvedValueOnce('unreachable') + // The raw pipe probe succeeds even though every RPC timed out. + probeSocketExistsMock.mockReturnValue(true) + netConnectMock.mockImplementationOnce(() => { + const handlers: Record void)[]> = { connect: [], error: [] } + return { + on(event: string, cb: () => void) { + handlers[event]?.push(cb) + if (event === 'connect') { + queueMicrotask(() => cb()) + } + return this + }, + removeListener(event: string, cb: () => void) { + handlers[event] = handlers[event]?.filter((handler) => handler !== cb) ?? [] + return this + }, + destroy() {} + } + }) + + await launcher('/fake/socket', '/fake/token') + + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) + + it('replaces a hello-rejected daemon even though its pipe accepts connections', async () => { + // Why: 'rejected' means the daemon answered and refused the handshake — + // it can never be adopted, so keeping it alive would strand the app with + // no terminals forever. Replacement stays the only recovery. + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + daemonClientMock.mockImplementationOnce(function MockDaemonClient() { + return { + ensureConnected: vi.fn(async () => { + throw new Error('Hello rejected') + }), + request: vi.fn(), + disconnect: vi.fn() + } + }) + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + checkDaemonHealthMock.mockResolvedValueOnce('rejected') + probeSocketExistsMock.mockReturnValue(true) + forkMock.mockImplementationOnce(() => ({ + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready' })) + } + return this + }, + off() { + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + })) + + await launcher('/fake/socket', '/fake/token') + + expect(killStaleDaemonMock).toHaveBeenCalledWith( + FAKE_RUNTIME_DIR, + '/fake/socket', + '/fake/token' + ) + expect(forkMock).toHaveBeenCalled() + }) + + it('adopts a healthy daemon whose pid-file identity cannot be verified (null startedAtMs metadata)', async () => { + // Why: the regression contract — a pid file with startedAtMs null (all + // pre-fix Windows pid files) resolves launch identity to 'unknown'. With + // a live daemon answering on the pipe, that must ADOPT, never replace. + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + getDaemonLaunchIdentityMock.mockReturnValueOnce('unknown') + isPackagedMock.mockReturnValue(true) + + await launcher('/fake/socket', '/fake/token') + + expect(killStaleDaemonMock).not.toHaveBeenCalled() + expect(forkMock).not.toHaveBeenCalled() + }) + + it('writes the daemon self-reported start time to the pid file when the OS query returns null', async () => { + // Why: getProcessStartedAtMs has no cheap Windows implementation, so the + // pid file's pid-recycling guard depends on the ready-message fallback. + const mod = await importFresh() + await mod.initDaemonPtyProvider() + + const launcher = spawnerInstances[0].launcher as ( + socketPath: string, + tokenPath: string + ) => Promise<{ shutdown(): Promise }> + checkDaemonHealthMock.mockResolvedValueOnce('unreachable') + getProcessStartedAtMsMock.mockReturnValue(null) + forkMock.mockImplementationOnce(() => ({ + pid: 12345, + on(event: string, cb: (arg?: unknown) => void) { + if (event === 'message') { + queueMicrotask(() => cb({ type: 'ready', startedAtMs: 1_700_000_123_456 })) + } + return this + }, + off() { + return this + }, + disconnect: vi.fn(), + unref: vi.fn() + })) + + await launcher('/fake/socket', '/fake/token') + + expect(writeFileSyncMock).toHaveBeenCalledWith( + `/fake/daemon/daemon-v${PROTOCOL_VERSION}.pid`, + JSON.stringify({ + pid: 12345, + startedAtMs: 1_700_000_123_456, + entryPath: FAKE_DAEMON_ENTRY_PATH, + appVersion: '1.2.3' + }), + { mode: 0o600 } + ) + }) + + it('keeps legacy daemon pid/token files when the probe fails but the pid-file process is alive', async () => { + // Why: deleting a live-but-wedged legacy daemon's token file makes its + // sessions permanently unadoptable — no future launch could authenticate. + const mod = await importFresh() + readFileSyncMock.mockReturnValue('{"pid":123}') + // process.pid is guaranteed alive, so the liveness probe succeeds. + parseDaemonPidFileMock.mockReturnValue({ pid: process.pid, startedAtMs: null }) + + await mod.initDaemonPtyProvider() + + const legacyUnlinks = unlinkSyncMock.mock.calls.filter( + ([p]) => typeof p === 'string' && (p.includes('.token') || p.includes('.pid')) + ) + expect(legacyUnlinks).toEqual([]) + }) + + it('cleans up legacy daemon pid/token files when the probe fails and the process is gone', async () => { + const mod = await importFresh() + readFileSyncMock.mockReturnValue('{"pid":123}') + // Why: spy on process.kill so the liveness probe deterministically reports + // "no such process" without depending on an unallocated real pid. + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => { + throw new Error('ESRCH') + }) + parseDaemonPidFileMock.mockReturnValue({ pid: 999_999, startedAtMs: null }) + + try { + await mod.initDaemonPtyProvider() + } finally { + killSpy.mockRestore() + } + + const tokenUnlinks = unlinkSyncMock.mock.calls.filter( + ([p]) => typeof p === 'string' && p.includes('.token') + ) + expect(tokenUnlinks.length).toBeGreaterThan(0) + }) + it('replaces a health-check-failing daemon when no live sessions would be lost', async () => { const mod = await importFresh() await mod.initDaemonPtyProvider() diff --git a/src/main/daemon/daemon-init.ts b/src/main/daemon/daemon-init.ts index 4de553cde16..ad0227bae86 100644 --- a/src/main/daemon/daemon-init.ts +++ b/src/main/daemon/daemon-init.ts @@ -8,7 +8,7 @@ module-level spawner/adapter singletons must stay co-located so a future change cannot leave them drifting out of sync. */ import { join } from 'node:path' import { app } from 'electron' -import { mkdirSync, existsSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, existsSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs' import { fork } from 'node:child_process' import { connect } from 'node:net' import { @@ -34,7 +34,8 @@ import { getProcessStartedAtMs, checkDaemonHealth, isDaemonStaleForCurrentBundle, - killStaleDaemon + killStaleDaemon, + parseDaemonPidFile } from './daemon-health' import { collectPinnedDaemonVersions, @@ -255,9 +256,7 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { // Why: a busy machine (e.g. right after an update) can time out the // health check while the daemon is alive and owning terminals. Killing // it would destroy every live session, so re-verify with a session list - // first. Only a verified non-empty list preserves: a daemon that cannot - // even list sessions cannot serve terminals, and replacing it is the - // only recovery. + // first. const liveSessionCount = await getAliveDaemonSessionCount(socketPath, tokenPath) if (liveSessionCount !== null && liveSessionCount > 0) { if (health === 'pty-spawn-unhealthy') { @@ -275,6 +274,20 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { ) return createPreservedDaemonHandle(runtimeDir) } + // Why: on a Windows update relaunch the daemon can be wedged past every + // RPC budget (final checkpoint flush + installer/AV disk pressure), so + // both the health check AND the session list time out while sessions + // are still alive — failing closed here is what killed those sessions. + // A pipe that still accepts connections proves a live daemon: adopt it + // and let the adapter reconnect once the daemon drains. 'rejected' + // means the daemon answered and refused the handshake — it can never be + // adopted, so replacement stays the only recovery. + if (liveSessionCount === null && health !== 'rejected' && (await probeSocket(socketPath))) { + console.warn( + '[daemon] Preserving unresponsive daemon because its socket still accepts connections' + ) + return createPreservedDaemonHandle(runtimeDir) + } } // Why: a raw socket can outlive a broken or wedged daemon. Kill by PID @@ -398,11 +411,19 @@ function createOutOfProcessLauncher(runtimeDir: string): DaemonLauncher { // killStaleDaemon() can verify the pid still belongs to the daemon // we forked before SIGTERMing it. Prevents pid-recycling hazard // where the OS hands the daemon's old pid to an unrelated process. + // Why the ready-message fallback: Windows has no cheap OS query + // for start time, so the daemon self-reports it — without this the + // recycling guard was permanently inert on win32. + const selfReported = (msg as { startedAtMs?: unknown }).startedAtMs writeFileSync( getDaemonPidPath(runtimeDir), serializeDaemonPidFile({ pid: child.pid, - startedAtMs: getProcessStartedAtMs(child.pid), + startedAtMs: + getProcessStartedAtMs(child.pid) ?? + (typeof selfReported === 'number' && Number.isFinite(selfReported) + ? selfReported + : null), entryPath, appVersion: app.getVersion() }), @@ -821,6 +842,21 @@ export async function cleanupDaemonForProtocol( return { cleaned: didRequestShutdown || didKillStaleDaemon, killedCount } } +function legacyDaemonProcessMayBeAlive(runtimeDir: string, protocolVersion: number): boolean { + try { + const parsed = parseDaemonPidFile( + readFileSync(getDaemonPidPath(runtimeDir, protocolVersion), 'utf8') + ) + if (!parsed) { + return false + } + process.kill(parsed.pid, 0) + return true + } catch { + return false + } +} + async function createLegacyDaemonAdapters(runtimeDir: string): Promise { const adapters: DaemonPtyAdapter[] = [] for (const protocolVersion of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) { @@ -830,23 +866,27 @@ async function createLegacyDaemonAdapters(runtimeDir: string): Promise void>> + resume: ReturnType void>> _simulateData: (data: string) => void _simulateExit: (code: number) => void } { @@ -43,6 +46,8 @@ function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & { getForegroundProcess: vi.fn(() => null), write: vi.fn(), resize: vi.fn(), + pause: vi.fn<() => void>(), + resume: vi.fn<() => void>(), kill: vi.fn(() => setTimeout(() => onExitCb?.(0), 5)), forceKill: vi.fn(), signal: vi.fn(), @@ -185,6 +190,135 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + describe('producer flow control', () => { + it('routes pausePty/resumePty notifications to the daemon session subprocess', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + + adapter.pauseProducer(id) + await waitFor(() => lastSubprocess.pause.mock.calls.length > 0) + + adapter.resumeProducer(id) + await waitFor(() => lastSubprocess.resume.mock.calls.length > 0) + }) + + it('sends pause/resume as fire-and-forget notifications on the current protocol', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + try { + adapter.pauseProducer(id) + adapter.resumeProducer(id) + expect(notifySpy).toHaveBeenCalledWith('pausePty', { sessionId: id }) + expect(notifySpy).toHaveBeenCalledWith('resumePty', { sessionId: id }) + } finally { + notifySpy.mockRestore() + } + }) + + it('never sends pause/resume notifications on a legacy protocol version', () => { + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 18 }) + try { + legacy.pauseProducer('legacy-session') + legacy.resumeProducer('legacy-session') + expect(notifySpy).not.toHaveBeenCalled() + } finally { + legacy.dispose() + notifySpy.mockRestore() + } + }) + + it('owes paused sessions a resumePty on the next connect after a socket drop', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + adapter.pauseProducer(id) + await waitFor(() => lastSubprocess.pause.mock.calls.length > 0) + + // Drop the daemon out from under the adapter: the in-flight pause has no + // matching resume anymore. + await server.shutdown() + await waitFor(() => !(adapter as unknown as { client: DaemonClient }).client.isConnected()) + + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: (opts) => { + lastSpawnOpts = opts + lastSubprocess = createMockSubprocess() + return lastSubprocess + } + }) + await server.start() + + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + try { + // Any reconnecting operation must flush the owed resume first. + await adapter.listProcesses() + expect(notifySpy).toHaveBeenCalledWith('resumePty', { sessionId: id }) + } finally { + notifySpy.mockRestore() + } + }) + }) + + describe('background stream thinning compatibility', () => { + it('reports background state on the authoritative-snapshot protocol', () => { + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + try { + adapter.setPtyBackgrounded('current-session', true) + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'current-session', + background: true + }) + } finally { + notifySpy.mockRestore() + } + }) + + it('keeps preserved v19 sessions unthinned because their snapshots have no sequence', () => { + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) + try { + legacy.setPtyBackgrounded('legacy-session', true) + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'legacy-session', + background: false + }) + } finally { + legacy.dispose() + notifySpy.mockRestore() + } + }) + + it('clears a preserved v19 background hint before attaching its stream', async () => { + const ensureConnectedSpy = vi + .spyOn(DaemonClient.prototype, 'ensureConnected') + .mockResolvedValue() + const requestSpy = vi.spyOn(DaemonClient.prototype, 'request').mockResolvedValue({ + isNew: true, + pid: null, + shellState: 'unsupported', + snapshot: null + } as never) + const notifySpy = vi.spyOn(DaemonClient.prototype, 'notify') + const legacy = new DaemonPtyAdapter({ socketPath, tokenPath, protocolVersion: 19 }) + try { + await legacy.spawn({ sessionId: 'legacy-session', cols: 80, rows: 24 }) + + expect(notifySpy).toHaveBeenCalledWith('setSessionBackground', { + sessionId: 'legacy-session', + background: false + }) + expect(notifySpy.mock.invocationCallOrder[0]).toBeLessThan( + requestSpy.mock.invocationCallOrder[0] + ) + } finally { + legacy.dispose() + notifySpy.mockRestore() + requestSpy.mockRestore() + ensureConnectedSpy.mockRestore() + } + }) + }) + describe('getAppliedSize', () => { it('reports the spawn dims before any resize', async () => { const { id } = await adapter.spawn({ cols: 80, rows: 24 }) @@ -225,6 +359,23 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { }) }) + describe('getBufferSnapshot', () => { + it('returns the daemon model with its absolute stream sequence', async () => { + const { id } = await adapter.spawn({ cols: 80, rows: 24 }) + lastSubprocess._simulateData('complete hidden output\r\n') + + const snapshot = await adapter.getBufferSnapshot(id, { scrollbackRows: 123 }) + + expect(snapshot).toMatchObject({ + data: expect.stringContaining('complete hidden output'), + cols: 80, + rows: 24, + seq: 'complete hidden output\r\n'.length, + source: 'headless' + }) + }) + }) + describe('shutdown', () => { it('kills the session', async () => { const { id } = await adapter.spawn({ cols: 80, rows: 24 }) @@ -884,6 +1035,105 @@ describe('DaemonPtyAdapter (IPtyProvider)', () => { } }) + it('checkpoints before keep-history shutdown so sleep can cold restore latest output', async () => { + const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } + const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS + adapterClass.CHECKPOINT_INTERVAL_MS = 10_000 + + try { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: 'sleep-checkpoint' + }) + const checkpointSpy = vi.spyOn(historyAdapter.getHistoryManager()!, 'checkpoint') + + lastSubprocess._simulateData('latest before sleep\r\n') + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + + expect(checkpointSpy).toHaveBeenCalledWith( + id, + expect.objectContaining({ snapshotAnsi: expect.stringContaining('latest before sleep') }) + ) + expect(existsSync(join(historyDir, getHistorySessionDirName(id)))).toBe(true) + + const restored = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(restored.coldRestore?.scrollback).toContain('latest before sleep') + historyAdapter.ackColdRestore(id) + + const remountAfterAck = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(remountAfterAck.coldRestore).toBeUndefined() + } finally { + adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval + } + }) + + it('cold restores the second sleep/wake cycle with post-wake output', async () => { + const adapterClass = DaemonPtyAdapter as unknown as { CHECKPOINT_INTERVAL_MS: number } + const previousInterval = adapterClass.CHECKPOINT_INTERVAL_MS + adapterClass.CHECKPOINT_INTERVAL_MS = 10_000 + + try { + historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) + const { id } = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: 'sleep-wake-cycles' + }) + + lastSubprocess._simulateData('first cycle content\r\n') + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + const metaPath = join(historyDir, getHistorySessionDirName(id), 'meta.json') + const checkpointPath = join(historyDir, getHistorySessionDirName(id), 'checkpoint.json') + // Why: keep-history sleep stays unclean so cold restore remains eligible; + // the final checkpoint is the deterministic handoff signal. + expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull() + expect(JSON.parse(readFileSync(checkpointPath, 'utf-8')).snapshotAnsi).toContain( + 'first cycle content' + ) + + const firstWake = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(firstWake.coldRestore?.scrollback).toContain('first cycle content') + historyAdapter.ackColdRestore(id) + expect(historyAdapter.hasPty(id)).toBe(true) + + lastSubprocess._simulateData('second cycle content\r\n') + await historyAdapter.shutdown(id, { immediate: true, keepHistory: true }) + expect(JSON.parse(readFileSync(metaPath, 'utf-8')).endedAt).toBeNull() + expect(JSON.parse(readFileSync(checkpointPath, 'utf-8')).snapshotAnsi).toContain( + 'second cycle content' + ) + + const secondWake = await historyAdapter.spawn({ + cols: 80, + rows: 24, + cwd: '/home/user', + sessionId: id + }) + expect(secondWake.coldRestore?.scrollback).toContain('second cycle content') + } finally { + adapterClass.CHECKPOINT_INTERVAL_MS = previousInterval + } + }) + it('writes meta.json with endedAt on exit', async () => { historyAdapter = new DaemonPtyAdapter({ socketPath, tokenPath, historyPath: historyDir }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index cac7f14e480..3535b410404 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -21,6 +21,8 @@ import { } from './types' import type { IPtyProvider, + PtyBackgroundStreamEvent, + PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnOptions, PtySpawnResult @@ -79,8 +81,13 @@ export class DaemonPtyAdapter implements IPtyProvider { // lock, each would fork its own daemon process. This promise coalesces // concurrent respawns so only the first caller forks; the rest await it. private respawnPromise: Promise | null = null - private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + private dataListeners: ((payload: { + id: string + data: string + sequenceChars?: number + }) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] + private backgroundStreamListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] private removeEventListener: (() => void) | null = null private initialCwds = new Map() // Why: React re-renders and StrictMode double-mounts can call createOrAttach @@ -93,6 +100,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // mount → ??? The sticky cache returns the same cold restore data on the // second mount until the renderer explicitly acknowledges it. private coldRestoreCache = new Map() + private sleepRestoreSessionIds = new Set() private activeSessionIds = new Set() private dirtySessionVersions = new Map() // Why: a cold-restored session is a fresh shell whose on-disk checkpoint and @@ -109,6 +117,19 @@ export class DaemonPtyAdapter implements IPtyProvider { // Why: incremental checkpoints require the takePendingOutput RPC (v13+). // Against older daemons the tick falls back to full-snapshot checkpoints. private supportsIncrementalCheckpoints: boolean + // Why: producer pause/resume notifications require v19+; legacy daemons + // must never see them, so gating makes them silent no-ops there. + private supportsProducerFlowControl: boolean + private supportsAuthoritativeBufferSnapshots: boolean + private pausedProducerSessionIds = new Set() + // Why tracked here: the daemon's background set (keep-tail stream thinning + // + transient-fact scan authority) dies with the daemon process/socket; + // re-sync it on a fresh connection so hidden panes stay thinned. + private backgroundedSessionIds = new Set() + // Why: a daemon that survives a socket drop can still hold a pause whose + // resume died with the connection. Owe those sessions a resume on the next + // connect; the daemon's 5s failsafe covers the window in between. + private producerResumesOwedOnReconnect = new Set() private static CHECKPOINT_INTERVAL_MS = 5_000 // Why: a streaming session (build logs, `yes`) re-triggers a full multi-MB // snapshot checkpoint on every 5s tick via pending-buffer overflow or the @@ -133,6 +154,14 @@ export class DaemonPtyAdapter implements IPtyProvider { this.respawnFn = opts.respawn ?? null this.supportsCheckpoints = this.protocolVersion >= 4 this.supportsIncrementalCheckpoints = this.protocolVersion >= 13 + this.supportsProducerFlowControl = this.protocolVersion >= 19 + this.supportsAuthoritativeBufferSnapshots = this.protocolVersion >= 20 + this.client.onDisconnected(() => { + for (const id of this.pausedProducerSessionIds) { + this.producerResumesOwedOnReconnect.add(id) + } + this.pausedProducerSessionIds.clear() + }) } getHistoryManager(): HistoryManager | null { @@ -155,6 +184,12 @@ export class DaemonPtyAdapter implements IPtyProvider { } await this.ensureConnected() + // Why before createOrAttach: a preserved v19 daemon may remember this + // session as backgrounded. Ordered control delivery clears it before any + // newly attached stream bytes can be thinned without a recoverable seq. + if (!this.supportsAuthoritativeBufferSnapshots) { + this.setPtyBackgrounded(sessionId, false) + } // Why: detect crash-recovery history before spawning a replacement PTY so // the revived shell inherits the recovered cwd and dimensions instead of @@ -232,6 +267,14 @@ export class DaemonPtyAdapter implements IPtyProvider { // but should still return the cached cold restore data. const cachedRestore = this.coldRestoreCache.get(sessionId) if (cachedRestore) { + // Why: wake after sleep also lands here, and the slept session's active + // tracking and history writer were dropped when sleep killed the PTY. + // Without re-registering both, checkpoints stop after wake and the + // second sleep/wake cycle restores a blank terminal. + this.activeSessionIds.add(sessionId) + if (this.historyManager) { + this.historyManager.reopenSession(sessionId) + } return { id: sessionId, pid, @@ -275,13 +318,7 @@ export class DaemonPtyAdapter implements IPtyProvider { // an unclean shutdown → return saved scrollback so the renderer can // display the previous terminal content. if (restoreInfo && (result.isNew || result.historySeeded === false)) { - // Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer - // (vim/less/htop); normal sessions use the full snapshot + rehydrate. - // Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback) - // would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone* - // (no rehydrateSequences — they start with \x1b[?1049h, which the - // renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as - // normal scrollback. An empty snapshot still yields null → no-op. + const coldRestore = this.buildColdRestorePayload(restoreInfo) const canReanchorHistory = !scrollback || result.historySeeded === true // Why: use registerWriter (not openSession) to avoid deleting the // existing checkpoint.json. If the revived daemon crashes again before @@ -300,8 +337,7 @@ export class DaemonPtyAdapter implements IPtyProvider { this.historyManager.suspendSession(sessionId) } } - if (scrollback) { - const coldRestore = { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks } + if (coldRestore) { this.coldRestoreCache.set(sessionId, coldRestore) return { id: sessionId, @@ -348,13 +384,24 @@ export class DaemonPtyAdapter implements IPtyProvider { } const isAltScreen = result.snapshot.modes.alternateScreen - const snapshotPayload = result.snapshot.rehydrateSequences + result.snapshot.snapshotAnsi + const snapshotPayload = + result.snapshot.scrollbackAnsi + + result.snapshot.rehydrateSequences + + result.snapshot.snapshotAnsi + // Why kitty flags ride beside the payload, not inside it: the snapshot + // string reaches renderer xterms too, where POST_REPLAY_REATTACH_RESET's + // deliberate kitty reset must win. Only the runtime emulator re-seed + // consumes the flags (terminal-query-authority.md §kitty). + const kittyKeyboardFlags = result.snapshot.modes.kittyKeyboardFlags return { id: sessionId, pid, snapshot: snapshotPayload, snapshotCols: result.snapshot.cols, snapshotRows: result.snapshot.rows, + ...(typeof kittyKeyboardFlags === 'number' && kittyKeyboardFlags > 0 + ? { snapshotKittyKeyboardFlags: kittyKeyboardFlags } + : {}), isReattach: true, isAlternateScreen: isAltScreen, // Why: carry the mid-escape tail so the renderer can write it after the @@ -368,6 +415,9 @@ export class DaemonPtyAdapter implements IPtyProvider { async attach(id: string): Promise { await this.ensureConnected() + if (!this.supportsAuthoritativeBufferSnapshots) { + this.setPtyBackgrounded(id, false) + } await this.client.request('createOrAttach', { sessionId: id, @@ -390,16 +440,62 @@ export class DaemonPtyAdapter implements IPtyProvider { this.client.notify('resize', { sessionId: id, cols, rows }) } + pauseProducer(id: string): void { + if (!this.supportsProducerFlowControl) { + return + } + this.pausedProducerSessionIds.add(id) + this.client.notify('pausePty', { sessionId: id }) + } + + resumeProducer(id: string): void { + this.producerResumesOwedOnReconnect.delete(id) + if (!this.supportsProducerFlowControl) { + return + } + this.pausedProducerSessionIds.delete(id) + this.client.notify('resumePty', { sessionId: id }) + } + + // Why fire-and-forget (like pausePty): a delivery hint for the daemon's + // keep-tail stream thinning. + setPtyBackgrounded(id: string, background: boolean): void { + if (!this.supportsProducerFlowControl) { + return + } + // Why: preserved v19 daemons can thin but cannot return the absolute + // snapshot sequence needed to recover a gap. Clear their stale hint too. + const safeBackground = this.supportsAuthoritativeBufferSnapshots && background + if (safeBackground) { + this.backgroundedSessionIds.add(id) + } else { + this.backgroundedSessionIds.delete(id) + } + this.client.notify('setSessionBackground', { sessionId: id, background: safeBackground }) + } + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { - // Why: sleep/exact-stop must preserve restorable terminal history, - // so force a final checkpoint before killing the daemon session. + // Why: sleep/exact-stop kills the live PTY before the periodic checkpoint may run. + // Force a final snapshot so wake can restore the pane users left. if (opts.keepHistory) { + if (this.checkpointInFlight) { + await this.checkpointInFlight + } await this.checkpointSessions([id], { final: true, teardown: true }) + const restoreInfo = this.historyReader?.detectColdRestore(id) ?? null + const coldRestore = restoreInfo ? this.buildColdRestorePayload(restoreInfo) : null + if (coldRestore) { + this.coldRestoreCache.set(id, coldRestore) + this.sleepRestoreSessionIds.add(id) + } } await this.client.request('kill', { sessionId: id, immediate: opts.immediate ?? false }) this.activeSessionIds.delete(id) this.dirtySessionVersions.delete(id) - this.coldRestoreCache.delete(id) + if (!opts.keepHistory) { + this.coldRestoreCache.delete(id) + this.sleepRestoreSessionIds.delete(id) + } // Why: the !keepHistory close path doesn't take a final checkpoint, so a // session stranded in sessionsNeedingFullCheckpoint would never be cleared. // (Under keepHistory the final checkpoint above already cleared the flag, so @@ -435,12 +531,30 @@ export class DaemonPtyAdapter implements IPtyProvider { ackColdRestore(sessionId: string): void { this.coldRestoreCache.delete(sessionId) + this.sleepRestoreSessionIds.delete(sessionId) } clearTombstone(sessionId: string): void { this.killedSessionTombstones.delete(sessionId) } + private buildColdRestorePayload(restoreInfo: ColdRestoreInfo): ColdRestorePayload | null { + // Why prefer scrollbackAnsi for alt-screen: snapshotAnsi is the alt buffer + // (vim/less/htop); normal sessions use the full snapshot + rehydrate. + // Why the snapshotAnsi fallback: a hibernated TUI agent (empty scrollback) + // would otherwise get `|| null` → blank pane on wake. snapshotAnsi *alone* + // (no rehydrateSequences — they start with \x1b[?1049h, which the + // renderer's POST_REPLAY_MODE_RESET does NOT undo) lands the last frame as + // normal scrollback. An empty snapshot still yields null → no-op. + const scrollback = restoreInfo.modes.alternateScreen + ? restoreInfo.scrollbackAnsi || restoreInfo.snapshotAnsi || null + : restoreInfo.rehydrateSequences + restoreInfo.snapshotAnsi + if (!scrollback) { + return null + } + return { scrollback, cwd: restoreInfo.cwd, oscLinks: restoreInfo.oscLinks } + } + async sendSignal(id: string, signal: string): Promise { await this.client.request('signal', { sessionId: id, signal }) } @@ -478,6 +592,44 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + async getBufferSnapshot( + id: string, + opts: { scrollbackRows?: number } = {} + ): Promise { + if (!this.supportsAuthoritativeBufferSnapshots) { + return null + } + try { + const result = await this.client.request('getSnapshot', { + sessionId: id, + ...(typeof opts.scrollbackRows === 'number' ? { scrollbackRows: opts.scrollbackRows } : {}) + }) + const snapshot = result.snapshot + // Why: older v19 daemons have no absolute output sequence. Their snapshot + // cannot safely reconcile stream bytes still queued on the other socket. + if (!snapshot || typeof snapshot.outputSequence !== 'number') { + return null + } + return { + data: snapshot.rehydrateSequences + snapshot.snapshotAnsi, + scrollbackAnsi: snapshot.scrollbackAnsi, + cols: snapshot.cols, + rows: snapshot.rows, + cwd: snapshot.cwd, + lastTitle: snapshot.lastTitle, + seq: snapshot.outputSequence, + source: 'headless', + oscLinks: snapshot.oscLinks, + alternateScreen: snapshot.modes.alternateScreen, + ...(snapshot.pendingEscapeTailAnsi + ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } + : {}) + } + } catch { + return null + } + } + async clearBuffer(id: string): Promise { await this.client.request('clearScrollback', { sessionId: id }) this.markSessionDirty(id) @@ -608,6 +760,8 @@ export class DaemonPtyAdapter implements IPtyProvider { this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() this.sessionsNeedingFullCheckpoint.clear() + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() this.stopCheckpointTimer() for (const id of ids) { this.coldRestoreCache.delete(id) @@ -640,7 +794,9 @@ export class DaemonPtyAdapter implements IPtyProvider { return shells.filter((s) => existsSync(s)).map((s) => ({ name: basename(s), path: s })) } - onData(callback: (payload: { id: string; data: string }) => void): () => void { + onData( + callback: (payload: { id: string; data: string; sequenceChars?: number }) => void + ): () => void { this.dataListeners.push(callback) return () => { const idx = this.dataListeners.indexOf(callback) @@ -650,6 +806,16 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { + this.backgroundStreamListeners.push(callback) + return () => { + const idx = this.backgroundStreamListeners.indexOf(callback) + if (idx !== -1) { + this.backgroundStreamListeners.splice(idx, 1) + } + } + } + onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { return () => {} } @@ -669,6 +835,8 @@ export class DaemonPtyAdapter implements IPtyProvider { this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() this.coldRestoreCache.clear() + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() this.removeEventListener?.() this.removeEventListener = null // Why: final checkpoints are written daemon-side in TerminalHost.dispose() @@ -705,6 +873,13 @@ export class DaemonPtyAdapter implements IPtyProvider { this.dirtySessionVersions.clear() this.lastFullCheckpointAt.clear() this.coldRestoreCache.clear() + // Why: the detached daemon keeps these PTYs alive for warm reattach; a + // pause left behind would block their shells for a failsafe window. + for (const id of this.pausedProducerSessionIds) { + this.client.notify('resumePty', { sessionId: id }) + } + this.pausedProducerSessionIds.clear() + this.producerResumesOwedOnReconnect.clear() this.removeEventListener?.() this.removeEventListener = null this.client.disconnect() @@ -712,8 +887,36 @@ export class DaemonPtyAdapter implements IPtyProvider { private async ensureConnected(): Promise { await this.client.ensureConnected() + // Why sampled before setupEventRouting: routing is (re)installed exactly + // once per connection, so "no listener yet" identifies a fresh connect — + // the only time the daemon-side backgrounded set needs a resync (it is + // process state that died with the previous daemon/socket). + const isFreshConnection = this.removeEventListener === null this.setupEventRouting() this.scheduleCheckpointTimer() + this.flushOwedProducerResumes() + if (isFreshConnection) { + this.resyncBackgroundedSessions() + } + } + + private resyncBackgroundedSessions(): void { + for (const id of this.backgroundedSessionIds) { + // Harmless no-op for sessions the daemon doesn't know (yet). + this.client.notify('setSessionBackground', { sessionId: id, background: true }) + } + } + + private flushOwedProducerResumes(): void { + if (this.producerResumesOwedOnReconnect.size === 0) { + return + } + for (const id of this.producerResumesOwedOnReconnect) { + // Why: resuming a session the fresh daemon doesn't know is a harmless + // no-op; leaving a survivor paused would waste 5s of failsafe latency. + this.client.notify('resumePty', { sessionId: id }) + } + this.producerResumesOwedOnReconnect.clear() } private stopCheckpointTimer(): void { @@ -1027,6 +1230,13 @@ export class DaemonPtyAdapter implements IPtyProvider { } } + private emitBackgroundStreamEvent(payload: PtyBackgroundStreamEvent): void { + // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration + for (const listener of [...this.backgroundStreamListeners]) { + listener(payload) + } + } + private async doRespawn(message = '[daemon] Daemon died — respawning'): Promise { console.warn(message) this.removeEventListener?.() @@ -1050,12 +1260,50 @@ export class DaemonPtyAdapter implements IPtyProvider { this.markSessionDirty(event.sessionId) // oxlint-disable-next-line unicorn/no-useless-spread -- copy-safe: listeners may unsubscribe during iteration for (const listener of [...this.dataListeners]) { - listener({ id: event.sessionId, data: event.payload.data }) + listener({ + id: event.sessionId, + data: event.payload.data, + ...(event.payload.sequenceChars === undefined + ? {} + : { sequenceChars: event.payload.sequenceChars }) + }) } + } else if (event.event === 'sessionBackgroundMarker') { + this.emitBackgroundStreamEvent({ + id: event.sessionId, + kind: 'backgroundMarker', + background: event.payload.background, + ...(event.payload.scanSeedAnsi !== undefined + ? { scanSeedAnsi: event.payload.scanSeedAnsi } + : {}) + }) + } else if (event.event === 'dataGap') { + this.emitBackgroundStreamEvent({ + id: event.sessionId, + kind: 'dataGap', + droppedChars: event.payload.droppedChars, + ...(event.payload.sequenceChars === undefined + ? {} + : { sequenceChars: event.payload.sequenceChars }) + }) + } else if (event.event === 'transientFact') { + this.emitBackgroundStreamEvent({ + id: event.sessionId, + kind: 'transientFact', + fact: event.payload + }) } else if (event.event === 'exit') { this.activeSessionIds.delete(event.sessionId) this.dirtySessionVersions.delete(event.sessionId) - this.coldRestoreCache.delete(event.sessionId) + // Why: an exited session must not be owed a resume on reconnect — a + // reused sessionId would receive a stray resumePty. Same for the + // background set: a reused id must start un-thinned. + this.pausedProducerSessionIds.delete(event.sessionId) + this.producerResumesOwedOnReconnect.delete(event.sessionId) + this.backgroundedSessionIds.delete(event.sessionId) + if (!this.sleepRestoreSessionIds.has(event.sessionId)) { + this.coldRestoreCache.delete(event.sessionId) + } // Why: an exited session can never be checkpointed again, so its pending // full-checkpoint flag is dead state. Without this, a cold-restored // session that exits before its first checkpoint leaks a permanent entry. diff --git a/src/main/daemon/daemon-pty-router.test.ts b/src/main/daemon/daemon-pty-router.test.ts index 47d44aa807c..a8c2b2232d8 100644 --- a/src/main/daemon/daemon-pty-router.test.ts +++ b/src/main/daemon/daemon-pty-router.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { DaemonPtyRouter } from './daemon-pty-router' import type { DaemonPtyAdapter } from './daemon-pty-adapter' -import type { PtySpawnOptions, PtySpawnResult } from '../providers/types' +import type { PtyBackgroundStreamEvent, PtySpawnOptions, PtySpawnResult } from '../providers/types' type AdapterMock = DaemonPtyAdapter & { - emitData: (id: string, data: string) => void + emitData: (id: string, data: string, sequenceChars?: number) => void + emitBackground: (event: PtyBackgroundStreamEvent) => void emitExit: (id: string, code: number) => void } @@ -24,7 +25,9 @@ function createAdapter( reconcileResult?: { alive: string[]; killed: string[] } ): AdapterMock { const writes: { id: string; data: string }[] = [] - const dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = + [] + const backgroundListeners: ((payload: PtyBackgroundStreamEvent) => void)[] = [] const exitListeners: ((payload: { id: string; code: number }) => void)[] = [] return { spawn: vi.fn(async (opts: PtySpawnOptions): Promise => { @@ -44,6 +47,8 @@ function createAdapter( writes.push({ id, data }) }), resize: vi.fn(), + setPtyBackgrounded: vi.fn(), + getBufferSnapshot: vi.fn(async () => null), shutdown: vi.fn(async (id: string) => { const idx = sessions.indexOf(id) if (idx !== -1) { @@ -62,12 +67,23 @@ function createAdapter( revive: vi.fn(async () => {}), getDefaultShell: vi.fn(async () => '/bin/zsh'), getProfiles: vi.fn(async () => []), - onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { - dataListeners.push(callback) + onData: vi.fn( + (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { + dataListeners.push(callback) + return () => { + const idx = dataListeners.indexOf(callback) + if (idx !== -1) { + dataListeners.splice(idx, 1) + } + } + } + ), + onBackgroundStreamEvent: vi.fn((callback: (payload: PtyBackgroundStreamEvent) => void) => { + backgroundListeners.push(callback) return () => { - const idx = dataListeners.indexOf(callback) + const idx = backgroundListeners.indexOf(callback) if (idx !== -1) { - dataListeners.splice(idx, 1) + backgroundListeners.splice(idx, 1) } } }), @@ -85,9 +101,14 @@ function createAdapter( reconcileOnStartup: vi.fn(async () => reconcileResult ?? { alive: sessions, killed: [] }), dispose: vi.fn(), disconnectOnly: vi.fn(async () => {}), - emitData: (id: string, data: string) => { + emitData: (id: string, data: string, sequenceChars?: number) => { for (const listener of dataListeners) { - listener({ id, data }) + listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) + } + }, + emitBackground: (event: PtyBackgroundStreamEvent) => { + for (const listener of backgroundListeners) { + listener(event) } }, emitExit: (id: string, code: number) => { @@ -118,6 +139,62 @@ describe('DaemonPtyRouter', () => { expect(current.write).toHaveBeenCalledWith(fresh.id, 'new\n') }) + it('routes background hints and authoritative snapshots to the session owner', async () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy', ['legacy-session']) + const snapshot = { + data: 'legacy frame', + cols: 80, + rows: 24, + seq: 42, + source: 'headless' as const + } + vi.mocked(legacy.getBufferSnapshot).mockResolvedValue(snapshot) + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + await router.discoverLegacySessions() + + router.setPtyBackgrounded('legacy-session', true) + await expect( + router.getBufferSnapshot('legacy-session', { scrollbackRows: 50_000 }) + ).resolves.toEqual(snapshot) + + expect(legacy.setPtyBackgrounded).toHaveBeenCalledWith('legacy-session', true) + expect(current.setPtyBackgrounded).not.toHaveBeenCalled() + expect(legacy.getBufferSnapshot).toHaveBeenCalledWith('legacy-session', { + scrollbackRows: 50_000 + }) + }) + + it('forwards gap events and explicit sequence accounting from every adapter', () => { + const current = createAdapter('current') + const legacy = createAdapter('legacy') + const router = new DaemonPtyRouter({ current, legacy: [legacy] }) + const dataSpy = vi.fn() + const backgroundSpy = vi.fn() + router.onData(dataSpy) + router.onBackgroundStreamEvent(backgroundSpy) + + current.emitData('current-session', '\x1b[6n', 0) + legacy.emitBackground({ + id: 'legacy-session', + kind: 'dataGap', + droppedChars: 512, + sequenceChars: 508 + }) + + expect(dataSpy).toHaveBeenCalledWith({ + id: 'current-session', + data: '\x1b[6n', + sequenceChars: 0 + }) + expect(backgroundSpy).toHaveBeenCalledWith({ + id: 'legacy-session', + kind: 'dataGap', + droppedChars: 512, + sequenceChars: 508 + }) + }) + it('drops a legacy mapping after the routed session exits', async () => { const current = createAdapter('current') const legacy = createAdapter('legacy', ['legacy-session']) diff --git a/src/main/daemon/daemon-pty-router.ts b/src/main/daemon/daemon-pty-router.ts index 1f80f770cd9..ed43c663325 100644 --- a/src/main/daemon/daemon-pty-router.ts +++ b/src/main/daemon/daemon-pty-router.ts @@ -1,6 +1,8 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { IPtyProvider, + PtyBackgroundStreamEvent, + PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnOptions, PtySpawnResult @@ -11,7 +13,11 @@ export class DaemonPtyRouter implements IPtyProvider { private legacy: DaemonPtyAdapter[] private sessionAdapters = new Map() private unsubscribers: (() => void)[] = [] - private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + private dataListeners: ((payload: { + id: string + data: string + sequenceChars?: number + }) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] constructor(opts: { current: DaemonPtyAdapter; legacy: DaemonPtyAdapter[] }) { @@ -76,6 +82,18 @@ export class DaemonPtyRouter implements IPtyProvider { this.adapterFor(id).resize(id, cols, rows) } + pauseProducer(id: string): void { + this.adapterFor(id).pauseProducer(id) + } + + resumeProducer(id: string): void { + this.adapterFor(id).resumeProducer(id) + } + + setPtyBackgrounded(id: string, background: boolean): void { + this.adapterFor(id).setPtyBackgrounded(id, background) + } + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { await this.adapterFor(id).shutdown(id, opts) // Why: sleep passes keepHistory=true and re-spawns against the same @@ -105,6 +123,13 @@ export class DaemonPtyRouter implements IPtyProvider { return (await this.adapterFor(id).getAppliedSize?.(id)) ?? null } + async getBufferSnapshot( + id: string, + opts?: { scrollbackRows?: number } + ): Promise { + return await this.adapterFor(id).getBufferSnapshot(id, opts) + } + async clearBuffer(id: string): Promise { await this.adapterFor(id).clearBuffer(id) } @@ -144,7 +169,9 @@ export class DaemonPtyRouter implements IPtyProvider { return this.current.getProfiles() } - onData(callback: (payload: { id: string; data: string }) => void): () => void { + onData( + callback: (payload: { id: string; data: string; sequenceChars?: number }) => void + ): () => void { this.dataListeners.push(callback) return () => { const idx = this.dataListeners.indexOf(callback) @@ -154,6 +181,17 @@ export class DaemonPtyRouter implements IPtyProvider { } } + onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { + const unsubscribes = this.allAdapters().map((adapter) => + adapter.onBackgroundStreamEvent(callback) + ) + return () => { + for (const unsubscribe of unsubscribes) { + unsubscribe() + } + } + } + onReplay(_callback: (payload: { id: string; data: string }) => void): () => void { return () => {} } diff --git a/src/main/daemon/daemon-server.test.ts b/src/main/daemon/daemon-server.test.ts index ae017e0ef02..7de928d7543 100644 --- a/src/main/daemon/daemon-server.test.ts +++ b/src/main/daemon/daemon-server.test.ts @@ -408,6 +408,72 @@ describe('DaemonServer', () => { vi.useRealTimers() } }) + + it('keeps exit behind final output held by the shallow socket gate', async () => { + vi.useFakeTimers() + try { + let subprocess: ReturnType + server = new DaemonServer({ + socketPath, + tokenPath, + spawnSubprocess: () => { + subprocess = createMockSubprocess() + return subprocess + } + }) + const daemon = server as unknown as DaemonServerPrivate + const refillCallbacks: (() => void)[] = [] + const controlSocket = { destroy: vi.fn() } as unknown as Socket + const streamSocket = { + destroyed: false, + destroy: vi.fn(), + writableLength: 128 * 1024, + write: vi.fn((_line: string, callback?: () => void) => { + if (callback) { + refillCallbacks.push(callback) + } + return true + }) + } as unknown as Socket & { + write: ReturnType + writableLength: number + } + + daemon.clients.set('client-1', { + clientId: 'client-1', + controlSocket, + streamSocket + }) + await daemon.routeRequest('client-1', { + id: 'req-1', + type: 'createOrAttach', + payload: { sessionId: 'test-session', cols: 80, rows: 24 } + }) + + const finalOutput = 'final-output'.repeat(1024) + subprocess!._simulateData(finalOutput) + subprocess!._simulateExit(42) + + // Only the refill sentinel may enter the already-deep socket; exit + // remains queued behind the final data for this session. + expect(refillCallbacks).toHaveLength(1) + const beforeRefill = streamSocket.write.mock.calls.map(([line]) => JSON.parse(String(line))) + expect(beforeRefill).toHaveLength(1) + expect(beforeRefill[0]).toMatchObject({ event: 'data', payload: { data: '' } }) + + streamSocket.writableLength = 0 + refillCallbacks[0]() + const delivered = streamSocket.write.mock.calls + .map( + ([line]) => JSON.parse(String(line)) as { event: string; payload: { data?: string } } + ) + .filter((message) => message.payload.data !== '') + expect(delivered.map((message) => message.event)).toEqual(['data', 'exit']) + expect(delivered[0]?.payload.data).toBe(finalOutput) + } finally { + vi.useRealTimers() + } + }) }) describe('authentication', () => { diff --git a/src/main/daemon/daemon-server.ts b/src/main/daemon/daemon-server.ts index 7d98babfdee..7c604341c2c 100644 --- a/src/main/daemon/daemon-server.ts +++ b/src/main/daemon/daemon-server.ts @@ -9,6 +9,15 @@ import { StringDecoder } from 'node:string_decoder' import { encodeNdjson, createNdjsonParser } from './ndjson' import { TerminalHost } from './terminal-host' import { DaemonStreamDataBatcher } from './daemon-stream-data-batcher' +import { + BackgroundTransientFactRelay, + BACKGROUND_STREAM_DROP_ENABLED +} from './daemon-background-transient-facts' +import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' +import { + recordDaemonStreamBacklogEvent, + startDaemonStreamBacklogProbe +} from './daemon-stream-backlog-probe' import { readCurrentProcessMacSystemResolverHealth } from '../network/macos-system-resolver-health' import type { SubprocessHandle } from './session' import { checkPtySpawnHealth } from './pty-subprocess' @@ -53,8 +62,40 @@ export class DaemonServer { private log: DaemonFileLog private clients = new Map() - private streamDataBatcher = new DaemonStreamDataBatcher((clientId) => this.clients.get(clientId)) + private streamDataBatcher = new DaemonStreamDataBatcher( + (clientId) => this.clients.get(clientId), + { + isSessionDroppable: (sessionId) => + BACKGROUND_STREAM_DROP_ENABLED && this.transientFactRelay.isBackgrounded(sessionId), + salvageDroppedData: (dropped) => { + if (!dropped.includes('\x1b')) { + return '' + } + const extracted = extractHiddenStartupRendererQueryData(dropped, '') + return ( + extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData + ) + } + } + ) + // Fact scan authority for backgrounded sessions — facts ride the stream + // queue as control entries so they hold byte order with the data around + // them (a fact jumping the queue could arrive after the reveal snapshot + // that already reflects it). + private transientFactRelay = new BackgroundTransientFactRelay((sessionId, fact) => { + const clientId = this.streamClientIdBySessionId.get(sessionId) + if (clientId) { + this.streamDataBatcher.enqueueControlEvent(clientId, sessionId, { + type: 'event', + event: 'transientFact', + sessionId, + payload: fact + }) + } + }) + private streamClientIdBySessionId = new Map() private lastInputAtBySessionId = new Map() + private stopStreamBacklogProbe: () => void = () => {} // Why: main-process PTY IPC has the same recent-input bypass, but daemon // output reaches main only after this stream layer. Keeping the window here @@ -69,6 +110,14 @@ export class DaemonServer { this.token = randomUUID() this.host = new TerminalHost({ spawnSubprocess: opts.spawnSubprocess }) this.ptySpawnHealthCheck = opts.ptySpawnHealthCheck ?? checkPtySpawnHealth + this.stopStreamBacklogProbe = startDaemonStreamBacklogProbe(() => ({ + clients: Array.from(this.clients.values(), (client) => ({ + clientId: client.clientId, + socketBufferedBytes: client.streamSocket?.writableLength ?? 0, + batcherQueuedChars: this.streamDataBatcher.queuedCharsForClient(client.clientId) + })), + backgroundedSessionIdSuffixes: this.transientFactRelay.backgroundedSessionIdSuffixes() + })) this.log = opts.log ?? createNoopDaemonFileLog() } @@ -97,6 +146,8 @@ export class DaemonServer { } async shutdown(): Promise { + this.stopStreamBacklogProbe() + this.transientFactRelay.dispose() this.host.dispose() this.streamDataBatcher.clear() @@ -225,6 +276,10 @@ export class DaemonServer { const previous = client.streamSocket socket.removeAllListeners('data') client.streamSocket = socket + // Why: 'drain' is the wake-up for the batcher's shallow-gate held bulk. + socket.on('drain', () => { + this.streamDataBatcher.flush(client.clientId) + }) const cleanup = (): void => { socket.removeListener('close', cleanup) @@ -296,6 +351,9 @@ export class DaemonServer { : {}), streamClient: { onData: (data) => { + // Scan BEFORE enqueue: the batcher may keep-tail drop this + // chunk, but its facts must be captured regardless. + this.transientFactRelay.onSessionData(p.sessionId, data) const lastInputAt = this.lastInputAtBySessionId.get(p.sessionId) const isInteractiveOutput = data.length <= DaemonServer.INTERACTIVE_OUTPUT_MAX_CHARS && @@ -307,24 +365,38 @@ export class DaemonServer { }) }, onExit: (code) => { - // Why: exit tears down renderer handlers; flush final output first - // so the last few milliseconds of PTY data are not stranded. + // Why: exit tears down renderer handlers, so it must ride the + // ordered queue behind final output even when the shallow socket + // gate holds that output for a later drain pass. this.log.log('session-exited', { sessionId: p.sessionId, code }) + this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, { + type: 'event', + event: 'exit', + sessionId: p.sessionId, + payload: { code } + }) this.streamDataBatcher.flush(clientId) + recordDaemonStreamBacklogEvent('sessionExit', { + sessionIdSuffix: p.sessionId.slice(-10) + }) + this.transientFactRelay.onSessionExit(p.sessionId) + this.streamClientIdBySessionId.delete(p.sessionId) this.lastInputAtBySessionId.delete(p.sessionId) - if (client?.streamSocket) { - client.streamSocket.write( - encodeNdjson({ - type: 'event', - event: 'exit', - sessionId: p.sessionId, - payload: { code } - }) - ) - } } } }) + this.streamClientIdBySessionId.set(p.sessionId, clientId) + // Why an attach-time marker: the adapter resyncs the background set on + // a fresh connection, which can precede this attach — main's scan + // suppression must still start at the head of the new stream. + if (this.transientFactRelay.isBackgrounded(p.sessionId)) { + this.streamDataBatcher.enqueueControlEvent(clientId, p.sessionId, { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId: p.sessionId, + payload: { background: true } + }) + } this.log.log(result.isNew ? 'session-created' : 'session-attached', { sessionId: p.sessionId, pid: result.pid @@ -365,6 +437,56 @@ export class DaemonServer { } return {} + case 'pausePty': + this.host.pauseProducer(request.payload.sessionId) + return {} + + case 'resumePty': + this.host.resumeProducer(request.payload.sessionId) + return {} + + case 'setSessionBackground': { + const sessionId = request.payload.sessionId + const background = request.payload.background === true + recordDaemonStreamBacklogEvent('setSessionBackground', { + sessionIdSuffix: sessionId.slice(-10), + background + }) + if (!this.transientFactRelay.setSessionBackground(sessionId, background)) { + return {} + } + if (background) { + // Prime the fresh relay tracker with the emulator's dangling + // incomplete escape so a sequence split across the handoff parses + // exactly as if the relay had seen the whole stream. + this.transientFactRelay.seedSessionScanState( + sessionId, + this.host.getPartialEscapeTailAnsi(sessionId) + ) + } + const streamClientId = this.streamClientIdBySessionId.get(sessionId) + if (!streamClientId) { + // Not attached yet — the attach-time marker covers the handoff. + return {} + } + // Reveal deliberately does NOT discard or force-flush the queued + // tail: main's model (hidden-output recovery buffer, tail previews) + // needs those bytes — a finished program's last output lives there — + // and the normal flush/drain loop delivers them within milliseconds + // (bounded ≤ the keep-tail drop cap), in order, ahead of the marker. + const scanSeedAnsi = background ? '' : this.host.getPartialEscapeTailAnsi(sessionId) + this.streamDataBatcher.enqueueControlEvent(streamClientId, sessionId, { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId, + payload: { + background, + ...(scanSeedAnsi.length > 0 ? { scanSeedAnsi } : {}) + } + }) + return {} + } + case 'kill': this.lastInputAtBySessionId.delete(request.payload.sessionId) this.log.log('session-killed', { @@ -397,8 +519,26 @@ export class DaemonServer { case 'listSessions': return { sessions: this.host.listSessions() } - case 'getSnapshot': - return { snapshot: this.host.getSnapshot(request.payload.sessionId) } + case 'getSnapshot': { + const snapshotStart = performance.now() + const requestedScrollbackRows = request.payload.scrollbackRows + const scrollbackRows = + typeof requestedScrollbackRows === 'number' && Number.isFinite(requestedScrollbackRows) + ? Math.max(0, Math.min(50_000, Math.floor(requestedScrollbackRows))) + : undefined + const snapshot = this.host.getSnapshot(request.payload.sessionId, { scrollbackRows }) + const snapshotMs = performance.now() - snapshotStart + if (snapshotMs >= 25) { + // Serialize stalls block the daemon's single thread — every pty's + // echo included. Surfaced here so multi-second typing stalls can be + // attributed to checkpoint storms (issue #5096 family) in the field. + recordDaemonStreamBacklogEvent('slowGetSnapshot', { + sessionIdSuffix: request.payload.sessionId.slice(-10), + snapshotMs: Math.round(snapshotMs) + }) + } + return { snapshot } + } case 'getSize': return { size: this.host.getAppliedSize(request.payload.sessionId) } @@ -449,13 +589,12 @@ export class DaemonServer { // Why: write/resize are notification-heavy and intentionally do not wait // for replies. If their target session is gone, this synthetic exit is the // only signal the renderer gets to clear stale terminal pane bindings. - client.streamSocket.write( - encodeNdjson({ - type: 'event', - event: 'exit', - sessionId, - payload: { code } - }) - ) + this.streamDataBatcher.enqueueControlEvent(client.clientId, sessionId, { + type: 'event', + event: 'exit', + sessionId, + payload: { code } + }) + this.streamDataBatcher.flush(client.clientId) } } diff --git a/src/main/daemon/daemon-stream-backlog-probe.ts b/src/main/daemon/daemon-stream-backlog-probe.ts new file mode 100644 index 00000000000..444d1deb250 --- /dev/null +++ b/src/main/daemon/daemon-stream-backlog-probe.ts @@ -0,0 +1,55 @@ +/** + * Env-gated diagnostics for the daemon→main stream backlog: samples the + * batcher queue and each stream socket's user-space write buffer so + * multi-second echo lag can be attributed to the hop that actually holds the + * bytes. Enable by setting ORCA_DAEMON_STREAM_BACKLOG_FILE to a writable + * path; zero cost otherwise. The timer only observes and appends JSONL — it + * never mutates delivery state. + */ +import { appendFileSync } from 'node:fs' + +export type StreamBacklogClientSample = { + clientId: string + socketBufferedBytes: number + batcherQueuedChars: number +} + +export type StreamBacklogSample = { + clients: StreamBacklogClientSample[] + backgroundedSessionIdSuffixes?: string[] +} + +const SAMPLE_INTERVAL_MS = 250 + +/** Event-level entries interleaved with the periodic samples — used to + * attribute WHO mutated pacing state, not just when counts changed. */ +export function recordDaemonStreamBacklogEvent( + event: string, + detail: Record +): void { + const filePath = process.env.ORCA_DAEMON_STREAM_BACKLOG_FILE + if (!filePath) { + return + } + try { + appendFileSync(filePath, `${JSON.stringify({ atMs: Date.now(), event, ...detail })}\n`) + } catch { + // Diagnostics must never break the daemon. + } +} + +export function startDaemonStreamBacklogProbe(sample: () => StreamBacklogSample): () => void { + const filePath = process.env.ORCA_DAEMON_STREAM_BACKLOG_FILE + if (!filePath) { + return () => {} + } + const timer = setInterval(() => { + try { + appendFileSync(filePath, `${JSON.stringify({ atMs: Date.now(), ...sample() })}\n`) + } catch { + // Diagnostics must never break the daemon. + } + }, SAMPLE_INTERVAL_MS) + timer.unref?.() + return () => clearInterval(timer) +} diff --git a/src/main/daemon/daemon-stream-data-batcher.test.ts b/src/main/daemon/daemon-stream-data-batcher.test.ts index 8777434e1d9..32d85ff87b0 100644 --- a/src/main/daemon/daemon-stream-data-batcher.test.ts +++ b/src/main/daemon/daemon-stream-data-batcher.test.ts @@ -6,12 +6,37 @@ import { createNdjsonParser } from './ndjson' function createBatcher(options?: ConstructorParameters[1]) { const streamSocket = { destroyed: false, + writableLength: 0, write: vi.fn() - } as unknown as Socket & { write: ReturnType } + } as unknown as Socket & { write: ReturnType; writableLength: number } const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket }), options) return { batcher, streamSocket } } +function writtenData(streamSocket: { write: ReturnType }): string { + return streamSocket.write.mock.calls + .map(([line]) => { + const parsed = JSON.parse(String(line)) as { payload?: { data?: string } } + return parsed.payload?.data ?? '' + }) + .join('') +} + +type ParsedWrite = { + event: string + sessionId?: string + payload?: { data?: string } +} + +// A held pass arms a zero-payload data event whose kernel-flush callback +// refills the queue; it carries no content, so assertions about delivered +// output must ignore it. +function nonSentinelWrites(streamSocket: { write: ReturnType }): ParsedWrite[] { + return streamSocket.write.mock.calls + .map(([line]) => JSON.parse(String(line)) as ParsedWrite) + .filter((message) => !(message.event === 'data' && (message.payload?.data ?? '') === '')) +} + describe('DaemonStreamDataBatcher', () => { it('coalesces background output before writing daemon stream events', () => { vi.useFakeTimers() @@ -22,7 +47,7 @@ describe('DaemonStreamDataBatcher', () => { batcher.enqueue('client-1', 'session-1', 'b') expect(streamSocket.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(7) + vi.advanceTimersByTime(1) expect(streamSocket.write).not.toHaveBeenCalled() vi.advanceTimersByTime(1) @@ -45,7 +70,7 @@ describe('DaemonStreamDataBatcher', () => { expect(streamSocket.write).toHaveBeenCalledTimes(1) expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('\\u001b[20;2Hredraw') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(streamSocket.write).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() @@ -65,7 +90,7 @@ describe('DaemonStreamDataBatcher', () => { }) expect(streamSocket.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(streamSocket.write).toHaveBeenCalledTimes(1) expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain(`${pending}redraw`) } finally { @@ -91,7 +116,7 @@ describe('DaemonStreamDataBatcher', () => { ) expect(String(streamSocket.write.mock.calls[0]?.[0])).toContain('"data":"echo"') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(streamSocket.write).toHaveBeenCalledTimes(2) expect(String(streamSocket.write.mock.calls[1]?.[0])).toContain( '"sessionId":"session-background"' @@ -102,6 +127,447 @@ describe('DaemonStreamDataBatcher', () => { } }) + it('holds bulk output while the socket buffer is deep and resumes on the next flush', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const bulk = 'x'.repeat(64 * 1024) + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', bulk) + vi.advanceTimersByTime(2) + expect(nonSentinelWrites(streamSocket)).toHaveLength(0) + expect(batcher.queuedCharsForClient('client-1')).toBe(bulk.length) + + // Socket drained (server routes 'drain' to flush): held bulk writes. + streamSocket.writableLength = 0 + batcher.flush('client-1') + expect(writtenData(streamSocket)).toBe(bulk) + expect(batcher.queuedCharsForClient('client-1')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + + it('lets interactive echo jump bulk held behind a deep socket', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', 'x'.repeat(64 * 1024)) + vi.advanceTimersByTime(2) + expect(nonSentinelWrites(streamSocket)).toHaveLength(0) + + batcher.enqueue('client-1', 'session-typing', 'echo', { + flushImmediately: true, + flushMaxChars: 1024 + }) + const written = nonSentinelWrites(streamSocket) + expect(written).toHaveLength(1) + expect(written[0]?.sessionId).toBe('session-typing') + // The bulk stays held — order across sessions has no contract, and the + // deep socket is exactly what the echo must not queue behind. + expect(batcher.queuedCharsForClient('client-1')).toBe(64 * 1024) + } finally { + vi.useRealTimers() + } + }) + + it('lets a small session write through the gate while a flooding session holds (per-session fairness)', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-flood', 'x'.repeat(256 * 1024)) + // Non-interactive small output (echo that missed the 100ms fast-path). + batcher.enqueue('client-1', 'session-typing', 'echo-line') + vi.advanceTimersByTime(2) + + // The flood holds; the tiny session's bytes must NOT wait behind it. + const written = nonSentinelWrites(streamSocket) + expect(written.some((m) => m.sessionId === 'session-typing')).toBe(true) + expect(writtenData(streamSocket)).toContain('echo-line') + expect(written.some((m) => m.sessionId === 'session-flood')).toBe(false) + expect(batcher.queuedCharsForClient('client-1')).toBe(256 * 1024) + } finally { + vi.useRealTimers() + } + }) + + it('never reorders bytes within a session around the small-session bypass', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + + streamSocket.writableLength = 128 * 1024 + // Same session: big entry then (non-adjacent) small entry. + batcher.enqueue('client-1', 'session-a', 'x'.repeat(256 * 1024)) + batcher.enqueue('client-1', 'session-b', 'other') + batcher.enqueue('client-1', 'session-a', 'tail') + vi.advanceTimersByTime(2) + const written = streamSocket.write.mock.calls.map(([line]) => String(line)).join('') + // session-a held its first entry, so its tail must be held too. + expect(written).not.toContain('tail') + + streamSocket.writableLength = 0 + batcher.flush('client-1') + expect(writtenData(streamSocket)).toContain('x'.repeat(64)) + // Full reassembly, in order, once drained. + const aPayload = streamSocket.write.mock.calls + .map( + ([line]) => + JSON.parse(String(line)) as { sessionId?: string; payload?: { data?: string } } + ) + .filter((m) => m.sessionId === 'session-a') + .map((m) => m.payload?.data ?? '') + .join('') + expect(aPayload).toBe(`${'x'.repeat(256 * 1024)}tail`) + } finally { + vi.useRealTimers() + } + }) + + it('slices oversized held entries so one write cannot re-deepen the socket unboundedly', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const bulk = 'y'.repeat(64 * 1024 + 5) + + batcher.enqueue('client-1', 'session-bulk', bulk) + vi.advanceTimersByTime(2) + // Two slices: 64K then the 5-char remainder; payload reassembles intact. + expect(streamSocket.write).toHaveBeenCalledTimes(2) + expect(writtenData(streamSocket)).toBe(bulk) + } finally { + vi.useRealTimers() + } + }) + + it('stops mid-queue when a written slice is followed by a still-deep socket', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const first = 'a'.repeat(70 * 1024) + + batcher.enqueue('client-1', 'session-bulk', first) + // First slice write fills the socket past the gate; the remainder holds. + streamSocket.write.mockImplementation(() => { + streamSocket.writableLength = 200 * 1024 + return false + }) + vi.advanceTimersByTime(2) + expect(nonSentinelWrites(streamSocket)).toHaveLength(1) + expect(batcher.queuedCharsForClient('client-1')).toBe(first.length - 64 * 1024) + + streamSocket.writableLength = 0 + streamSocket.write.mockImplementation(() => true) + batcher.flush('client-1') + expect(writtenData(streamSocket)).toBe(first) + } finally { + vi.useRealTimers() + } + }) + + it('writes through the gate once held bulk exceeds the memory safety valve', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const huge = 'z'.repeat(32 * 1024 * 1024 + 1) + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', huge) + vi.advanceTimersByTime(2) + // Deep socket, but holding would exceed the valve: old write-through + // behavior wins over bounded echo latency. + expect(writtenData(streamSocket).length).toBeGreaterThan(0) + } finally { + vi.useRealTimers() + } + }) + + it('does not split surrogate pairs at the bulk slice boundary', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + // Position an astral char to straddle the 64K slice boundary. + const bulk = `${'a'.repeat(64 * 1024 - 1)}😀${'b'.repeat(10)}` + + batcher.enqueue('client-1', 'session-bulk', bulk) + vi.advanceTimersByTime(2) + expect(writtenData(streamSocket)).toBe(bulk) + for (const [line] of streamSocket.write.mock.calls) { + expect(String(line)).not.toContain('�') + } + } finally { + vi.useRealTimers() + } + }) + + it('keep-tail drops a droppable session over the cap and delivers a gap before the kept tail', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher({ + isSessionDroppable: (sessionId) => sessionId === 'session-bg' + }) + // Two enqueues that together cross the 1MB cap. + batcher.enqueue('client-1', 'session-bg', 'a'.repeat(900 * 1024)) + batcher.enqueue('client-1', 'session-bg', 'b'.repeat(300 * 1024)) + expect(batcher.queuedCharsForClient('client-1')).toBe(512 * 1024) + vi.advanceTimersByTime(2) + + const messages = streamSocket.write.mock.calls.map( + ([line]) => + JSON.parse(String(line)) as { + event: string + payload: { data?: string; droppedChars?: number } + } + ) + expect(messages[0]?.event).toBe('dataGap') + expect(messages[0]?.payload.droppedChars).toBe((900 + 300 - 512) * 1024) + const delivered = messages + .filter((m) => m.event === 'data') + .map((m) => m.payload.data ?? '') + .join('') + expect(delivered.length).toBe(512 * 1024) + // Keep-TAIL: the newest bytes survive. + expect(delivered.endsWith('b'.repeat(300 * 1024))).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('accounts every dropped char across repeated drops (gap sums + delivered = enqueued)', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher({ isSessionDroppable: () => true }) + // Deep socket: data holds; a gap entry (~100B) may still write through — + // it always precedes the kept tail, so a second drop mints a second gap. + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bg', 'a'.repeat(1024 * 1024 + 1)) + batcher.enqueue('client-1', 'session-bg', 'b'.repeat(600 * 1024)) + batcher.enqueue('client-1', 'session-bg', 'c'.repeat(600 * 1024)) + vi.advanceTimersByTime(2) + + streamSocket.writableLength = 0 + batcher.flush('client-1') + const messages = streamSocket.write.mock.calls.map( + ([line]) => + JSON.parse(String(line)) as { + event: string + payload: { data?: string; droppedChars?: number } + } + ) + const gapChars = messages + .filter((m) => m.event === 'dataGap') + .reduce((sum, m) => sum + (m.payload.droppedChars ?? 0), 0) + const dataMessages = messages.filter((m) => m.event === 'data') + const deliveredChars = dataMessages.reduce((sum, m) => sum + (m.payload.data?.length ?? 0), 0) + expect(deliveredChars).toBeLessThanOrEqual(1024 * 1024) + expect(gapChars + deliveredChars).toBe(1024 * 1024 + 1 + 1200 * 1024) + // The newest bytes always survive. + expect(dataMessages.at(-1)?.payload.data?.endsWith('c')).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('salvages reply-eliciting query bytes out of dropped data', () => { + vi.useFakeTimers() + try { + const dsr = '\x1b[6n' + const { batcher, streamSocket } = createBatcher({ + isSessionDroppable: () => true, + salvageDroppedData: (dropped) => (dropped.includes(dsr) ? dsr : '') + }) + // The DSR probe sits in the oldest (dropped) region. + batcher.enqueue('client-1', 'session-bg', `flood${dsr}${'x'.repeat(900 * 1024)}`) + batcher.enqueue('client-1', 'session-bg', 'y'.repeat(300 * 1024)) + vi.advanceTimersByTime(2) + + const messages = streamSocket.write.mock.calls.map( + ([line]) => + JSON.parse(String(line)) as { + event: string + payload: { data?: string; droppedChars?: number; sequenceChars?: number } + } + ) + expect(messages[0]?.event).toBe('dataGap') + // The salvaged query rides right after the gap, before the kept tail. + expect(messages[1]?.event).toBe('data') + expect(messages[1]?.payload.data).toBe(dsr) + expect(messages[1]?.payload.sequenceChars).toBe(0) + const originalSequenceChars = messages.reduce( + (sum, message) => + sum + + (message.event === 'dataGap' + ? (message.payload.sequenceChars ?? 0) + : (message.payload.sequenceChars ?? message.payload.data?.length ?? 0)), + 0 + ) + expect(originalSequenceChars).toBe(`flood${dsr}${'x'.repeat(900 * 1024)}`.length + 300 * 1024) + } finally { + vi.useRealTimers() + } + }) + + it('shrinks keep-tails as more backgrounded sessions queue (global aggregate budget)', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher({ isSessionDroppable: () => true }) + streamSocket.writableLength = 128 * 1024 // deep socket: queues accumulate + // 17 backgrounded sessions × 768KB — each below the single-session cap, + // but a reveal would have to drain the ~13MB aggregate (measured 2.5s + // hidden-restore). The global budget thins each to ~2MB/17 ≈ 120KB. + for (let s = 0; s < 17; s++) { + batcher.enqueue('client-1', `session-${s}`, '#'.repeat(768 * 1024)) + } + const totalQueued = batcher.queuedCharsForClient('client-1') + expect(totalQueued).toBeLessThan(3 * 1024 * 1024) + // Every session still keeps at least a full screen of newest tail. + streamSocket.writableLength = 0 + batcher.flush('client-1') + const perSession = new Map() + for (const m of nonSentinelWrites(streamSocket)) { + if (m.event === 'data' && m.sessionId) { + perSession.set( + m.sessionId, + (perSession.get(m.sessionId) ?? 0) + (m.payload?.data?.length ?? 0) + ) + } + } + for (let s = 0; s < 17; s++) { + expect(perSession.get(`session-${s}`) ?? 0).toBeGreaterThanOrEqual(64 * 1024) + } + } finally { + vi.useRealTimers() + } + }) + + it('never drops sessions that are not droppable', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher({ isSessionDroppable: () => false }) + const bulk = 'v'.repeat(2 * 1024 * 1024) + batcher.enqueue('client-1', 'session-visible', bulk) + vi.advanceTimersByTime(2) + expect(writtenData(streamSocket)).toBe(bulk) + } finally { + vi.useRealTimers() + } + }) + + it('delivers control events in byte order with the session data around them', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + batcher.enqueue('client-1', 'session-1', 'before') + batcher.enqueueControlEvent('client-1', 'session-1', { + type: 'event', + event: 'transientFact', + sessionId: 'session-1', + payload: { kind: 'bell' } + }) + batcher.enqueue('client-1', 'session-1', 'after') + vi.advanceTimersByTime(2) + + const messages = streamSocket.write.mock.calls.map( + ([line]) => JSON.parse(String(line)) as { event: string; payload: { data?: string } } + ) + expect(messages.map((m) => m.event)).toEqual(['data', 'transientFact', 'data']) + expect(messages[0]?.payload.data).toBe('before') + expect(messages[2]?.payload.data).toBe('after') + } finally { + vi.useRealTimers() + } + }) + + it("holds a control event behind its session's held bulk (order latch)", () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', 'x'.repeat(64 * 1024)) + batcher.enqueueControlEvent('client-1', 'session-bulk', { + type: 'event', + event: 'sessionBackgroundMarker', + sessionId: 'session-bulk', + payload: { background: true } + }) + vi.advanceTimersByTime(2) + expect(nonSentinelWrites(streamSocket)).toHaveLength(0) + + streamSocket.writableLength = 0 + batcher.flush('client-1') + expect(nonSentinelWrites(streamSocket).map((m) => m.event)).toEqual([ + 'data', + 'sessionBackgroundMarker' + ]) + } finally { + vi.useRealTimers() + } + }) + + it('delivers a held queued tail (data + facts, in order) once the socket drains', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bg', 'x'.repeat(64 * 1024)) + batcher.enqueueControlEvent('client-1', 'session-bg', { + type: 'event', + event: 'transientFact', + sessionId: 'session-bg', + payload: { kind: 'bell' } + }) + batcher.enqueue('client-1', 'session-bg', 'DONE') + vi.advanceTimersByTime(2) + + // Reveal never discards: a finished program's last output must reach + // main's model (restore reads it). The normal drain loop delivers it. + streamSocket.writableLength = 0 + batcher.flush('client-1') + expect(batcher.queuedCharsForClient('client-1')).toBe(0) + const messages = nonSentinelWrites(streamSocket) + expect(messages.map((m) => m.event)).toEqual(['data', 'transientFact', 'data']) + expect(messages.at(-1)?.payload?.data).toBe('DONE') + } finally { + vi.useRealTimers() + } + }) + + it('arms one kernel-flush refill sentinel per held pass and resumes without waiting for drain', () => { + vi.useFakeTimers() + try { + const { batcher, streamSocket } = createBatcher() + const flushCallbacks: (() => void)[] = [] + streamSocket.write.mockImplementation((_line: string, cb?: () => void) => { + if (cb) { + flushCallbacks.push(cb) + } + return true + }) + + streamSocket.writableLength = 128 * 1024 + batcher.enqueue('client-1', 'session-bulk', 'x'.repeat(64 * 1024)) + vi.advanceTimersByTime(2) + // Held pass → exactly one sentinel armed; further held passes don't stack. + expect(flushCallbacks).toHaveLength(1) + batcher.flush('client-1') + expect(flushCallbacks).toHaveLength(1) + + // Kernel flushed the in-flight bytes (socket now shallow): the sentinel + // callback resumes the held bulk with no 'drain' event involved. + streamSocket.writableLength = 0 + flushCallbacks[0]() + expect(writtenData(streamSocket)).toBe('x'.repeat(64 * 1024)) + expect(batcher.queuedCharsForClient('client-1')).toBe(0) + } finally { + vi.useRealTimers() + } + }) + it('writes large stream data as parser-sized NDJSON events', () => { vi.useFakeTimers() try { @@ -113,7 +579,7 @@ describe('DaemonStreamDataBatcher', () => { const parser = createNdjsonParser(onMessage, onError, { maxLineBytes }) batcher.enqueue('client-1', 'session-1', data) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (const [line] of streamSocket.write.mock.calls) { parser.feed(String(line)) } diff --git a/src/main/daemon/daemon-stream-data-batcher.ts b/src/main/daemon/daemon-stream-data-batcher.ts index 3374222f17f..84f7465b913 100644 --- a/src/main/daemon/daemon-stream-data-batcher.ts +++ b/src/main/daemon/daemon-stream-data-batcher.ts @@ -1,19 +1,63 @@ import type { Socket } from 'node:net' import { encodeNdjson, NDJSON_MAX_LINE_BYTES } from './ndjson' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import { + clampToSafeSplitIndex, + encodeStreamDataEvent, + writeStreamDataEvents +} from './daemon-stream-data-split' +import { + backgroundSessionDropCapChars, + backgroundSessionKeepTailChars, + dropOldestQueuedForSession, + type PendingStreamDataBatch +} from './daemon-stream-keep-tail-drop' +import type { DaemonEvent } from './types' type StreamDataClient = { streamSocket: Socket | null } -type PendingStreamDataBatch = { - timer: ReturnType | null - queue: { sessionId: string; data: string }[] - queuedChars: number -} +// Why 2ms: under continuous agent output every chunk waits an expected +// half-window here AND again in main's PTY batch — at 8ms each that was +// ~8ms of the measured ~19ms DSR-under-load latency. 2ms keeps burst +// coalescing (~500 socket writes/s worst case, ~100B framing overhead per +// write against MB/s payloads) while cutting the fixed latency tax 4x. +const STREAM_DATA_BATCH_INTERVAL_MS = 2 -// Why: match main-process PTY IPC batching to avoid adding latency while -// removing daemon socket writes and JSON framing during bursty output. -const STREAM_DATA_BATCH_INTERVAL_MS = 8 +// Why a shallow socket: the stream socket is one FIFO for every session, and +// bytes already written can never be overtaken — a deep user-space buffer +// buries a visible pane's keystroke echo behind bulk output for other panes +// (measured 192MB / 6+s under 12 flooding hidden agents). Bulk writes stop at +// this depth and the remainder is HELD here, where the interactive +// flushSession path can still jump it; socket 'drain' refills. Echo latency +// is then bounded by the shallow depth, not by how much bulk is in flight. +// 128KB must stay above the socket's ~16KB highWaterMark so a held state +// implies a false write() and therefore a guaranteed 'drain' wake-up. +// Kill switch: ORCA_DAEMON_SHALLOW_SOCKET_GATE=0 restores pre-gate unbounded +// socket writes for field debugging and true fix-off A/B benches. +const SHALLOW_SOCKET_WRITE_GATE_BYTES = + process.env.ORCA_DAEMON_SHALLOW_SOCKET_GATE === '0' ? Number.POSITIVE_INFINITY : 128 * 1024 +// Why sliced writes: enqueue coalesces per-session entries, so a held entry +// can grow to megabytes; writing it whole would re-deepen the socket past the +// gate in one call. +const BULK_WRITE_SLICE_CHARS = 64 * 1024 +// Safety valve: if held bulk ever exceeds this, write through to the socket +// (exactly the pre-gate behavior) — bounded daemon memory beats bounded echo +// latency in the extreme. Must sit FAR above the pacer's pause watermark plus +// its overshoot (observed ~5MB with 17 paused sessions' in-flight pty reads): +// an engaged valve deepens the socket and buries interactive echo behind the +// whole backlog (measured as bimodal ~2.4s key medians when this was 8MB). +const HELD_WRITE_THROUGH_TOTAL_CHARS = 32 * 1024 * 1024 +// Why a small-session bypass: the hold is there to stop FLOODS from burying +// everyone else; a session with only a few KB queued (keystroke echo, prompt +// redraws, query replies) is never the flood and must not wait FIFO behind +// other sessions' megabytes. The daemon's 100ms interactive fast-path is a +// heuristic that misses under event-loop load (measured: echo classified +// non-interactive rode the held queue for ~2.4s); this bypass is the +// deterministic backstop. Worst socket over-deepening per flush is +// sessions × this ≈ tens of KB. +const SMALL_SESSION_HOLD_BYPASS_CHARS = 4 * 1024 type EnqueueOptions = { flushImmediately?: boolean @@ -22,90 +66,25 @@ type EnqueueOptions = { type DaemonStreamDataBatcherOptions = { maxLineBytes?: number -} - -function encodeStreamDataEvent(sessionId: string, data: string): string { - return encodeNdjson({ - type: 'event', - event: 'data', - sessionId, - payload: { data } - }) -} - -function streamDataEventLineBytes(sessionId: string, data: string): number { - return Buffer.byteLength(encodeStreamDataEvent(sessionId, data), 'utf8') -} - -function isHighSurrogate(value: number): boolean { - return value >= 0xd800 && value <= 0xdbff -} - -function isLowSurrogate(value: number): boolean { - return value >= 0xdc00 && value <= 0xdfff -} - -function clampToSafeSplitIndex(value: string, start: number, end: number): number { - if (end <= start || end >= value.length) { - return end - } - const prev = value.charCodeAt(end - 1) - const next = value.charCodeAt(end) - return isHighSurrogate(prev) && isLowSurrogate(next) ? end - 1 : end -} - -function nextSafeSplitIndex(value: string, start: number): number { - const next = Math.min(value.length, start + 1) - if ( - next < value.length && - isHighSurrogate(value.charCodeAt(start)) && - isLowSurrogate(value.charCodeAt(next)) - ) { - return next + 1 - } - return next -} - -function splitStreamDataForNdjson(sessionId: string, data: string, maxLineBytes: number): string[] { - if (streamDataEventLineBytes(sessionId, data) <= maxLineBytes) { - return [data] - } - - const chunks: string[] = [] - let start = 0 - while (start < data.length) { - let low = start + 1 - let high = data.length - let best = start - - while (low <= high) { - const rawMid = Math.floor((low + high) / 2) - const mid = clampToSafeSplitIndex(data, start, rawMid) - if (mid <= start) { - low = rawMid + 1 - continue - } - - if (streamDataEventLineBytes(sessionId, data.slice(start, mid)) <= maxLineBytes) { - best = mid - low = rawMid + 1 - } else { - high = rawMid - 1 - } - } - - const end = best > start ? best : nextSafeSplitIndex(data, start) - chunks.push(data.slice(start, end)) - start = end - } - - return chunks + /** Fires after each stream-socket write — the only place backlog grows, so + * the backlog pacer checks its watermark here. */ + onAfterSocketWrite?: () => void + /** True for sessions whose queued output may be keep-tail dropped + * (main-marked background sessions). */ + isSessionDroppable?: (sessionId: string) => boolean + /** Carve reply-eliciting query bytes (DSR/DA/DECRQM/OSC color probes) out + * of dropped data — the hidden program blocks on the reply, so those few + * bytes must still be delivered even when their flood is not. */ + salvageDroppedData?: (dropped: string) => string } export class DaemonStreamDataBatcher { private pendingByClient = new Map() private getClient: (clientId: string) => StreamDataClient | undefined private maxLineBytes: number + private onAfterSocketWrite: (() => void) | undefined + private isSessionDroppable: (sessionId: string) => boolean + private salvageDroppedData: (dropped: string) => string constructor( getClient: (clientId: string) => StreamDataClient | undefined, @@ -113,6 +92,9 @@ export class DaemonStreamDataBatcher { ) { this.getClient = getClient this.maxLineBytes = Math.max(1, options.maxLineBytes ?? NDJSON_MAX_LINE_BYTES) + this.onAfterSocketWrite = options.onAfterSocketWrite + this.isSessionDroppable = options.isSessionDroppable ?? (() => false) + this.salvageDroppedData = options.salvageDroppedData ?? (() => '') } enqueue(clientId: string, sessionId: string, data: string, options: EnqueueOptions = {}): void { @@ -121,19 +103,45 @@ export class DaemonStreamDataBatcher { return } - let batch = this.pendingByClient.get(clientId) - if (!batch) { - batch = { timer: null, queue: [], queuedChars: 0 } - this.pendingByClient.set(clientId, batch) - } - + const batch = this.getOrCreateBatch(clientId) const last = batch.queue.at(-1) - if (last?.sessionId === sessionId) { + // Never coalesce across a control entry — it marks a position in the + // session's byte order. + if (last?.sessionId === sessionId && !last.control) { last.data += data } else { batch.queue.push({ sessionId, data }) } batch.queuedChars += data.length + batch.queuedCharsBySession.set( + sessionId, + (batch.queuedCharsBySession.get(sessionId) ?? 0) + data.length + ) + + if (this.isSessionDroppable(sessionId)) { + // Keep-tail scales down as more backgrounded sessions queue, bounding + // the AGGREGATE a reveal must drain (see daemon-stream-keep-tail-drop). + const droppableQueued = this.countDroppableSessionsWithQueuedData(batch) + const dropCap = backgroundSessionDropCapChars(droppableQueued) + const keepTail = backgroundSessionKeepTailChars(droppableQueued) + if ((batch.queuedCharsBySession.get(sessionId) ?? 0) > dropCap) { + dropOldestQueuedForSession(batch, sessionId, keepTail, this.salvageDroppedData) + } + if (droppableQueued > (batch.lastDroppableSessionCount ?? 0)) { + // The shared budget tightened: re-trim sessions that already finished + // producing — they never re-enter this path on their own. + for (const [queuedSessionId, queued] of Array.from(batch.queuedCharsBySession)) { + if ( + queued > dropCap && + queuedSessionId !== sessionId && + this.isSessionDroppable(queuedSessionId) + ) { + dropOldestQueuedForSession(batch, queuedSessionId, keepTail, this.salvageDroppedData) + } + } + } + batch.lastDroppableSessionCount = droppableQueued + } if ( options.flushImmediately === true && @@ -148,6 +156,43 @@ export class DaemonStreamDataBatcher { } } + /** Append a pre-shaped stream event at the current position in the + * session's byte order (scan handoff markers, gaps, transient facts). */ + enqueueControlEvent(clientId: string, sessionId: string, control: DaemonEvent): void { + const client = this.getClient(clientId) + if (!client?.streamSocket || client.streamSocket.destroyed) { + return + } + const batch = this.getOrCreateBatch(clientId) + batch.queue.push({ sessionId, data: '', control }) + if (!batch.timer) { + batch.timer = setTimeout(() => this.flush(clientId), STREAM_DATA_BATCH_INTERVAL_MS) + } + } + + private countDroppableSessionsWithQueuedData(batch: PendingStreamDataBatch): number { + let count = 0 + for (const [sessionId, queued] of batch.queuedCharsBySession) { + if (queued > 0 && this.isSessionDroppable(sessionId)) { + count++ + } + } + return count + } + + private getOrCreateBatch(clientId: string): PendingStreamDataBatch { + let batch = this.pendingByClient.get(clientId) + if (!batch) { + batch = { timer: null, queue: [], queuedChars: 0, queuedCharsBySession: new Map() } + this.pendingByClient.set(clientId, batch) + } + return batch + } + + queuedCharsForClient(clientId: string): number { + return this.pendingByClient.get(clientId)?.queuedChars ?? 0 + } + flush(clientId: string): void { const batch = this.pendingByClient.get(clientId) if (!batch) { @@ -158,16 +203,113 @@ export class DaemonStreamDataBatcher { clearTimeout(batch.timer) batch.timer = null } - this.pendingByClient.delete(clientId) const client = this.getClient(clientId) if (!client?.streamSocket || client.streamSocket.destroyed) { + // Same as pre-gate behavior: a vanished stream socket drops the batch — + // the model owns the bytes and reconnect restores from a snapshot. + this.pendingByClient.delete(clientId) return } - for (const entry of batch.queue) { - this.writeStreamDataEvent(client.streamSocket, entry.sessionId, entry.data) + const socket = client.streamSocket + // Sessions that held an entry must hold ALL their later entries in this + // pass — writing around a held entry would reorder that session's bytes. + const heldSessions = new Set() + const retained: PendingStreamDataBatch['queue'] = [] + while (batch.queue.length > 0) { + const entry = batch.queue[0] + if (entry.control) { + // Control entries only respect the held-session order latch — they + // are ~100B, so writing them onto a deep socket is as harmless as the + // small-session bypass. + if (heldSessions.has(entry.sessionId)) { + retained.push(entry) + batch.queue.shift() + continue + } + batch.queue.shift() + socket.write(encodeNdjson(entry.control)) + this.onAfterSocketWrite?.() + continue + } + const socketDeep = (socket.writableLength ?? 0) >= SHALLOW_SOCKET_WRITE_GATE_BYTES + if (socketDeep && batch.queuedChars <= HELD_WRITE_THROUGH_TOTAL_CHARS) { + const sessionHeld = batch.queuedCharsBySession.get(entry.sessionId) ?? 0 + if (heldSessions.has(entry.sessionId) || sessionHeld > SMALL_SESSION_HOLD_BYPASS_CHARS) { + // Hold this flooding session's entry; small talkers keep flowing. + // The socket's 'drain' (routed back to flush by the server) + // resumes held bulk. No timer: a deep socket implies a prior + // false write(), so 'drain' is guaranteed. + heldSessions.add(entry.sessionId) + retained.push(entry) + batch.queue.shift() + continue + } + } else if (socketDeep) { + // Valve engaged: held bulk exceeded the memory cap and is being + // written through onto a deep socket — echo protection is off until + // it drains. Rare enough to be worth a diagnostics event every time. + recordDaemonStreamBacklogEvent('heldWriteThrough', { + heldChars: batch.queuedChars, + socketBufferedBytes: socket.writableLength ?? 0 + }) + } + const end = + entry.data.length <= BULK_WRITE_SLICE_CHARS + ? entry.data.length + : clampToSafeSplitIndex(entry.data, 0, BULK_WRITE_SLICE_CHARS) + const slice = entry.data.slice(0, end) + const entrySequenceChars = entry.sequenceChars ?? entry.data.length + const sliceSequenceChars = entrySequenceChars === 0 ? 0 : slice.length + if (end >= entry.data.length) { + batch.queue.shift() + } else { + entry.data = entry.data.slice(end) + const remainingSequenceChars = entrySequenceChars - sliceSequenceChars + entry.sequenceChars = + remainingSequenceChars === entry.data.length ? undefined : remainingSequenceChars + } + batch.queuedChars -= slice.length + const sessionHeldAfter = + (batch.queuedCharsBySession.get(entry.sessionId) ?? slice.length) - slice.length + if (sessionHeldAfter <= 0) { + batch.queuedCharsBySession.delete(entry.sessionId) + } else { + batch.queuedCharsBySession.set(entry.sessionId, sessionHeldAfter) + } + writeStreamDataEvents(socket, entry.sessionId, slice, this.maxLineBytes, sliceSequenceChars) + this.onAfterSocketWrite?.() } + if (retained.length > 0) { + batch.queue = retained + // Held entries must not wait for the socket's 'drain' alone: drain only + // fires when the user-space buffer fully EMPTIES, so bulk would advance + // one gate-depth per daemon event-loop turn — seconds of dead time for + // a multi-MB hidden backlog on a busy daemon (measured: hidden-restore + // 2.5s vs the 1.5s budget). Arm ONE ~90B empty data event whose + // kernel-flush callback re-flushes while bytes are still in flight, so + // main never starves. (An empty socket write's callback fires + // immediately — verified — so the sentinel must be a real protocol + // no-op line.) Event-driven, no timers; the per-client latch stops + // sentinel stacking; 'drain' remains the backstop. + this.armHeldQueueRefill(socket, clientId, retained[0].sessionId) + return + } + this.pendingByClient.delete(clientId) + } + + private refillArmedClients = new Set() + + private armHeldQueueRefill(socket: Socket, clientId: string, sessionId: string): void { + if (this.refillArmedClients.has(clientId) || socket.destroyed) { + return + } + this.refillArmedClients.add(clientId) + socket.write(encodeStreamDataEvent(sessionId, ''), () => { + this.refillArmedClients.delete(clientId) + this.flush(clientId) + }) } private queuedCharsForSession(batch: PendingStreamDataBatch, sessionId: string): number { @@ -203,6 +345,7 @@ export class DaemonStreamDataBatcher { batch.queue = retained batch.queuedChars -= flushedChars + batch.queuedCharsBySession.delete(sessionId) if (batch.queue.length === 0) { if (batch.timer) { clearTimeout(batch.timer) @@ -217,7 +360,19 @@ export class DaemonStreamDataBatcher { } for (const entry of flushed) { - this.writeStreamDataEvent(client.streamSocket, entry.sessionId, entry.data) + if (entry.control) { + client.streamSocket.write(encodeNdjson(entry.control)) + this.onAfterSocketWrite?.() + } else { + writeStreamDataEvents( + client.streamSocket, + entry.sessionId, + entry.data, + this.maxLineBytes, + entry.sequenceChars ?? entry.data.length + ) + this.onAfterSocketWrite?.() + } } } @@ -234,13 +389,4 @@ export class DaemonStreamDataBatcher { this.pendingByClient.delete(id) } } - - private writeStreamDataEvent(streamSocket: Socket, sessionId: string, data: string): void { - // Why: createNdjsonParser rejects oversized lines. Terminal output can - // burst faster than the batch interval, so writer-side chunking prevents - // the daemon from dropping its own stream events at the receiver. - for (const chunk of splitStreamDataForNdjson(sessionId, data, this.maxLineBytes)) { - streamSocket.write(encodeStreamDataEvent(sessionId, chunk)) - } - } } diff --git a/src/main/daemon/daemon-stream-data-split.ts b/src/main/daemon/daemon-stream-data-split.ts new file mode 100644 index 00000000000..0e15cf49523 --- /dev/null +++ b/src/main/daemon/daemon-stream-data-split.ts @@ -0,0 +1,114 @@ +/** + * Surrogate-safe splitting for daemon stream data events: NDJSON line-size + * chunking (the receiver's parser rejects oversized lines) and the safe-index + * clamp shared by the batcher's bulk write slicing and keep-tail dropping. + */ +import { encodeNdjson } from './ndjson' +import type { Socket } from 'node:net' + +export function encodeStreamDataEvent( + sessionId: string, + data: string, + sequenceChars?: number +): string { + return encodeNdjson({ + type: 'event', + event: 'data', + sessionId, + payload: { data, ...(sequenceChars === undefined ? {} : { sequenceChars }) } + }) +} + +function streamDataEventLineBytes(sessionId: string, data: string, sequenceChars?: number): number { + return Buffer.byteLength(encodeStreamDataEvent(sessionId, data, sequenceChars), 'utf8') +} + +function isHighSurrogate(value: number): boolean { + return value >= 0xd800 && value <= 0xdbff +} + +function isLowSurrogate(value: number): boolean { + return value >= 0xdc00 && value <= 0xdfff +} + +export function clampToSafeSplitIndex(value: string, start: number, end: number): number { + if (end <= start || end >= value.length) { + return end + } + const prev = value.charCodeAt(end - 1) + const next = value.charCodeAt(end) + return isHighSurrogate(prev) && isLowSurrogate(next) ? end - 1 : end +} + +function nextSafeSplitIndex(value: string, start: number): number { + const next = Math.min(value.length, start + 1) + if ( + next < value.length && + isHighSurrogate(value.charCodeAt(start)) && + isLowSurrogate(value.charCodeAt(next)) + ) { + return next + 1 + } + return next +} + +export function splitStreamDataForNdjson( + sessionId: string, + data: string, + maxLineBytes: number, + sequenceChars?: number +): string[] { + if (streamDataEventLineBytes(sessionId, data, sequenceChars) <= maxLineBytes) { + return [data] + } + + const chunks: string[] = [] + let start = 0 + while (start < data.length) { + let low = start + 1 + let high = data.length + let best = start + + while (low <= high) { + const rawMid = Math.floor((low + high) / 2) + const mid = clampToSafeSplitIndex(data, start, rawMid) + if (mid <= start) { + low = rawMid + 1 + continue + } + + if ( + streamDataEventLineBytes(sessionId, data.slice(start, mid), sequenceChars) <= maxLineBytes + ) { + best = mid + low = rawMid + 1 + } else { + high = rawMid - 1 + } + } + + const end = best > start ? best : nextSafeSplitIndex(data, start) + chunks.push(data.slice(start, end)) + start = end + } + + return chunks +} + +export function writeStreamDataEvents( + streamSocket: Pick, + sessionId: string, + data: string, + maxLineBytes: number, + sequenceChars = data.length +): void { + const explicitSequenceChars = sequenceChars === data.length ? undefined : sequenceChars + for (const chunk of splitStreamDataForNdjson( + sessionId, + data, + maxLineBytes, + explicitSequenceChars + )) { + streamSocket.write(encodeStreamDataEvent(sessionId, chunk, explicitSequenceChars)) + } +} diff --git a/src/main/daemon/daemon-stream-events.ts b/src/main/daemon/daemon-stream-events.ts new file mode 100644 index 00000000000..1644642bd09 --- /dev/null +++ b/src/main/daemon/daemon-stream-events.ts @@ -0,0 +1,78 @@ +// ─── Events (Daemon → Client, on stream socket) ──────────────────── +import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' + +export type DataEvent = { + type: 'event' + event: 'data' + sessionId: string + payload: { data: string; sequenceChars?: number } +} + +export type ExitEvent = { + type: 'event' + event: 'exit' + sessionId: string + payload: { code: number } +} + +export type TerminalErrorEvent = { + type: 'event' + event: 'terminalError' + sessionId: string + payload: { message: string } +} + +// Why these ride the stream socket (not control): each marks a POSITION in a +// session's byte stream — scan-authority handoffs and dropped ranges are only +// meaningful relative to the data events around them. Old mains ignore +// unknown stream events, and only new mains send setSessionBackground. The +// v20 bump is for sequence-safe recovery snapshots, not these tolerated events. + +/** Scan-authority handoff marker: bytes before this event were (not) scanned + * by the daemon's transient-fact relay; main flips its own scanners at + * exactly this position so no fact double-fires or goes missing. + * scanSeedAnsi (un-background only) carries the emulator's dangling + * incomplete escape so main can prime its fresh scanner carry — a sequence + * split across the handoff must not mint a phantom bell or lose its fact. */ +export type SessionBackgroundMarkerEvent = { + type: 'event' + event: 'sessionBackgroundMarker' + sessionId: string + payload: { background: boolean; scanSeedAnsi?: string } +} + +/** A backgrounded session's oldest undelivered output was dropped at the + * daemon (keep-tail thinning). The daemon emulator ingested every byte — + * only this monitoring stream is thinned. */ +export type DataGapEvent = { + type: 'event' + event: 'dataGap' + sessionId: string + payload: { droppedChars: number; sequenceChars?: number } +} + +/** Notification-bearing fact detected by the daemon while it holds scan + * authority for a backgrounded session. Title/agent-status facts stay + * main-side: they converge from the kept tail (stale-working-title timer, + * snapshot-restores-title-state) and fuse with main-fabricated synthetic + * frames the daemon never sees. */ +export type DaemonTransientFact = + | { kind: 'bell' } + | { kind: 'command-finished'; exitCode: number | null } + | { kind: 'pr-link'; link: TerminalGitHubPRLink } + | { kind: '2031-subscribe' } + +export type TransientFactEvent = { + type: 'event' + event: 'transientFact' + sessionId: string + payload: DaemonTransientFact +} + +export type DaemonEvent = + | DataEvent + | ExitEvent + | TerminalErrorEvent + | SessionBackgroundMarkerEvent + | DataGapEvent + | TransientFactEvent diff --git a/src/main/daemon/daemon-stream-keep-tail-drop.ts b/src/main/daemon/daemon-stream-keep-tail-drop.ts new file mode 100644 index 00000000000..0dc57e53522 --- /dev/null +++ b/src/main/daemon/daemon-stream-keep-tail-drop.ts @@ -0,0 +1,182 @@ +/** + * Keep-tail thinning for backgrounded sessions' queued stream data. Hidden + * panes' stream copy is a monitoring feed (tail previews, agent status) — the + * daemon emulator holds the complete model and reveal restores from its + * snapshot. Once a backgrounded session's undelivered output exceeds the cap, + * its OLDEST bytes are dropped down to the keep-tail and a dataGap event + * takes their place, so the feed stays tail-fresh, daemon memory stays + * bounded, and the producer is never paused (no reveal catch-up). + */ +import { clampToSafeSplitIndex } from './daemon-stream-data-split' +import { recordDaemonStreamBacklogEvent } from './daemon-stream-backlog-probe' +import type { DaemonEvent, DataGapEvent } from './types' + +// A control entry carries a whole pre-shaped stream event (background marker, +// data gap, transient fact) that must ride at its exact position in the +// session's byte order; its data is always '' so it never counts against the +// gate or drop caps, and drops never remove it. +export type StreamQueueEntry = { + sessionId: string + data: string + /** Original PTY characters represented by data. Salvaged query copies are + * delivered bytes but represent zero new positions in the source stream. */ + sequenceChars?: number + control?: DaemonEvent +} + +export type PendingStreamDataBatch = { + timer: ReturnType | null + queue: StreamQueueEntry[] + queuedChars: number + // Per-session held totals so the flush hold can spare small talkers + // (echo/replies) from waiting behind other sessions' floods. + queuedCharsBySession: Map + // Last droppable-sessions-with-queued-data count seen by the keep-tail + // logic: when it GROWS the shared budget tightens, and sessions that + // finished producing must be re-trimmed (they will never re-enqueue). + lastDroppableSessionCount?: number +} + +// The keep-tail must comfortably cover a full TUI repaint (~cols×rows×SGR ≈ +// 100KB) so the delivered tail always re-renders a coherent screen. +// Hysteresis (cap = 2× keep) bounds drop churn. +// Kill switch: ORCA_DAEMON_BACKGROUND_STREAM_DROP=0 disables thinning. +const BACKGROUND_SESSION_KEEP_TAIL_CHARS = 512 * 1024 +const BACKGROUND_SESSION_MIN_KEEP_TAIL_CHARS = 64 * 1024 +// Why a GLOBAL budget too: the per-session cap bounds each flood, but N +// backgrounded sessions can still queue N×cap in aggregate — and a reveal +// (worktree switch) then waits behind the whole aggregate at the gated drain +// rate (~8MB/s event-loop-turn-bound; measured 9MB queued → 2.5s hidden +// restore vs the 1.5s budget). Shrinking each session's keep-tail as more +// backgrounded sessions queue keeps the total ~2MB, so any reveal drains in +// ~250ms while every pane still keeps at least a full screen of tail. +const BACKGROUND_GLOBAL_KEEP_BUDGET_CHARS = 2 * 1024 * 1024 + +export function backgroundSessionKeepTailChars(droppableSessionsWithQueuedData: number): number { + return Math.min( + BACKGROUND_SESSION_KEEP_TAIL_CHARS, + Math.max( + BACKGROUND_SESSION_MIN_KEEP_TAIL_CHARS, + Math.floor(BACKGROUND_GLOBAL_KEEP_BUDGET_CHARS / Math.max(1, droppableSessionsWithQueuedData)) + ) + ) +} + +export function backgroundSessionDropCapChars(droppableSessionsWithQueuedData: number): number { + return backgroundSessionKeepTailChars(droppableSessionsWithQueuedData) * 2 +} +// Mirrors main's DROPPED_QUERY_SALVAGE_MAX_CHARS: salvage past this means a +// pathological query stream; keep the O(1) memory guarantee. A prior drop's +// salvage entry is itself the oldest data and re-salvages through the next +// drop, so query order is preserved across repeated drops. +const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096 + +/** Trim the session's OLDEST queued data down to the keep-tail and leave (or + * grow) a dataGap control entry where the dropped bytes were. Control + * entries are never dropped. Boundary note: the kept tail can start + * mid-escape-sequence — deliberate; the receiver treats a gap as a + * tail-preview reset and transient-fact scanning is daemon-authoritative + * for droppable sessions, so nothing downstream parses across the cut. */ +export function dropOldestQueuedForSession( + batch: PendingStreamDataBatch, + sessionId: string, + keepTailChars: number, + salvageDroppedData: (dropped: string) => string +): void { + let toDrop = (batch.queuedCharsBySession.get(sessionId) ?? 0) - keepTailChars + if (toDrop <= 0) { + return + } + const totalDropped = toDrop + let droppedSequenceChars = 0 + let salvaged = '' + const salvageIntoCap = (dropped: string): void => { + if (salvaged.length >= DROPPED_QUERY_SALVAGE_MAX_CHARS) { + return + } + salvaged = (salvaged + salvageDroppedData(dropped)).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS) + } + let existingGap: DataGapEvent | null = null + let insertGapAt = -1 + for (let i = 0; i < batch.queue.length && toDrop > 0; i++) { + const entry = batch.queue[i] + if (entry.sessionId !== sessionId) { + continue + } + if (entry.control) { + if (entry.control.event === 'dataGap') { + existingGap = entry.control + } + continue + } + if (entry.data.length <= toDrop) { + toDrop -= entry.data.length + droppedSequenceChars += entry.sequenceChars ?? entry.data.length + salvageIntoCap(entry.data) + if (insertGapAt === -1) { + insertGapAt = i + } + batch.queue.splice(i, 1) + i-- + } else { + const cut = clampToSafeSplitIndex(entry.data, 0, toDrop) + if (cut > 0) { + const entrySequenceChars = entry.sequenceChars ?? entry.data.length + const cutSequenceChars = entrySequenceChars === 0 ? 0 : cut + droppedSequenceChars += cutSequenceChars + salvageIntoCap(entry.data.slice(0, cut)) + entry.data = entry.data.slice(cut) + const remainingSequenceChars = entrySequenceChars - cutSequenceChars + entry.sequenceChars = + remainingSequenceChars === entry.data.length ? undefined : remainingSequenceChars + if (insertGapAt === -1) { + insertGapAt = i + } + } + toDrop = 0 + } + } + const dropped = totalDropped - toDrop + if (dropped <= 0) { + return + } + batch.queuedChars -= dropped + batch.queuedCharsBySession.set( + sessionId, + Math.max(0, (batch.queuedCharsBySession.get(sessionId) ?? 0) - dropped) + ) + if (existingGap) { + const priorSequenceChars = existingGap.payload.sequenceChars ?? existingGap.payload.droppedChars + existingGap.payload.droppedChars += dropped + existingGap.payload.sequenceChars = priorSequenceChars + droppedSequenceChars + } else { + recordDaemonStreamBacklogEvent('backgroundKeepTailDrop', { + sessionIdSuffix: sessionId.slice(-10), + droppedChars: dropped + }) + batch.queue.splice(Math.max(0, insertGapAt), 0, { + sessionId, + data: '', + control: { + type: 'event', + event: 'dataGap', + sessionId, + payload: { droppedChars: dropped, sequenceChars: droppedSequenceChars } + } + }) + insertGapAt = Math.max(0, insertGapAt) + 1 + } + if (salvaged.length > 0) { + // Salvaged query bytes ride as a tiny data entry at the gap position — + // the writing program is blocked on their replies. + const at = existingGap + ? batch.queue.findIndex((e) => e.control === existingGap) + 1 + : insertGapAt + batch.queue.splice(at, 0, { sessionId, data: salvaged, sequenceChars: 0 }) + batch.queuedChars += salvaged.length + batch.queuedCharsBySession.set( + sessionId, + (batch.queuedCharsBySession.get(sessionId) ?? 0) + salvaged.length + ) + } +} diff --git a/src/main/daemon/degraded-daemon-fallback-shutdown.ts b/src/main/daemon/degraded-daemon-fallback-shutdown.ts new file mode 100644 index 00000000000..0d4932d6c20 --- /dev/null +++ b/src/main/daemon/degraded-daemon-fallback-shutdown.ts @@ -0,0 +1,25 @@ +import type { IPtyProvider } from '../providers/types' + +export async function shutdownDegradedFallbackSessions( + sessionProviders: Map, + fallback: T +): Promise { + const ids = [...sessionProviders] + .filter(([, provider]) => provider === fallback) + .map(([id]) => id) + const results = await Promise.allSettled( + ids.map(async (id) => { + await fallback.shutdown(id, { immediate: true }) + sessionProviders.delete(id) + }) + ) + // Why: fallback cleanup must not abort the user's daemon-restart recovery path. + const failed = results.filter((result) => result.status === 'rejected') + if (failed.length > 0) { + console.warn( + `[daemon] ${failed.length} local fallback PTY session(s) failed to shut down during daemon restart; continuing restart`, + ...failed.map((result) => (result as PromiseRejectedResult).reason) + ) + } + return results.length - failed.length +} diff --git a/src/main/daemon/degraded-daemon-pty-provider.test.ts b/src/main/daemon/degraded-daemon-pty-provider.test.ts index 7bd656fc6fb..fb0b5087f15 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.test.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.test.ts @@ -4,13 +4,14 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' import type { IPtyProvider, PtySpawnOptions, PtySpawnResult } from '../providers/types' type ProviderMock = IPtyProvider & { - emitData: (id: string, data: string) => void + emitData: (id: string, data: string, sequenceChars?: number) => void emitReplay: (id: string, data: string) => void emitExit: (id: string, code: number) => void } function createProvider(label: string, sessions: string[] = []): ProviderMock { - const dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + const dataListeners: ((payload: { id: string; data: string; sequenceChars?: number }) => void)[] = + [] const replayListeners: ((payload: { id: string; data: string }) => void)[] = [] const exitListeners: ((payload: { id: string; code: number }) => void)[] = [] return { @@ -41,15 +42,17 @@ function createProvider(label: string, sessions: string[] = []): ProviderMock { listProcesses: vi.fn(async () => sessions.map((id) => ({ id, cwd: '', title: label }))), getDefaultShell: vi.fn(async () => '/bin/zsh'), getProfiles: vi.fn(async () => []), - onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { - dataListeners.push(callback) - return () => { - const idx = dataListeners.indexOf(callback) - if (idx !== -1) { - dataListeners.splice(idx, 1) + onData: vi.fn( + (callback: (payload: { id: string; data: string; sequenceChars?: number }) => void) => { + dataListeners.push(callback) + return () => { + const idx = dataListeners.indexOf(callback) + if (idx !== -1) { + dataListeners.splice(idx, 1) + } } } - }), + ), onReplay: vi.fn((callback: (payload: { id: string; data: string }) => void) => { replayListeners.push(callback) return () => { @@ -68,9 +71,9 @@ function createProvider(label: string, sessions: string[] = []): ProviderMock { } } }), - emitData: (id: string, data: string) => { + emitData: (id: string, data: string, sequenceChars?: number) => { for (const listener of dataListeners) { - listener({ id, data }) + listener({ id, data, ...(sequenceChars === undefined ? {} : { sequenceChars }) }) } }, emitReplay: (id: string, data: string) => { @@ -151,6 +154,30 @@ describe('DegradedDaemonPtyProvider', () => { expect(fallback.write).not.toHaveBeenCalled() }) + it('routes authoritative recovery snapshots to the owning daemon', async () => { + const current = createDaemonAdapter('daemon', ['daemon-session']) + const fallback = createProvider('fallback') + const snapshot = { + data: 'alt frame', + scrollbackAnsi: 'normal history', + cols: 80, + rows: 24, + seq: 42, + source: 'headless' as const + } + current.getBufferSnapshot = vi.fn(async () => snapshot) + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + + await provider.discoverDaemonSessions() + + await expect( + provider.getBufferSnapshot('daemon-session', { scrollbackRows: 50_000 }) + ).resolves.toEqual(snapshot) + expect(current.getBufferSnapshot).toHaveBeenCalledWith('daemon-session', { + scrollbackRows: 50_000 + }) + }) + it('forwards replay output from fallback and daemon providers', () => { const current = createDaemonAdapter('daemon') const fallback = createProvider('fallback') @@ -174,6 +201,22 @@ describe('DegradedDaemonPtyProvider', () => { }) }) + it('preserves explicit sequence accounting on daemon data events', () => { + const current = createDaemonAdapter('daemon') + const fallback = createProvider('fallback') + const provider = new DegradedDaemonPtyProvider({ current, legacy: [], fallback }) + const dataSpy = vi.fn() + provider.onData(dataSpy) + + current.emitData('daemon-session', '\x1b[6n', 0) + + expect(dataSpy).toHaveBeenCalledWith({ + id: 'daemon-session', + data: '\x1b[6n', + sequenceChars: 0 + }) + }) + it('detaches provider subscriptions without disposing the underlying providers', () => { const current = createDaemonAdapter('daemon') const fallback = createProvider('fallback') diff --git a/src/main/daemon/degraded-daemon-pty-provider.ts b/src/main/daemon/degraded-daemon-pty-provider.ts index a7b8c68fa19..4e818c9b6c0 100644 --- a/src/main/daemon/degraded-daemon-pty-provider.ts +++ b/src/main/daemon/degraded-daemon-pty-provider.ts @@ -1,6 +1,9 @@ import type { DaemonPtyAdapter } from './daemon-pty-adapter' +import { shutdownDegradedFallbackSessions } from './degraded-daemon-fallback-shutdown' import type { IPtyProvider, + PtyBackgroundStreamEvent, + PtyProviderBufferSnapshot, PtyProcessInfo, PtySpawnOptions, PtySpawnResult @@ -23,7 +26,11 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { private fallback: ManagedPtyProvider private sessionProviders = new Map() private unsubscribers: (() => void)[] = [] - private dataListeners: ((payload: { id: string; data: string }) => void)[] = [] + private dataListeners: ((payload: { + id: string + data: string + sequenceChars?: number + }) => void)[] = [] private exitListeners: ((payload: { id: string; code: number }) => void)[] = [] constructor(opts: { @@ -93,6 +100,18 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { this.providerFor(id).resize(id, cols, rows) } + pauseProducer(id: string): void { + this.providerFor(id).pauseProducer?.(id) + } + + resumeProducer(id: string): void { + this.providerFor(id).resumeProducer?.(id) + } + + setPtyBackgrounded(id: string, background: boolean): void { + this.providerFor(id).setPtyBackgrounded?.(id, background) + } + async shutdown(id: string, opts: { immediate?: boolean; keepHistory?: boolean }): Promise { await this.providerFor(id).shutdown(id, opts) if (!opts.keepHistory) { @@ -116,6 +135,15 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { return (await this.providerFor(id).getAppliedSize?.(id)) ?? null } + async getBufferSnapshot( + id: string, + opts?: { scrollbackRows?: number } + ): Promise { + // Why: a preserved legacy daemon can still thin its monitoring stream; + // recovery must reach the adapter that owns that session's full model. + return (await this.providerFor(id).getBufferSnapshot?.(id, opts)) ?? null + } + async clearBuffer(id: string): Promise { await this.providerFor(id).clearBuffer(id) } @@ -155,7 +183,9 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { return this.fallback.getProfiles() } - onData(callback: (payload: { id: string; data: string }) => void): () => void { + onData( + callback: (payload: { id: string; data: string; sequenceChars?: number }) => void + ): () => void { this.dataListeners.push(callback) return () => { const idx = this.dataListeners.indexOf(callback) @@ -165,6 +195,17 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } } + onBackgroundStreamEvent(callback: (payload: PtyBackgroundStreamEvent) => void): () => void { + const unsubscribes = this.allProviders().flatMap( + (provider) => provider.onBackgroundStreamEvent?.(callback) ?? [] + ) + return () => { + for (const unsubscribe of unsubscribes) { + unsubscribe() + } + } + } + onReplay(callback: (payload: { id: string; data: string }) => void): () => void { const unsubscribes = this.allProviders().map((provider) => provider.onReplay(callback)) let active = true @@ -237,28 +278,7 @@ export class DegradedDaemonPtyProvider implements IPtyProvider { } async shutdownFallbackSessions(): Promise { - const ids = [...this.sessionProviders] - .filter(([, provider]) => provider === this.fallback) - .map(([id]) => id) - const results = await Promise.allSettled( - ids.map(async (id) => { - await this.fallback.shutdown(id, { immediate: true }) - this.sessionProviders.delete(id) - }) - ) - // Why: this runs first in the daemon-restart sequence. A throw here would - // abort the whole restart and leave "Restart daemon" — the user's recovery - // path for a wedged terminal — unusable, recreating the original lockup. So - // it is best-effort: log failures, keep restarting, and only count the - // sessions that actually shut down. - const failed = results.filter((result) => result.status === 'rejected') - if (failed.length > 0) { - console.warn( - `[daemon] ${failed.length} local fallback PTY session(s) failed to shut down during daemon restart; continuing restart`, - ...failed.map((result) => (result as PromiseRejectedResult).reason) - ) - } - return results.length - failed.length + return shutdownDegradedFallbackSessions(this.sessionProviders, this.fallback) } getCurrentDaemonSessionIds(): string[] { diff --git a/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts new file mode 100644 index 00000000000..8ce1191722d --- /dev/null +++ b/src/main/daemon/headless-emulator-fidelity.fuzz.test.ts @@ -0,0 +1,430 @@ +import { describe, expect, it } from 'vitest' +import { HeadlessEmulator } from './headless-emulator' +import { + buildAgentTuiStreamOps, + mulberry32, + splitIntoRandomChunks, + type AgentTuiStreamDims +} from '../../shared/agent-tui-ansi-fuzz-stream' +import { + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, + SNAPSHOT_REPLAY_PREAMBLE_ALT, + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + bufferHasSerializeHostileWrappedRow, + createRendererParityTerminal, + cursorPosition, + normalBufferRowsTrimmed, + visibleRowStyles, + visibleRows, + writeChunksToTerminal +} from '../../shared/terminal-restore-parity-fixture' + +// Differential garble gate for the hidden-terminal model/view contract +// (docs/reference/terminal-model-view-contract.md): with the hidden-delivery +// gate on, a hidden pane receives NOTHING — main's HeadlessEmulator is the +// source of truth and reveal repaints the renderer xterm from +// preamble + rehydrateSequences + snapshotAnsi (applyMainBufferSnapshot). +// This fuzz feeds seeded agent-TUI byte streams to the production emulator +// and to an always-visible renderer-parity terminal, then asserts the +// serialize→replay round trip reproduces the exact screen the renderer would +// have shown. Any diff = a garble bug on reveal. +// +// Runtime knobs: +// FUZZ_ITERATIONS=5000 deep/nightly mode (default 300, <60s combined with +// the reveal-reconciliation suite) +// FUZZ_SEED=1234 re-run exactly one seed (repro from a failure log) + +const DEFAULT_ITERATIONS = 300 +const FIXED_SEED = readPositiveIntEnv('FUZZ_SEED') +const ITERATIONS = + FIXED_SEED !== null ? 1 : (readPositiveIntEnv('FUZZ_ITERATIONS') ?? DEFAULT_ITERATIONS) +// Matches HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS in pty-connection.ts — the +// scrollback budget the reveal restore actually requests from main. +const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 + +function readPositiveIntEnv(name: string): number | null { + const raw = Number(process.env[name]) + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null +} + +const DIMS: readonly AgentTuiStreamDims[] = [ + { cols: 80, rows: 24 }, + { cols: 100, rows: 30 }, + { cols: 120, rows: 40 } +] + +type FidelityCase = { + seed: number + dims: AgentTuiStreamDims + ops: string[] + chunked: boolean +} + +type FidelityDiff = { + stage: string + expected: unknown + actual: unknown + /** True when the always-visible buffer matches the known upstream + * @xterm/addon-serialize blank-leading-wrapped-row bug predicate — see + * bufferHasSerializeHostileWrappedRow and the skipped repro test below. */ + knownSerializeWrapBug?: boolean +} + +function buildCase(seed: number): FidelityCase { + const rng = mulberry32(seed) + const dims = DIMS[Math.floor(rng() * DIMS.length)]! + const opCount = 12 + Math.floor(rng() * 28) + const ops = buildAgentTuiStreamOps(rng, dims, { + includeMouseModes: true, + includeOscHyperlinks: false, + opCount + }) + return { seed, dims, ops, chunked: true } +} + +function firstDiff(stage: string, expected: unknown, actual: unknown): FidelityDiff | null { + return JSON.stringify(expected) === JSON.stringify(actual) ? null : { stage, expected, actual } +} + +async function runFidelityCase(testCase: FidelityCase): Promise { + const stream = testCase.ops.join('') + const chunks = testCase.chunked + ? splitIntoRandomChunks(mulberry32(testCase.seed ^ 0x9e3779b9), stream, { + minLen: 3, + maxLen: 120 + }) + : [stream] + const emulator = new HeadlessEmulator({ cols: testCase.dims.cols, rows: testCase.dims.rows }) + const control = createRendererParityTerminal(testCase.dims) + const restored = createRendererParityTerminal(testCase.dims) + try { + for (const chunk of chunks) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, chunks) + + // Stage 1 — model fidelity: the emulator's screen must already match the + // renderer twin before any serialization enters the picture. + const modelDiff = firstDiff( + 'model-visible (HeadlessEmulator vs renderer twin)', + visibleRows(control.terminal), + emulator.getVisibleLines() + ) + if (modelDiff) { + return modelDiff + } + + // Stage 2 — reveal round trip: serialize exactly like + // serializeHiddenOutputRecoveryBuffer, replay exactly like + // applyMainBufferSnapshot, then compare against the always-visible twin. + const alt = emulator.isAlternateScreen + const snapshot = emulator.getSnapshot({ + scrollbackRows: alt ? 0 : HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS + }) + await writeChunksToTerminal(restored.terminal, [ + alt ? SNAPSHOT_REPLAY_PREAMBLE_ALT : SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + + const diffs = [ + firstDiff( + 'restore-visible-text', + visibleRows(control.terminal), + visibleRows(restored.terminal) + ), + firstDiff( + 'restore-visible-styles', + visibleRowStyles(control.terminal), + visibleRowStyles(restored.terminal) + ), + firstDiff( + 'restore-cursor', + cursorPosition(control.terminal), + cursorPosition(restored.terminal) + ), + firstDiff( + 'restore-mode-bracketed-paste', + control.terminal.modes.bracketedPasteMode, + restored.terminal.modes.bracketedPasteMode + ), + // Why alt is excluded from the two comparisons below: + // - scrollback: serializeHeadlessTerminalBuffer (orca-runtime.ts) + // deliberately forces scrollbackRows=0 while an alt-screen TUI is + // active, so normal-buffer history is not part of the alt contract. + // - application cursor: HeadlessEmulator.getModes reports + // applicationCursor false on the alternate buffer, so rehydrate omits + // ?1h there by design. + alt + ? null + : firstDiff( + 'restore-scrollback-text', + normalBufferRowsTrimmed(control.terminal), + normalBufferRowsTrimmed(restored.terminal) + ), + alt + ? null + : firstDiff( + 'restore-mode-application-cursor', + control.terminal.modes.applicationCursorKeysMode, + restored.terminal.modes.applicationCursorKeysMode + ) + ] + const diff = diffs.find((candidate) => candidate !== null) ?? null + if (!diff) { + return null + } + if (bufferHasSerializeHostileWrappedRow(control.terminal)) { + return { ...diff, knownSerializeWrapBug: true } + } + return diff + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } +} + +/** Greedy op-drop minimizer: re-runs the full differential pipeline on + * smaller op lists so a failure report carries the smallest byte stream that + * still diverges (plus its seed for exact replay via FUZZ_SEED). */ +async function minimizeFailure(testCase: FidelityCase): Promise { + let current = { ...testCase, chunked: false } + if ((await runFidelityCase(current)) === null) { + current = { ...testCase, chunked: true } + } + let budget = 400 + let shrunk = true + while (shrunk && budget > 0) { + shrunk = false + for (let i = current.ops.length - 1; i >= 0 && budget > 0; i--) { + const candidate = { ...current, ops: current.ops.toSpliced(i, 1) } + budget -= 1 + if ((await runFidelityCase(candidate)) !== null) { + current = candidate + shrunk = true + } + } + } + return current +} + +function formatFailure(minimized: FidelityCase, diff: FidelityDiff | null): string { + return [ + `HeadlessEmulator fidelity divergence — stage: ${diff?.stage ?? 'unknown'}`, + `seed: ${minimized.seed} (re-run: FUZZ_SEED=${minimized.seed} pnpm exec vitest run --config config/vitest.config.ts src/main/daemon/headless-emulator-fidelity.fuzz.test.ts)`, + `dims: ${minimized.dims.cols}x${minimized.dims.rows} chunked: ${minimized.chunked}`, + `minimized ops (${minimized.ops.length}): ${JSON.stringify(minimized.ops)}`, + `expected (always-visible renderer twin): ${JSON.stringify(diff?.expected)}`, + `actual (snapshot restore replay): ${JSON.stringify(diff?.actual)}` + ].join('\n') +} + +describe('headless emulator snapshot fidelity fuzz', () => { + // Known-legitimate divergence, pinned so it cannot silently regress into a + // real one: xterm marks OSC 8 hyperlink cells underlined, SerializeAddon + // never re-emits OSC 8, and production compensates by shipping the ranges + // out-of-band in snapshot.oscLinks (collectHeadlessOscLinkRanges) for the + // renderer link provider to re-register. Byte-replay therefore keeps the + // TEXT but not the link underline — the metadata must carry the range. + it('drops OSC 8 underline from byte replay but preserves the range in snapshot metadata', async () => { + const emulator = new HeadlessEmulator({ cols: 60, rows: 10 }) + const restored = createRendererParityTerminal({ cols: 60, rows: 10 }) + try { + await emulator.write('\x1b]8;;https://example.com/pr/7\x07review link\x1b]8;;\x07 tail') + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + expect(visibleRows(restored.terminal)[0]).toBe('review link tail') + expect(snapshot.oscLinks).toContainEqual({ + row: 0, + startCol: 0, + endCol: 11, + uri: 'https://example.com/pr/7' + }) + } finally { + emulator.dispose() + restored.terminal.dispose() + } + }) + + it(`matches an always-visible renderer twin across ${ITERATIONS} seeded agent-TUI streams`, async () => { + // The known-and-pinned serialize wrap bug (A) is tolerated + counted so + // deep mode (FUZZ_ITERATIONS) surfaces only GENUINELY NEW divergences. + // Bugs B (bold-reset, fixed by the addon patch) and C (margin cursor, + // fixed by the absolute-cursor epilogue) are no longer tolerated — a + // regression fails the corpus loudly and the unskipped repros below. + let knownSerializeWrapBugHits = 0 + for (let i = 0; i < ITERATIONS; i++) { + const seed = FIXED_SEED ?? 1 + i + const testCase = buildCase(seed) + const diff = await runFidelityCase(testCase) + if (diff?.knownSerializeWrapBug) { + knownSerializeWrapBugHits += 1 + continue + } + if (diff) { + const minimized = await minimizeFailure(testCase) + const minimizedDiff = await runFidelityCase(minimized) + expect.fail(formatFailure(minimized, minimizedDiff ?? diff)) + } + } + // Guard the tolerance from swallowing the suite: the predicate tripping + // on most seeds means the gate has gone degenerate. + expect(knownSerializeWrapBugHits).toBeLessThan(Math.max(3, ITERATIONS * 0.5)) + }, 600_000) + + // ── HEADLINE FINDING (do not delete while unfixed upstream) ────────────── + // @xterm/addon-serialize 0.15.0-beta.287 does not round-trip null cells + // that touch a soft-wrap boundary. Two variants, both found by this fuzz + // and minimized below. Every Orca snapshot consumer is affected: hidden + // reveal, parked-tab reveal, sleep/wake restore, and mobile subscribe + // replay paint lost/shifted characters or stray '-' fillers whenever a TUI + // erased inside a soft-wrapped line (shell line editing, status lines wider + // than the pane, Claude Code in-place prompt redraws). + // + // V1 — cell loss (found by seed 31, minimized to 2 ops): + // Root cause: the wrap-validity ternary in SerializeAddon.ts (~L214) + // nextRowFirstChar.getChars() && isNextRowFirstCharDoubleWidth + // ? this._nullCellCount <= 1 : this._nullCellCount <= 0 + // binds as `(chars && doubleWidth) ? ...`, so a null-leading wrapped row + // passes as a "natural" wrap. The serializer then emits the previous row as + // full-width text (leaving xterm in wrap-pending) and skips the null cell + // with CUF (`ESC[1C`) — but CUF clamps at the right margin instead of + // crossing the wrap boundary, so the next character overwrites the previous + // row's last cell and the whole tail shifts left by one. + // cols=20: write 'ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n' then '\x1b[1A\x1b[1K' + // live rows: ['ABCDEFGHIJKLMNOPQRST', ' VWXYZ12'] + // serialize(): 'ABCDEFGHIJKLMNOPQRST\x1b[1CVWXYZ12\x1b[8D' + // replayed rows: ['ABCDEFGHIJKLMNOPQRSV', 'WXYZ12'] ← 'T' eaten, tail shifted + // + // V2 — stray filler '-' (found by seed 157, minimized below): when the + // SOURCE row of a wrapped pair is entirely null (a TUI erased the whole + // first half of a wrapped line), the addon's forced-wrap "magic" writes + // nullCellCount+1 dashes and then cleans up with + // ESC[A ESC[(length-nullCellCount)C ESC[(nullCellCount)X ... + // With length === nullCellCount that cursor-forward becomes `ESC[0C`, and + // CSI param 0 means 1, so the ECH erase lands one cell right and the first + // '-' stays visible on the restored row. + // Unskip once the upstream fix (or a local serialize post-processor) lands. + it.skip('round-trips a wrapped line whose continuation row starts with an erased cell', async () => { + const emulator = new HeadlessEmulator({ cols: 20, rows: 6 }) + const control = createRendererParityTerminal({ cols: 20, rows: 6 }) + const restored = createRendererParityTerminal({ cols: 20, rows: 6 }) + try { + const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[1A\x1b[1K'] + for (const chunk of bytes) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, bytes) + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal)) + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } + }) + + // V2 repro of the headline finding above (stray '-' filler on a fully + // erased wrapped source row). Unskip alongside the V1 repro. + it.skip('round-trips a wrapped line whose source row was fully erased', async () => { + const emulator = new HeadlessEmulator({ cols: 20, rows: 6 }) + const control = createRendererParityTerminal({ cols: 20, rows: 6 }) + const restored = createRendererParityTerminal({ cols: 20, rows: 6 }) + try { + // Wrap a 28-char line, then erase the entire first (source) row of the + // wrapped pair: cursor up twice onto it, EL 2. + const bytes = ['ABCDEFGHIJKLMNOPQRSTUVWXYZ12\r\n', '\x1b[2A\x1b[2K'] + for (const chunk of bytes) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, bytes) + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + // Fails today: restored row 0 shows '-' where the live row is blank. + expect(visibleRows(restored.terminal)).toEqual(visibleRows(control.terminal)) + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } + }) + + // ── Bug B regression guard: SGR bold on a dim→bold-only cell transition ── + // Upstream @xterm/addon-serialize emitted `\x1b[1;22m` for this transition; + // SGR 22 (normalIntensity) clears BOTH bold and dim, so the restored cell + // lost its bold. FIXED by the intensity-group reorder in + // config/patches/@xterm__addon-serialize@0.15.0-beta.287.patch (22 before + // 1/2). Found by fuzz seeds 435, 770, 1321; mechanism in + // notes/garble-fuzz-divergences.md (Bug B). + it('preserves bold when serializing a dim cell followed by a bold-only cell', async () => { + const emulator = new HeadlessEmulator({ cols: 20, rows: 4 }) + const control = createRendererParityTerminal({ cols: 20, rows: 4 }) + const restored = createRendererParityTerminal({ cols: 20, rows: 4 }) + try { + // 'A' dim, 'B' bold-only. Live: A=dim, B=bold. The patched serializer + // emits 22;1 for the B transition (clear before re-set). + const bytes = ['\x1b[2mA\x1b[22m\x1b[1mB\x1b[0m'] + for (const chunk of bytes) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, bytes) + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + expect(visibleRowStyles(restored.terminal)).toEqual(visibleRowStyles(control.terminal)) + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } + }) + + // ── Bug C regression guard: cursor exact when the last row fills the margin ── + // Upstream @xterm/addon-serialize computes its relative cursor-restore from + // a wrap-pending position and lands one column short. FIXED Orca-side: the + // emulator snapshot appends an absolute CUP from the source's authoritative + // cursor (serializeWithAbsoluteCursor). Found by fuzz seeds 454, 1696; + // mechanism in notes/garble-fuzz-divergences.md (Bug C). + it('restores the cursor exactly when the last content row fills the right margin', async () => { + const emulator = new HeadlessEmulator({ cols: 10, rows: 4 }) + const control = createRendererParityTerminal({ cols: 10, rows: 4 }) + const restored = createRendererParityTerminal({ cols: 10, rows: 4 }) + try { + // Fill row 0 to exactly 10 cols (wrap-pending), then CUP the cursor to a + // known lower-row column. Live cursor is (x=4, y=2). + const bytes = ['0123456789\x1b[3;5H'] + for (const chunk of bytes) { + await emulator.write(chunk) + } + await writeChunksToTerminal(control.terminal, bytes) + const snapshot = emulator.getSnapshot({ scrollbackRows: 5000 }) + await writeChunksToTerminal(restored.terminal, [ + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + snapshot.rehydrateSequences + snapshot.snapshotAnsi, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY + ]) + expect(cursorPosition(restored.terminal)).toEqual(cursorPosition(control.terminal)) + } finally { + emulator.dispose() + control.terminal.dispose() + restored.terminal.dispose() + } + }) +}) diff --git a/src/main/daemon/headless-emulator.test.ts b/src/main/daemon/headless-emulator.test.ts index 0a5832247c6..b9630b8ff7e 100644 --- a/src/main/daemon/headless-emulator.test.ts +++ b/src/main/daemon/headless-emulator.test.ts @@ -98,6 +98,74 @@ describe('HeadlessEmulator', () => { uri: 'https://example.com/issue/1234' }) }) + + it('serializes split synchronized rich TUI frames for model-backed replay', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 12 }) + const richFrame = [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + '\x1b[2;36m╭────────────────────────────╮\x1b[0m\r\n', + '\x1b[2;36m│ Codex rich restore 🟢 ███░ │\x1b[0m\r\n', + '\x1b[2;36m│ status streaming │\x1b[0m\r\n', + '\x1b[2;36m╰────────────────────────────╯\x1b[0m', + '\x1b[6;4H\x1b[?25h', + '\x1b[?2026l' + ].join('') + + // Why: hidden rich TUI bytes may arrive split across DEC 2026 frame + // boundaries; model/view work needs the headless model to preserve the + // final visible state before renderer writes can be removed. + await emulator.write(richFrame.slice(0, 17)) + await emulator.write(richFrame.slice(17, 91)) + await emulator.write(richFrame.slice(91)) + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.alternateScreen).toBe(true) + expect(snapshot.snapshotAnsi).toContain('Codex rich restore') + expect(snapshot.snapshotAnsi).toContain('🟢') + expect(snapshot.snapshotAnsi).toContain('███░') + expect(snapshot.snapshotAnsi).toContain('╭') + expect(snapshot.snapshotAnsi).not.toContain('\x1b[?2026h') + + const replay = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows }) + try { + await replay.write(snapshot.rehydrateSequences + snapshot.snapshotAnsi) + const replayed = replay.getSnapshot() + expect(replayed.modes.alternateScreen).toBe(true) + expect(replayed.snapshotAnsi).toContain('Codex rich restore') + expect(replayed.snapshotAnsi).toContain('🟢') + expect(replayed.snapshotAnsi).toContain('███░') + } finally { + replay.dispose() + } + }) + + it('preserves the normal buffer behind an alternate-screen snapshot', async () => { + emulator = new HeadlessEmulator({ cols: 40, rows: 6 }) + await emulator.write('shell history one\r\nshell history two') + await emulator.write('\x1b[?1049h\x1b[2J\x1b[HTUI frame') + + const snapshot = emulator.getSnapshot() + expect(snapshot.scrollbackAnsi).toContain('shell history one') + expect(snapshot.snapshotAnsi).toContain('TUI frame') + expect(snapshot.snapshotAnsi).not.toContain('shell history one') + + const replay = new HeadlessEmulator({ cols: snapshot.cols, rows: snapshot.rows }) + try { + await replay.write( + snapshot.scrollbackAnsi + snapshot.rehydrateSequences + snapshot.snapshotAnsi + ) + expect(replay.getVisibleLines().join('\n')).toContain('TUI frame') + + await replay.write('\x1b[?1049l') + expect(replay.getVisibleLines().join('\n')).toContain('shell history one') + expect(replay.getVisibleLines().join('\n')).toContain('shell history two') + } finally { + replay.dispose() + } + }) }) describe('OSC-7 CWD tracking', () => { @@ -305,6 +373,49 @@ describe('HeadlessEmulator', () => { expect(emulator.getSnapshot().modes.sgrMouseMode).toBe(false) }) + it('tracks kitty keyboard flags for emulator re-seed parity', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(0) + + await emulator.write('\x1b[=5;1u') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(5) + }) + + it('round-trips a pushed CSI > 1 u flag through the core-internals read path', async () => { + // Why: getKittyKeyboardFlags reads _core.coreService.kittyKeyboard.flags, + // a private xterm surface. If an xterm upgrade breaks that path this + // must fail loudly instead of the responder silently answering ?0u. + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + + await emulator.write('\x1b[>1u') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(1) + }) + + it('snapshots the active-buffer kitty flags (alt screen keeps its own set)', async () => { + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + // Kitty flags are per screen buffer: entering the alt screen swaps to + // its own (empty) flag set, exactly what a CSI ? u reply would report. + await emulator.write('\x1b[=5;1u\x1b[?1049h') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(0) + + await emulator.write('\x1b[=3;1u') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(3) + + await emulator.write('\x1b[?1049l') + expect(emulator.getSnapshot().modes.kittyKeyboardFlags).toBe(5) + }) + + it('never pushes kitty flags into rehydrateSequences', async () => { + // Why: POST_REPLAY_REATTACH_RESET's deliberate kitty reset must stay + // authoritative for renderer replays (terminal-query-authority.md). + emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) + await emulator.write('\x1b[?1049h\x1b[=5;1u') + + const snapshot = emulator.getSnapshot() + expect(snapshot.modes.kittyKeyboardFlags).toBe(5) + expect(snapshot.rehydrateSequences).not.toContain('u') + }) + it('tracks split SGR mouse reporting sequences', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) @@ -499,14 +610,16 @@ describe('HeadlessEmulator', () => { expect(snapshot.rehydrateSequences).not.toContain('\x1b[?1002h') }) - it('rehydrates kitty keyboard flags a TUI pushed (CSI > u)', async () => { + it('records kitty flags without pushing them into renderer rehydration', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) // OMP/pi negotiate progressive enhancement with a level-1 push. await emulator.write('\x1b[>1u') const snapshot = emulator.getSnapshot() expect(snapshot.modes.kittyKeyboardFlags).toBe(1) - expect(snapshot.rehydrateSequences).toContain('\x1b[=1;1u') + // Why: renderer replay deliberately resets stale CSI-u state; the daemon + // warm-reattach path re-seeds the model from modes.kittyKeyboardFlags. + expect(snapshot.rehydrateSequences).not.toContain('\x1b[=1;1u') }) it('omits kitty rehydration after the TUI pops its flags', async () => { @@ -519,7 +632,7 @@ describe('HeadlessEmulator', () => { expect(snapshot.rehydrateSequences).not.toContain('u') }) - it('re-arms kitty flags after the alt-screen switch so they land on the negotiated screen', async () => { + it('keeps kitty flags out of alternate-screen renderer rehydration', async () => { emulator = new HeadlessEmulator({ cols: 80, rows: 24 }) await emulator.write('\x1b[?1049h\x1b[>1u') @@ -527,7 +640,7 @@ describe('HeadlessEmulator', () => { const altScreenIndex = snapshot.rehydrateSequences.indexOf('\x1b[?1049h') const kittyIndex = snapshot.rehydrateSequences.indexOf('\x1b[=1;1u') expect(altScreenIndex).toBeGreaterThanOrEqual(0) - expect(kittyIndex).toBeGreaterThan(altScreenIndex) + expect(kittyIndex).toBe(-1) }) it('drops kitty rehydration after a TUI soft reset (DECSTR)', async () => { diff --git a/src/main/daemon/headless-emulator.ts b/src/main/daemon/headless-emulator.ts index 7ce69db0cc8..e7da2a4589e 100644 --- a/src/main/daemon/headless-emulator.ts +++ b/src/main/daemon/headless-emulator.ts @@ -3,13 +3,21 @@ import { Terminal } from '@xterm/headless' import { SerializeAddon } from '@xterm/addon-serialize' import { Unicode11Addon } from '@xterm/addon-unicode11' import { activateOrcaTerminalUnicodeProvider } from '../../shared/terminal-unicode-provider' +import { + readSavedCursorRegister, + serializeWithAbsoluteCursor +} from '../../shared/terminal-serialize-absolute-cursor' import { advancePartialEscapeTail } from '../../shared/terminal-partial-escape-tail' -import { TerminalKittyKeyboardModeTracker } from '../../shared/terminal-kitty-keyboard-mode-tracker' -import { extractLastOscTitle } from '../../shared/agent-detection' +import type { TerminalViewAttributes } from '../../shared/terminal-view-attributes' import { collectHeadlessOscLinkRanges } from './headless-osc-link-ranges' -import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction' -import { parseFileUriPath } from './osc7-file-uri' -import { TerminalPrivateModeTracker } from './terminal-private-mode-tracker' +import { buildRehydrateSequences } from './terminal-mode-rehydrate-sequences' +import { TerminalMouseModeMirror } from './terminal-mouse-mode-mirror' +import { TerminalOscCwdTitleScanner } from './terminal-osc-cwd-title-scanner' +import { splitTerminalSnapshotAnsi } from './terminal-snapshot-ansi-buffers' +import { + installTerminalViewAttributeResponder, + type TerminalViewAttributeResponder +} from './terminal-view-attribute-responder' import type { TerminalSnapshot, TerminalModes } from './types' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' @@ -17,48 +25,87 @@ export type HeadlessEmulatorOptions = { cols: number rows: number scrollback?: number + /** Phase-5 model query responder sink (terminal-query-authority.md). + * When set, xterm-core auto-replies generated while parsing a write + * flagged `forwardQueryReplies` are forwarded here; all other emissions + * (seeds, hydration, snapshot replay, unsolicited core pushes) are + * discarded. The daemon Session must NEVER pass this — its emulator + * stays write-only forever (contract invariant: the daemon never + * answers). */ + onQueryReply?: (reply: string) => void pathFlavor?: 'posix' | 'win32' remotePosixFileUriAuthority?: boolean } +export type HeadlessEmulatorWriteOptions = { + /** Reply ownership captured at ingestion for this exact chunk. Default + * false is the main-side replay guard (twin of the renderer's + * replay-guard.ts): seed/hydration/snapshot writes never forward. */ + forwardQueryReplies?: boolean +} + type TerminalWithSynchronousWrite = Terminal & { _core?: { writeSync?: (data: string) => void + // Why: kitty keyboard flags are not on the public IModes; read the core + // service state the CSI =/>/< u handlers mutate. + coreService?: { + kittyKeyboard?: { flags?: number } + } } } const DEFAULT_SCROLLBACK = 5000 -const OSC_SCAN_TAIL_LIMIT = 4096 +// Keep in sync with the renderer twin in terminal-capability-replies.ts +// (main must not import renderer modules). +const CONPTY_DA1_RESPONSE = '\x1b[?61;4c' export class HeadlessEmulator { private terminal: Terminal private serializer: SerializeAddon - private cwd: string | null = null - private lastTitle: string | null = null - private oscScanTail = '' - private privateModes = new TerminalPrivateModeTracker() - private kittyKeyboardModes = new TerminalKittyKeyboardModeTracker() - private restoredOscLinks: TerminalOscLinkRange[] = [] - // Why: a PTY read can end mid-escape-sequence — those bytes live in xterm's - // parser, not the screen buffer, so serialize() drops them and the next - // chunk's continuation renders literally after a remote snapshot restore - // (#7329). Track the unparsed trailing partial at ingest (committed after - // xterm parses the same bytes, like the private-mode mirror) and ship it in - // the snapshot so the restorer can complete the sequence. - private partialEscapeTail = '' - private disposed = false + // Why: our restructure owns cwd/title via TerminalOscCwdTitleScanner and the + // DECSET mouse modes via TerminalMouseModeMirror (functionally identical to + // main's inline cwd/lastTitle/oscScanTail + TerminalPrivateModeTracker, which + // only tracks the same mouse modes). restoredOscLinks/disposed/partialEscapeTail + // are declared below. + private oscText: TerminalOscCwdTitleScanner + private mouseModes = new TerminalMouseModeMirror() private readonly pathFlavor?: 'posix' | 'win32' private readonly remotePosixFileUriAuthority: boolean + private restoredOscLinks: TerminalOscLinkRange[] = [] + private disposed = false + private onQueryReply: ((reply: string) => void) | null + private conptyDa1OverrideInstalled = false + private viewAttributeResponder: TerminalViewAttributeResponder | null = null + // Why: replies must be scoped to the exact write that carried the query. + // The window opens around the parse of a forward-flagged chunk and closes + // with it, so seeds/snapshots and unsolicited core emissions (e.g. native + // 997 pushes from option mutations) can never leak to the PTY. + private queryReplyForwardingDepth = 0 + // Why: a chunk ending mid-escape leaves the sequence in xterm's parser, not + // the buffer, so serialize() drops it and the next chunk's continuation + // renders literal after a restore (Bug E, notes/garble-fuzz-divergences.md). + // Committed alongside mouseModes: only after xterm parsed the same bytes. + private partialEscapeTail = '' constructor(opts: HeadlessEmulatorOptions) { this.pathFlavor = opts.pathFlavor this.remotePosixFileUriAuthority = opts.remotePosixFileUriAuthority === true + this.oscText = new TerminalOscCwdTitleScanner({ + pathFlavor: this.pathFlavor, + remotePosixAuthority: this.remotePosixFileUriAuthority + }) this.terminal = new Terminal({ cols: opts.cols, rows: opts.rows, scrollback: opts.scrollback ?? DEFAULT_SCROLLBACK, allowProposedApi: true, - logLevel: 'off' + logLevel: 'off', + // Why: parity with the renderer's buildDefaultTerminalOptions — parse + // CSI =/>/< u pushes so CSI ? u answers with the flags the hidden app + // actually pushed. Write-only daemon use is unaffected: keyboard state + // never alters serialization (terminal-query-authority.md §kitty). + vtExtensions: { kittyKeyboard: true } }) this.serializer = new SerializeAddon() @@ -72,34 +119,136 @@ export class HeadlessEmulator { this.terminal.loadAddon(new Unicode11Addon()) activateOrcaTerminalUnicodeProvider(this.terminal) - // Why no onData wiring: this emulator exists purely for state tracking - // (snapshots, cwd, mode flags). It MUST NOT respond to terminal query - // sequences (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The emulator parses - // data in-process synchronously before `handleSubprocessData` forwards - // it to the renderer over IPC, so any reply it emits would land on the - // shell's stdin ahead of the renderer's xterm reply and win the race. - // The renderer is the authoritative responder (it has the real theme, - // cursor position, and paste mode); a daemon-side reply would be a - // double-reply with wrong values. OSC 11 was the visible casualty: - // Claude Code's /theme auto always saw the emulator's default-black - // background regardless of Orca's configured terminal theme. + // Why onData is gated behind onQueryReply: by default this emulator is + // pure state tracking and MUST NOT respond to terminal query sequences + // (DA1/DA2, DSR, OSC 10/11/12, DECRPM). The daemon emulator parses data + // in-process synchronously before `handleSubprocessData` forwards it to + // the renderer over IPC, so any reply it emitted would land on the + // shell's stdin ahead of the renderer's xterm reply and win the race — + // a double-reply with default-xterm values (OSC 11 default-black was + // the visible casualty). Only main's runtime per-PTY emulators pass a + // sink, and even then replies flow only for chunks the hidden-delivery + // gate DROPPED, where the renderer never sees the bytes and main is the + // single answerer. See docs/reference/terminal-query-authority.md. + this.onQueryReply = opts.onQueryReply ?? null + if (this.onQueryReply) { + this.terminal.onData((reply) => this.emitQueryReply(reply)) + } } - write(data: string): Promise { + /** Main-side twin of the renderer's terminal-capability-replies.ts: + * ConPTY 1.22+ blocks at spawn waiting for a DA1 reply, and the override + * variant (`CSI ?61;4c`) must win. Returning true consumes the query so + * xterm core's default `?1;2c` cannot double-reply (custom CSI handlers + * run before core's; false falls through). The reply still routes through + * the forwarding window, so replayed/seeded bytes never answer. */ + installConptyPrimaryDeviceAttributesOverride(): void { + // Why idempotent: the spawn mark can land after daemon stream data + // already created the emulator, so the override is installed both at + // creation and retrofitted at mark time — never stacked. + if (this.conptyDa1OverrideInstalled) { + return + } + this.conptyDa1OverrideInstalled = true + this.terminal.parser.registerCsiHandler({ final: 'c' }, (params) => { + const isPrimaryQuery = params.length === 0 || (params.length === 1 && params[0] === 0) + if (!isPrimaryQuery) { + return false + } + this.emitQueryReply(CONPTY_DA1_RESPONSE) + return true + }) + } + + /** Phase-5 slice-2 view-attribute bridge: the headless core has no theme + * service, so OSC 4/10/11/12 queries and DSR ?996n are answered from the + * renderer's pushed attributes via these parser handlers — never from + * emulator defaults. Runtime-only, like onQueryReply: the daemon Session + * must NEVER call this (its emulator stays write-only forever). */ + installViewAttributeResponder(getBaseAttributes: () => TerminalViewAttributes | null): void { + if (this.viewAttributeResponder) { + return + } + this.viewAttributeResponder = installTerminalViewAttributeResponder({ + parser: this.terminal.parser, + getBaseAttributes, + // emitQueryReply keeps replies inside the per-chunk forwarding window, + // so seeded/replayed view-attribute queries answer no one. + emitReply: (reply) => this.emitQueryReply(reply) + }) + } + + /** Applies a renderer view-attribute push: cursor options make xterm core + * answer DECRQSS DECSCUSR / DECRQM 12 renderer-true, and the per-PTY OSC + * color overrides are dropped because a theme apply overwrites mutated + * colors on visible panes too (ThemeService._setTheme parity). Option + * writes happen outside any forwarding window, so any core emission they + * trigger is discarded (main-side replay guard). */ + applyPushedViewAttributes(attributes: TerminalViewAttributes): void { + if (this.disposed) { + return + } + this.terminal.options.cursorStyle = attributes.cursorStyle + this.terminal.options.cursorBlink = attributes.cursorBlink + this.viewAttributeResponder?.clearColorOverrides() + } + + /** Re-seed parity for snapshot `modes.kittyKeyboardFlags` + * (terminal-query-authority.md §kitty): replays the persisted flags + * through the same `CSI = flags ; 1 u` parse a live push uses, so hidden + * `CSI ? u` reports them instead of `?0u`. Routed as an UNFLAGGED write — + * outside any forwarding window, it can never answer anything — and never + * into renderer rehydrateSequences (POST_REPLAY_REATTACH_RESET's kitty + * reset stays authoritative). */ + applyKittyKeyboardFlags(flags: number): Promise { + if (!Number.isInteger(flags) || flags <= 0) { + return Promise.resolve() + } + return this.write(`\x1b[=${flags};1u`) + } + + private emitQueryReply(reply: string): void { + if (this.queryReplyForwardingDepth > 0 && this.onQueryReply) { + this.onQueryReply(reply) + } + } + + /** Severs the reply sink at PTY teardown. Queued writeChain links may + * still parse after dispose is requested, and daemon respawns reuse + * session ids — a late reply must never reach a successor PTY. */ + disableQueryReplyForwarding(): void { + this.onQueryReply = null + } + + write(data: string, opts: HeadlessEmulatorWriteOptions = {}): Promise { if (this.disposed) { return Promise.resolve() } - if (this.tryWriteSync(data)) { + const forwardQueryReplies = opts.forwardQueryReplies === true + if (this.tryWriteSync(data, { forwardQueryReplies })) { return Promise.resolve() } - this.scanInputForOscState(data) + this.oscText.scan(data) + // Why the sentinel: xterm parses queued writes asynchronously, so opening + // the window at enqueue time would leak it over earlier queued unflagged + // chunks (seed/hydration bytes parsing while depth > 0). Write callbacks + // fire in FIFO parse order, so a zero-byte write whose callback opens the + // window brackets the parse of exactly this chunk; the data callback + // closes it. + if (forwardQueryReplies) { + this.terminal.write('', () => { + this.queryReplyForwardingDepth += 1 + }) + } return new Promise((resolve) => { this.terminal.write(data, () => { + if (forwardQueryReplies) { + this.queryReplyForwardingDepth -= 1 + } // Why: snapshots combine serialized xterm state with mirrored mouse // modes. Commit the mirror only after xterm has parsed the same bytes. - this.privateModes.scan(data) - this.kittyKeyboardModes.scan(data) + this.mouseModes.scan(data) this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) resolve() }) @@ -117,31 +266,30 @@ export class HeadlessEmulator { return this.tryWriteSync(data) } - private tryWriteSync(data: string): boolean { + private tryWriteSync(data: string, opts: HeadlessEmulatorWriteOptions = {}): boolean { const writeSync = (this.terminal as TerminalWithSynchronousWrite)._core?.writeSync if (typeof writeSync !== 'function') { return false } - this.scanInputForOscState(data) + this.oscText.scan(data) + const forwardQueryReplies = opts.forwardQueryReplies === true + if (forwardQueryReplies) { + this.queryReplyForwardingDepth += 1 + } // Why: hidden renderer restore snapshots are requested immediately after // PTY bursts; queued headless writes can snapshot half-cleared TUI rows. - writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data) - this.privateModes.scan(data) - this.kittyKeyboardModes.scan(data) + try { + writeSync.call((this.terminal as TerminalWithSynchronousWrite)._core, data) + } finally { + if (forwardQueryReplies) { + this.queryReplyForwardingDepth -= 1 + } + } + this.mouseModes.scan(data) this.partialEscapeTail = advancePartialEscapeTail(this.partialEscapeTail, data) return true } - private scanInputForOscState(data: string): void { - const oscInput = this.oscScanTail + data - this.oscScanTail = this.extractOscScanTail(oscInput) - this.scanOsc7(oscInput) - const lastTitle = extractLastOscTitle(oscInput) - if (lastTitle !== null) { - this.lastTitle = lastTitle - } - } - resize(cols: number, rows: number): void { if (this.disposed) { return @@ -160,38 +308,65 @@ export class HeadlessEmulator { getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot { const modes = this.getModes() - const snapshotAnsi = this.normalizeSnapshotAnsiForModes( - this.serializer.serialize({ scrollback: opts.scrollbackRows }), - modes + // Why serializeWithAbsoluteCursor: SerializeAddon's relative cursor + // restore lands one column short after a margin-filling final row leaves + // replay wrap-pending; the trailing CUP survives the alt-marker slice. + // The saved-cursor register rides along so a post-restore DECRC lands + // where the hidden TUI saved, not at home. + const serializedAnsi = serializeWithAbsoluteCursor( + this.serializer, + this.terminal, + { scrollback: opts.scrollbackRows }, + readSavedCursorRegister(this.terminal) ) - return { + const { snapshotAnsi, scrollbackAnsi } = splitTerminalSnapshotAnsi(serializedAnsi, modes) + const snapshot: TerminalSnapshot = { snapshotAnsi, - scrollbackAnsi: '', + scrollbackAnsi, oscLinks: collectHeadlessOscLinkRanges( this.terminal, opts.scrollbackRows, this.restoredOscLinks ), - rehydrateSequences: this.buildRehydrateSequences(modes), - cwd: this.cwd, + rehydrateSequences: buildRehydrateSequences(modes), + cwd: this.oscText.cwd, modes, cols: this.terminal.cols, rows: this.terminal.rows, scrollbackLines: this.terminal.buffer.normal.length - this.terminal.rows, - lastTitle: this.lastTitle ?? undefined, + lastTitle: this.oscText.lastTitle ?? undefined, // Why: written LAST by the restorer (after any reset) so the next live // chunk completes this dangling sequence instead of rendering it literally - // (#7329). Its bytes are already counted by the snapshot seq. + // (Bug E / #7329). Its bytes are already counted by the snapshot seq. ...(this.partialEscapeTail.length > 0 ? { pendingEscapeTailAnsi: this.partialEscapeTail } : {}) } + if (this.partialEscapeTail.length > 0) { + // Why a separate field, not part of snapshotAnsi: consumers write their + // own reset sequences after the snapshot body, and any ESC written after + // a dangling partial would abort it. The restorer must write this LAST, + // immediately before post-snapshot live chunks. Its bytes are already + // counted by the snapshot seq (they were ingested), so tail-slicing + // arithmetic is unchanged. + snapshot.pendingEscapeTailAnsi = this.partialEscapeTail + } + return snapshot } get isAlternateScreen(): boolean { return this.terminal.buffer.active.type === 'alternate' } + /** The dangling incomplete escape at the current stream position (empty + * when none). Scan-authority handoffs seed the other side's fact scanners + * with it so a sequence split across the handoff neither mints a phantom + * bell (unseen OSC terminator) nor loses its fact. Contains no complete + * sequence by construction, so seeding can never double-fire. */ + get partialEscapeTailAnsi(): string { + return this.partialEscapeTail + } + /** Why: PSReadLine's Ctrl+L repaint is only safe at an empty prompt — with * pending input it re-renders at a cached buffer row that ConPTY's fixed * viewport doesn't track, painting the input well below the prompt. The @@ -219,15 +394,15 @@ export class HeadlessEmulator { } getCwd(): string | null { - return this.cwd + return this.oscText.cwd } setCwd(cwd: string | null): void { - this.cwd = cwd + this.oscText.cwd = cwd } setLastTitle(title: string): void { - this.lastTitle = title + this.oscText.lastTitle = title } setRestoredOscLinks(links: TerminalOscLinkRange[] | undefined): void { @@ -244,102 +419,25 @@ export class HeadlessEmulator { this.terminal.dispose() } - private scanOsc7(data: string): void { - scanOsc7Uris(data, (uri) => { - this.parseOsc7Uri(uri) - }) - } - - private extractOscScanTail(input: string): string { - return extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT) - } - - private normalizeSnapshotAnsiForModes(snapshotAnsi: string, modes: TerminalModes): string { - if (!modes.alternateScreen) { - return snapshotAnsi - } - const alternateScreenMarker = '\x1b[?1049h' - const start = snapshotAnsi.lastIndexOf(alternateScreenMarker) - if (start === -1) { - return snapshotAnsi - } - // Why: rehydrateSequences already enters the alternate screen and restores - // mouse modes. Dropping SerializeAddon's duplicate ?1049h keeps mobile's - // "slice from last alt-screen marker" replay from discarding those modes. - return snapshotAnsi.slice(start + alternateScreenMarker.length) - } - - private parseOsc7Uri(uri: string): void { - const parsed = parseFileUriPath(uri, { - pathFlavor: this.pathFlavor, - remotePosixAuthority: this.remotePosixFileUriAuthority - }) - if (parsed) { - this.cwd = parsed - } - } - private getModes(): TerminalModes { const buffer = this.terminal.buffer.active - const mouseTrackingMode = this.privateModes.mouseTrackingMode + const mouseTrackingMode = this.mouseModes.mouseTrackingMode return { bracketedPaste: this.terminal.modes.bracketedPasteMode, mouseTracking: mouseTrackingMode !== 'none', mouseTrackingMode, - sgrMouseMode: this.privateModes.sgrMouseMode, - sgrMousePixelsMode: this.privateModes.sgrMousePixelsMode, + sgrMouseMode: this.mouseModes.sgrMouseMode, + sgrMousePixelsMode: this.mouseModes.sgrMousePixelsMode, applicationCursor: buffer.type === 'normal' ? this.terminal.modes.applicationCursorKeysMode : false, alternateScreen: buffer.type === 'alternate', - kittyKeyboardFlags: this.kittyKeyboardModes.flags + kittyKeyboardFlags: this.getKittyKeyboardFlags() } } - private buildRehydrateSequences(modes: TerminalModes): string { - const seqs: string[] = [] - if (modes.alternateScreen) { - seqs.push('\x1b[?1049h') - } - if (modes.bracketedPaste) { - seqs.push('\x1b[?2004h') - } - if (modes.applicationCursor) { - seqs.push('\x1b[?1h') - } - // Why: mobile alt-screen scroll gestures need xterm's mouse mode restored - // from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way. - switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') { - case 'x10': - seqs.push('\x1b[?9h') - break - case 'vt200': - seqs.push('\x1b[?1000h') - break - case 'drag': - seqs.push('\x1b[?1002h') - break - case 'any': - seqs.push('\x1b[?1003h') - break - case 'none': - break - } - // Why: xterm tracks the mouse protocol and SGR encoding as independent - // modes, so snapshots must preserve the encoding even when reporting is off. - if (modes.sgrMousePixelsMode) { - seqs.push('\x1b[?1016h') - } else if (modes.sgrMouseMode) { - seqs.push('\x1b[?1006h') - } - // Why: kitty keyboard flags are per-screen state SerializeAddon cannot - // capture; without re-arming them, the still-running TUI keeps expecting - // protocol-encoded keys the restored client no longer sends. `=` (set) - // instead of `>` (push) so repeated replays cannot grow the flag stack. - // Emitted after the alt-screen switch above so the flags land on the - // screen the TUI negotiated them on. - if (modes.kittyKeyboardFlags && modes.kittyKeyboardFlags > 0) { - seqs.push(`\x1b[=${modes.kittyKeyboardFlags};1u`) - } - return seqs.join('') + private getKittyKeyboardFlags(): number { + const flags = (this.terminal as TerminalWithSynchronousWrite)._core?.coreService?.kittyKeyboard + ?.flags + return typeof flags === 'number' ? flags : 0 } } diff --git a/src/main/daemon/history-manager.ts b/src/main/daemon/history-manager.ts index 9453488c2d9..47b96b14547 100644 --- a/src/main/daemon/history-manager.ts +++ b/src/main/daemon/history-manager.ts @@ -132,6 +132,24 @@ export class HistoryManager { }) } + // Why: wake after sleep re-spawns a session whose history was closed by the + // sleep-time kill. Re-register the writer without deleting checkpoint.json + // (still the only recovery data until the next tick) and clear endedAt so + // the next sleep can cold-restore this session again. + reopenSession(sessionId: string): void { + this.disabledSessions.delete(sessionId) + this.registerWriter(sessionId) + const writer = this.writers.get(sessionId) + if (!writer) { + return + } + try { + this.updateMeta(writer.dir, { endedAt: null, exitCode: null }) + } catch (err) { + this.handleWriteError(sessionId, err) + } + } + suspendSession(sessionId: string): void { // Why: if a fresh daemon cannot accept recovered scrollback, leaving its // writer active would let the next checkpoint overwrite the only good copy. diff --git a/src/main/daemon/history-reader.ts b/src/main/daemon/history-reader.ts index f6661b50044..8d31e3c638d 100644 --- a/src/main/daemon/history-reader.ts +++ b/src/main/daemon/history-reader.ts @@ -152,7 +152,13 @@ export class HistoryReader { }) try { if (checkpoint) { - if (!emulator.writeSync(checkpoint.rehydrateSequences + checkpoint.snapshotAnsi)) { + if ( + !emulator.writeSync( + (checkpoint.scrollbackAnsi ?? '') + + checkpoint.rehydrateSequences + + checkpoint.snapshotAnsi + ) + ) { return null } emulator.setRestoredOscLinks(checkpoint.oscLinks) @@ -198,12 +204,8 @@ export class HistoryReader { cwd: string | null, meta: SessionMeta ): ColdRestoreInfo { - // Why: HeadlessEmulator.getSnapshot() doesn't populate scrollbackAnsi - // (it's always ''). For non-alt-screen snapshots, snapshotAnsi IS the - // normal buffer content and is safe to use as scrollback. For alt-screen - // snapshots, snapshotAnsi is the serialized TUI buffer (not raw PTY - // stream); return empty instead — the adapter skips cold restore when - // scrollbackAnsi is falsy. + // Why: legacy normal snapshots stored their buffer only in snapshotAnsi; + // current alt snapshots carry their normal buffer in scrollbackAnsi. const scrollbackAnsi = snapshot.scrollbackAnsi || (snapshot.modes?.alternateScreen ? '' : snapshot.snapshotAnsi) return { diff --git a/src/main/daemon/pty-subprocess.ts b/src/main/daemon/pty-subprocess.ts index c732a16e1cf..119b3fb9430 100644 --- a/src/main/daemon/pty-subprocess.ts +++ b/src/main/daemon/pty-subprocess.ts @@ -1068,6 +1068,30 @@ export function createPtySubprocess(opts: PtySubprocessOptions): SubprocessHandl dead = true } }, + // Why pause/resume work on Windows too: node-pty's base Terminal + // implements both as socket pause/resume (lib/terminal.js), and + // WindowsTerminal wires _socket to the ConPTY conout pipe — pausing stops + // conout reads so ConPTY's bounded buffer backpressures the child. + pause: () => { + if (dead) { + return + } + try { + proc.pause() + } catch { + /* native handle already torn down — flow control is best-effort */ + } + }, + resume: () => { + if (dead) { + return + } + try { + proc.resume() + } catch { + /* native handle already torn down — flow control is best-effort */ + } + }, clear: () => { if (dead) { return diff --git a/src/main/daemon/reattach-snapshot.test.ts b/src/main/daemon/reattach-snapshot.test.ts index a8bdf252442..aec624cbf01 100644 --- a/src/main/daemon/reattach-snapshot.test.ts +++ b/src/main/daemon/reattach-snapshot.test.ts @@ -37,7 +37,7 @@ function createMockSubprocess(): SubprocessHandle & { // for a reattach. Alt-screen sessions include the full ANSI snapshot because function buildReattachPayload(snapshot: ReturnType) { const isAltScreen = snapshot.modes.alternateScreen - const fullPayload = snapshot.rehydrateSequences + snapshot.snapshotAnsi + const fullPayload = snapshot.scrollbackAnsi + snapshot.rehydrateSequences + snapshot.snapshotAnsi return { rehydrateSequences: snapshot.rehydrateSequences, snapshotAnsi: snapshot.snapshotAnsi, @@ -274,6 +274,24 @@ describe('reattach snapshot flow', () => { expect(payload.fullPayload).toContain('Codex TUI content') }) + it('returns to preserved shell history after the reattached TUI exits', async () => { + const emulator = new HeadlessEmulator({ cols: 80, rows: 10 }) + await emulator.write('shell output before Codex\r\n$ codex') + await emulator.write('\x1b[?1049h\x1b[2J\x1b[HCodex TUI content') + const payload = buildReattachPayload(emulator.getSnapshot()) + emulator.dispose() + + const replay = new HeadlessEmulator({ cols: 80, rows: 10 }) + try { + await replay.write(payload.fullPayload) + expect(replay.getVisibleLines().join('\n')).toContain('Codex TUI content') + await replay.write('\x1b[?1049l') + expect(replay.getVisibleLines().join('\n')).toContain('shell output before Codex') + } finally { + replay.dispose() + } + }) + it('SIGWINCH repaint after rehydrate produces clean single render', async () => { const h = createHost() await h.createOrAttach({ diff --git a/src/main/daemon/session-ingest-throughput.bench.test.ts b/src/main/daemon/session-ingest-throughput.bench.test.ts new file mode 100644 index 00000000000..e226ddf869d --- /dev/null +++ b/src/main/daemon/session-ingest-throughput.bench.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from 'vitest' +import { performance } from 'node:perf_hooks' +import { Session, type SubprocessHandle } from './session' + +// Benchmark harness for the terminal performance initiative: measures the +// daemon-side ingest rate (Session.handleSubprocessData -> HeadlessEmulator +// write + pending-output recording + client fanout) for the same workload +// shapes as tools/benchmarks/terminal-pipeline-bench.mjs. Bare headless +// xterm parses these at ~80-100 MB/s; the end-to-end Orca pipeline measured +// 2-15 MB/s (baseline-jul02) — this isolates the daemon layer's share. +// Run with: +// ORCA_TERMINAL_PERF_BENCH=1 pnpm vitest run \ +// src/main/daemon/session-ingest-throughput.bench.test.ts \ +// --config config/vitest.config.ts +const benchEnabled = process.env.ORCA_TERMINAL_PERF_BENCH === '1' + +const COLS = 114 +const ROWS = 85 +const TARGET_BYTES = 10 * 1024 * 1024 +const CHUNK = 64 * 1024 + +function asciiLog(targetBytes: number): string { + const parts: string[] = [] + let bytes = 0 + let line = 0 + while (bytes < targetBytes) { + line++ + const s = `\x1b[32m[build ${String(line).padStart(6, '0')}]\x1b[0m compile transform resolve bundle emit chunk module (${line % 5000}ms)\r\n` + parts.push(s) + bytes += s.length + } + return parts.join('') +} + +function agentTui(targetBytes: number): string { + const statusRows = 10 + const parts: string[] = [] + let bytes = 0 + let frame = 0 + let painted = false + const push = (s: string): void => { + parts.push(s) + bytes += Buffer.byteLength(s, 'utf8') + } + while (bytes < targetBytes) { + frame++ + push('\x1b[?2026h') + if (painted) { + push(`\x1b[${statusRows}A\x1b[0J`) + } + push(`\x1b[2m●\x1b[0m transcript line for frame ${frame} with some words\r\n`) + for (let r = 0; r < statusRows; r++) { + push( + `\x1b[38;5;${33 + (r % 6)}m⠼ task ${frame % 100}·${r}\x1b[0m ${'▇'.repeat((frame + r) % 40)}\r\n` + ) + } + painted = true + push('\x1b[?2026l') + } + return parts.join('') +} + +function makeSubprocess(): SubprocessHandle & { emit: (data: string) => void } { + let onData: ((data: string) => void) | null = null + return { + pid: 4242, + getForegroundProcess: () => 'bench', + write: () => {}, + resize: () => {}, + kill: () => {}, + forceKill: () => {}, + signal: () => {}, + onData: (cb) => { + onData = cb + }, + onExit: () => {}, + dispose: () => {}, + emit: (data: string) => onData?.(data) + } +} + +function ingest(fixture: string, drainPendingEveryChunks: number | null): number { + const subprocess = makeSubprocess() + const session = new Session({ + sessionId: 'bench', + cols: COLS, + rows: ROWS, + subprocess, + shellReadySupported: false + }) + session.attachClient({ onData: () => {}, onExit: () => {} }) + // Warmup primes JIT paths. + subprocess.emit(fixture.slice(0, 256 * 1024)) + session.takePendingOutput(false) + const start = performance.now() + let chunks = 0 + for (let i = 0; i < fixture.length; i += CHUNK) { + subprocess.emit(fixture.slice(i, i + CHUNK)) + chunks++ + // Why: without periodic takes the 2MB pending cap overflows and recording + // short-circuits, understating the real steady-state cost. The 5s adapter + // tick drains in production; drain per ~1.5MB approximates a hot session. + if (drainPendingEveryChunks && chunks % drainPendingEveryChunks === 0) { + session.takePendingOutput(false) + } + } + const ms = performance.now() - start + session.dispose() + return ms +} + +describe.skipIf(!benchEnabled)('daemon session ingest throughput', () => { + it('measures MB/s per workload shape', () => { + const rows: string[] = [] + for (const [name, fixture] of [ + ['ascii-log', asciiLog(TARGET_BYTES)], + ['agent-tui', agentTui(TARGET_BYTES)] + ] as const) { + const bytes = Buffer.byteLength(fixture, 'utf8') + const ms = ingest(fixture, 24) + const rate = bytes / 1024 / 1024 / (ms / 1000) + rows.push( + `${name}: ${rate.toFixed(1)} MB/s (${ms.toFixed(0)}ms for ${(bytes / 1024 / 1024).toFixed(1)}MB)` + ) + } + // eslint-disable-next-line no-console -- bench harness output + console.log(`\n[session-ingest] ${COLS}x${ROWS}\n ${rows.join('\n ')}`) + expect(rows.length).toBe(2) + }) +}) diff --git a/src/main/daemon/session.test.ts b/src/main/daemon/session.test.ts index 478b4ea99f0..ade8b18fe15 100644 --- a/src/main/daemon/session.test.ts +++ b/src/main/daemon/session.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Session } from './session' +import { PRODUCER_PAUSE_FAILSAFE_MS, Session } from './session' import type { SessionState, ShellReadyState } from './types' // Stub the subprocess — Session talks to it via an interface, not child_process directly. @@ -11,6 +11,8 @@ function createMockSubprocess() { let killed = false let clearCalls = 0 let pid = 12345 + let pauseCalls = 0 + let resumeCalls = 0 return { written, @@ -21,6 +23,12 @@ function createMockSubprocess() { get pid() { return pid }, + get pauseCalls() { + return pauseCalls + }, + get resumeCalls() { + return resumeCalls + }, foregroundProcess: null as string | null, getForegroundProcess(): string | null { return this.foregroundProcess @@ -29,6 +37,12 @@ function createMockSubprocess() { written.push(data) }, resize(_cols: number, _rows: number) {}, + pause() { + pauseCalls++ + }, + resume() { + resumeCalls++ + }, get clearCalls() { return clearCalls }, @@ -174,14 +188,22 @@ describe('Session', () => { describe('emulator does not reply to terminal queries', () => { // Why: daemon emulator parses in-process synchronously — before - // handleSubprocessData forwards bytes to the renderer over IPC — so any - // auto-reply it emits races ahead of the renderer's xterm and clobbers - // it with default-xterm values (no theme, stale cursor). The renderer is - // the authoritative responder; a daemon-side reply to any query is a bug. + // handleSubprocessData forwards bytes onward — so any auto-reply it + // emits races ahead of the live answerer and clobbers it with + // default-xterm values (no theme, stale cursor). Query authority is + // structural (terminal-query-authority.md): a delivered chunk is + // answered by the consuming view's xterm, a hidden-dropped chunk by + // MAIN's runtime model responder. The daemon emulator is neither — it + // stays write-only forever, and these pins are permanent. it.each([ + ['OSC 10 foreground-color', '\x1b]10;?\x07'], ['OSC 11 background-color', '\x1b]11;?\x07'], + ['OSC 12 cursor-color', '\x1b]12;?\x1b\\'], ['DA1 device-attributes', '\x1b[c'], - ['DSR cursor-position', '\x1b[6n'] + ['DA2 secondary device-attributes', '\x1b[>c'], + ['DSR terminal status', '\x1b[5n'], + ['DSR cursor-position', '\x1b[6n'], + ['DECRPM bracketed-paste mode', '\x1b[?2004$p'] ])('does not reply to %s query', async (_label, query) => { createSession({ shellReadySupported: false }) subprocess.simulateData(query) @@ -260,6 +282,15 @@ describe('Session', () => { expect(session.getSnapshot()?.snapshotAnsi).not.toContain('orca-shell-ready') }) + it('publishes an absolute output sequence with live snapshots', () => { + createSession() + subprocess.simulateData('first') + subprocess.simulateData('🟢second') + + expect(session.getSnapshot()?.outputSequence).toBe('first🟢second'.length) + expect(session.takePendingOutput(true)?.snapshot?.outputSequence).toBe('first🟢second'.length) + }) + it('releases held marker-prefix bytes before flushing queued input on timeout', () => { createSession({ shellReadySupported: true, shellReadyTimeoutMs: 100 }) const received: string[] = [] @@ -600,4 +631,103 @@ describe('Session', () => { expect(session.state).toBe('exited') }) }) + + describe('producer flow control', () => { + it('pauses the subprocess and auto-resumes via the lost-resume failsafe', () => { + createSession() + session.pauseProducer() + expect(subprocess.pauseCalls).toBe(1) + expect(subprocess.resumeCalls).toBe(0) + + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1) + expect(subprocess.resumeCalls).toBe(0) + vi.advanceTimersByTime(1) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('resumeProducer resumes once and cancels the failsafe timer', () => { + createSession() + session.pauseProducer() + session.resumeProducer() + expect(subprocess.resumeCalls).toBe(1) + + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS * 2) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('resumeProducer without a matching pause is a no-op', () => { + createSession() + session.resumeProducer() + expect(subprocess.resumeCalls).toBe(0) + }) + + it('re-pausing re-arms the failsafe window', () => { + createSession() + session.pauseProducer() + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1_000) + session.pauseProducer() + + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS - 1) + expect(subprocess.resumeCalls).toBe(0) + vi.advanceTimersByTime(1) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('kill() resumes a paused producer before signalling the child', () => { + createSession() + session.pauseProducer() + session.kill() + expect(subprocess.resumeCalls).toBe(1) + expect(subprocess.killed).toBe(true) + }) + + it('dispose() resumes a paused producer and clears the failsafe', () => { + createSession() + session.pauseProducer() + session.dispose() + expect(subprocess.resumeCalls).toBe(1) + expect(vi.getTimerCount()).toBe(0) + }) + + it('subprocess exit clears the failsafe without resuming a reaped child', () => { + createSession() + session.pauseProducer() + subprocess.simulateExit(0) + vi.advanceTimersByTime(PRODUCER_PAUSE_FAILSAFE_MS * 2) + expect(subprocess.resumeCalls).toBe(0) + }) + + it('ignores pauseProducer on an exited session', () => { + createSession() + subprocess.simulateExit(0) + session.pauseProducer() + expect(subprocess.pauseCalls).toBe(0) + expect(vi.getTimerCount()).toBe(0) + }) + + it('detaching the last client resumes a paused producer', () => { + createSession() + const token = session.attachClient({ onData: () => {}, onExit: () => {} }) + session.pauseProducer() + session.detachClient(token) + expect(subprocess.resumeCalls).toBe(1) + }) + + it('keeps the pause while another client is still attached', () => { + createSession() + const token = session.attachClient({ onData: () => {}, onExit: () => {} }) + session.attachClient({ onData: () => {}, onExit: () => {} }) + session.pauseProducer() + session.detachClient(token) + expect(subprocess.resumeCalls).toBe(0) + }) + + it('detachAllClients resumes a paused producer', () => { + createSession() + session.attachClient({ onData: () => {}, onExit: () => {} }) + session.pauseProducer() + session.detachAllClients() + expect(subprocess.resumeCalls).toBe(1) + }) + }) }) diff --git a/src/main/daemon/session.ts b/src/main/daemon/session.ts index 0d377c7f3f0..80987b5a1bb 100644 --- a/src/main/daemon/session.ts +++ b/src/main/daemon/session.ts @@ -31,6 +31,11 @@ const KILL_TIMEOUT_MS = 5_000 // Worst-case wire size for a full take is ~6x this (each control char // JSON-escapes to six bytes) and must stay under NDJSON_MAX_LINE_BYTES (16MB). const PENDING_OUTPUT_MAX_BYTES = 2 * 1024 * 1024 +// Why: producer pause is requested over a fire-and-forget notification, so the +// matching resume can be lost (main crash, dropped socket). A lost resume must +// never wedge a shell: auto-resume after this window; a still-flooded main +// re-asserts the pause on its next watermark check. +export const PRODUCER_PAUSE_FAILSAFE_MS = 5_000 export type SubprocessHandle = { pid: number @@ -42,6 +47,11 @@ export type SubprocessHandle = { startupCommandDeliveredInShellArgs?: boolean write(data: string): void resize(cols: number, rows: number): void + /** Stop reading the PTY fd (node-pty pause()) so the kernel/ConPTY buffer + * fills and a flooding child blocks on write. Optional: handles that + * cannot pause simply omit it and flow control degrades to a no-op. */ + pause?(): void + resume?(): void /** Resync the native PTY's own screen state after a frontend clear. * No-op except on Windows/ConPTY, where a stale ConPTY cursor row makes * the next prompt repaint land below a blank gap. */ @@ -102,6 +112,9 @@ export class Session { private pendingOutputBytes = 0 private pendingOutputOverflowed = false private pendingOutputSeq = 0 + private outputSequence = 0 + private producerPaused = false + private producerPauseFailsafeTimer: ReturnType | null = null private readonly _historySeeded: boolean | undefined constructor(opts: SessionOptions) { @@ -198,12 +211,52 @@ export class Session { this.subprocess.resize(cols, rows) } + /** Producer-side flow control: stop reading the PTY fd so the flooding + * child blocks on write (kernel backpressure). Arms the lost-resume + * failsafe; re-pausing re-arms it (main re-asserts during long floods). */ + pauseProducer(): void { + if (this._state === 'exited' || this._disposed) { + return + } + this.producerPaused = true + this.subprocess.pause?.() + if (this.producerPauseFailsafeTimer) { + clearTimeout(this.producerPauseFailsafeTimer) + } + this.producerPauseFailsafeTimer = setTimeout(() => { + this.producerPauseFailsafeTimer = null + this.producerPaused = false + this.subprocess.resume?.() + }, PRODUCER_PAUSE_FAILSAFE_MS) + } + + resumeProducer(): void { + this.releaseProducerPause({ resume: true }) + } + + private releaseProducerPause(opts: { resume: boolean }): void { + if (this.producerPauseFailsafeTimer) { + clearTimeout(this.producerPauseFailsafeTimer) + this.producerPauseFailsafeTimer = null + } + if (!this.producerPaused) { + return + } + this.producerPaused = false + if (opts.resume) { + this.subprocess.resume?.() + } + } + kill(): void { if (this._state === 'exited' || this._isTerminating) { return } this._isTerminating = true + // Why: a paused child can be blocked inside write(); resume before + // signalling so it can run signal handlers and actually exit. + this.releaseProducerPause({ resume: true }) this.subprocess.kill() this.killTimer = setTimeout(() => { @@ -231,17 +284,30 @@ export class Session { if (idx !== -1) { this.attachedClients.splice(idx, 1) } + // Why: with no attached client, nobody will ever send resumePty — a + // paused shell would sit wedged until the failsafe. Resume eagerly. + if (this.attachedClients.length === 0) { + this.releaseProducerPause({ resume: true }) + } } detachAllClients(): void { this.attachedClients.length = 0 + this.releaseProducerPause({ resume: true }) } - getSnapshot(): TerminalSnapshot | null { + getSnapshot(opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { if (this._disposed) { return null } - return this.emulator.getSnapshot() + return { ...this.emulator.getSnapshot(opts), outputSequence: this.outputSequence } + } + + getPartialEscapeTailAnsi(): string { + if (this._disposed) { + return '' + } + return this.emulator.partialEscapeTailAnsi } // Why: the size the PTY actually applied (emulator dims, which Session.resize @@ -282,7 +348,7 @@ export class Session { : records, seq: this.pendingOutputSeq, overflowed, - snapshot: includeSnapshot ? this.emulator.getSnapshot() : null + snapshot: includeSnapshot ? this.getSnapshot() : null } } @@ -424,6 +490,9 @@ export class Session { return } this._disposed = true + // Why: never leave a paused fd behind on any teardown path — the handle's + // own dead-guard makes this a no-op when the child is already reaped. + this.releaseProducerPause({ resume: true }) if (this.killTimer) { clearTimeout(this.killTimer) this.killTimer = null @@ -490,6 +559,10 @@ export class Session { return } + // Why: daemon stream thinning can omit bytes before main sees them. The + // absolute count lets an authoritative snapshot cover those gaps while + // renderer reconciliation deduplicates any queued post-snapshot tail. + this.outputSequence += data.length // Feed data to headless emulator for state tracking this.emulator.write(data) this.recordPendingOutput({ kind: 'output', data }) @@ -507,6 +580,9 @@ export class Session { this._exitCode = code this._state = 'exited' + // Why resume:false — the child is reaped, so there is nothing to unblock; + // only the failsafe timer must not outlive the session. + this.releaseProducerPause({ resume: false }) this.releaseHeldShellReadyBytes() if (this.killTimer) { diff --git a/src/main/daemon/terminal-history-incremental-restore.test.ts b/src/main/daemon/terminal-history-incremental-restore.test.ts index 8cd9a42a307..544ff2a4c75 100644 --- a/src/main/daemon/terminal-history-incremental-restore.test.ts +++ b/src/main/daemon/terminal-history-incremental-restore.test.ts @@ -183,7 +183,7 @@ describe('incremental terminal history restore', () => { expect(restore!.scrollbackAnsi).not.toContain('cleared away') }) - it('skips restorable content for sessions crashed inside the alt screen', async () => { + it('preserves normal history without treating active alt content as scrollback', async () => { await manager.appendIncrements(SESSION_ID, 1, [ { kind: 'output', data: 'normal output\r\n\x1b[?1049halt screen content' } ]) @@ -191,9 +191,8 @@ describe('incremental terminal history restore', () => { const restore = reader.detectColdRestore(SESSION_ID) expect(restore).not.toBeNull() expect(restore!.modes.alternateScreen).toBe(true) - // Why: the adapter skips cold restore when scrollbackAnsi is empty — alt - // buffer contents must not replay into a fresh shell. - expect(restore!.scrollbackAnsi).toBe('') + expect(restore!.scrollbackAnsi).toContain('normal output') + expect(restore!.scrollbackAnsi).not.toContain('alt screen content') }) it('resets the log on checkpoint so old records are not replayed twice', async () => { diff --git a/src/main/daemon/terminal-host.ts b/src/main/daemon/terminal-host.ts index 8b8968931f5..644356a6c4f 100644 --- a/src/main/daemon/terminal-host.ts +++ b/src/main/daemon/terminal-host.ts @@ -197,6 +197,21 @@ export class TerminalHost { this.getAliveSession(sessionId).resize(cols, rows) } + // Why null-not-throw (unlike write/resize): pause/resume are best-effort + // flow-control hints; a session that exited while the notify was in flight + // must not surface an error or a synthetic exit. + pauseProducer(sessionId: string): void { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + return + } + session.pauseProducer() + } + + resumeProducer(sessionId: string): void { + this.sessions.get(sessionId)?.resumeProducer() + } + kill(sessionId: string, opts: { immediate?: boolean } = {}): void { const session = this.getAliveSession(sessionId) this.recordTombstone(sessionId) @@ -266,12 +281,22 @@ export class TerminalHost { // Why: unlike getAliveSession (which throws), this returns null for dead/missing // sessions. Checkpoint is best-effort — a session that exited between the timer // firing and the RPC arriving should not throw. - getSnapshot(sessionId: string): TerminalSnapshot | null { + getSnapshot(sessionId: string, opts: { scrollbackRows?: number } = {}): TerminalSnapshot | null { const session = this.sessions.get(sessionId) if (!session || !session.isAlive) { return null } - return session.getSnapshot() + return session.getSnapshot(opts) + } + + // Why: scan-authority handoff seed (null-not-throw like getSnapshot) — the + // emulator's dangling incomplete escape at the current stream position. + getPartialEscapeTailAnsi(sessionId: string): string { + const session = this.sessions.get(sessionId) + if (!session || !session.isAlive) { + return '' + } + return session.getPartialEscapeTailAnsi() } // Why: read-only readback of the size the PTY actually applied (null-not-throw diff --git a/src/main/daemon/terminal-mode-rehydrate-sequences.ts b/src/main/daemon/terminal-mode-rehydrate-sequences.ts new file mode 100644 index 00000000000..77e3603d4f5 --- /dev/null +++ b/src/main/daemon/terminal-mode-rehydrate-sequences.ts @@ -0,0 +1,47 @@ +import type { TerminalModes } from './types' + +// Why no kitty flags here: rehydrateSequences feeds renderer xterms, and +// POST_REPLAY_REATTACH_RESET's deliberate kitty reset (stale CSI-u Ctrl+C +// hazard) must stay authoritative. modes.kittyKeyboardFlags exists for +// emulator re-seed parity only; a re-seeded emulator answers ?0u and +// protocol-conformant programs re-push. +export function buildRehydrateSequences(modes: TerminalModes): string { + const seqs: string[] = [] + if (modes.alternateScreen) { + // Why: normal-buffer serialization can leave its pen active, while the + // separately serialized alt body assumes it starts from default SGR. + seqs.push('\x1b[0m\x1b[?1049h') + } + if (modes.bracketedPaste) { + seqs.push('\x1b[?2004h') + } + if (modes.applicationCursor) { + seqs.push('\x1b[?1h') + } + // Why: mobile alt-screen scroll gestures need xterm's mouse mode restored + // from cold snapshots; OpenCode/OpenTUI enables scrollable panes this way. + switch (modes.mouseTracking ? (modes.mouseTrackingMode ?? 'vt200') : 'none') { + case 'x10': + seqs.push('\x1b[?9h') + break + case 'vt200': + seqs.push('\x1b[?1000h') + break + case 'drag': + seqs.push('\x1b[?1002h') + break + case 'any': + seqs.push('\x1b[?1003h') + break + case 'none': + break + } + // Why: xterm tracks the mouse protocol and SGR encoding as independent + // modes, so snapshots must preserve the encoding even when reporting is off. + if (modes.sgrMousePixelsMode) { + seqs.push('\x1b[?1016h') + } else if (modes.sgrMouseMode) { + seqs.push('\x1b[?1006h') + } + return seqs.join('') +} diff --git a/src/main/daemon/terminal-mouse-mode-mirror.ts b/src/main/daemon/terminal-mouse-mode-mirror.ts new file mode 100644 index 00000000000..8f8b284f7f4 --- /dev/null +++ b/src/main/daemon/terminal-mouse-mode-mirror.ts @@ -0,0 +1,119 @@ +import type { TerminalModes } from './types' + +type MouseTrackingMode = NonNullable + +// Why: PTY/SSH chunks can split a long combined DECSET before the final h/l. +// Keep parser state far beyond normal mode lists while still bounding memory. +const PRIVATE_MODE_SCAN_TAIL_LIMIT = 4096 + +/** + * Mirrors DECSET mouse-protocol/encoding state from the raw byte stream. + * xterm's public modes API does not expose which mouse protocol is active, + * so snapshots track it independently of the headless terminal; callers + * must feed `scan()` the same bytes the terminal parsed, in order. + */ +export class TerminalMouseModeMirror { + private scanTail = '' + private trackingModeState: MouseTrackingMode = 'none' + private sgrMouseModeState = false + private sgrMousePixelsModeState = false + + get mouseTrackingMode(): MouseTrackingMode { + return this.trackingModeState + } + + get sgrMouseMode(): boolean { + return this.sgrMouseModeState + } + + get sgrMousePixelsMode(): boolean { + return this.sgrMousePixelsModeState + } + + scan(data: string): void { + // Why the pre-filter: this runs on the daemon's per-chunk hot path for + // every session; a flood chunk with no private-mode/reset introducer + // must not pay the regex pass (measured share of a 2.2x ingest + // regression — findings log 2026-07-03). Split sequences stay correct: + // an introducer split across chunks either left a non-empty scanTail + // (previous partial) or ends this chunk, which extractScanTail retains. + if ( + this.scanTail.length === 0 && + !data.includes('\x1b[?') && + !data.includes('\x1bc') && + !data.includes('\x9b') + ) { + this.scanTail = this.extractScanTail(data) + return + } + const input = this.scanTail.length === 0 ? data : this.scanTail + data + this.scanTail = this.extractScanTail(input) + // oxlint-disable-next-line no-control-regex -- terminal escape sequences require control chars + const privateModeRe = /\x1bc|\x1b\[\?([0-9;]+)([hl])|\x9b\?([0-9;]+)([hl])/g + let match: RegExpExecArray | null + while ((match = privateModeRe.exec(input)) !== null) { + if (match[0] === '\x1bc') { + this.trackingModeState = 'none' + this.sgrMouseModeState = false + this.sgrMousePixelsModeState = false + continue + } + const params = match[1] ?? match[3] + const enabled = (match[2] ?? match[4]) === 'h' + for (const rawParam of params.split(';')) { + if (rawParam === '') { + continue + } + const param = Number(rawParam) + if (!Number.isInteger(param)) { + continue + } + if (param === 9) { + this.trackingModeState = enabled ? 'x10' : 'none' + } + if (param === 1000) { + this.trackingModeState = enabled ? 'vt200' : 'none' + } + if (param === 1002) { + this.trackingModeState = enabled ? 'drag' : 'none' + } + if (param === 1003) { + this.trackingModeState = enabled ? 'any' : 'none' + } + if (param === 1006) { + this.sgrMouseModeState = enabled + this.sgrMousePixelsModeState = false + } + if (param === 1016) { + this.sgrMouseModeState = false + this.sgrMousePixelsModeState = enabled + } + } + } + } + + private extractScanTail(input: string): string { + const start = Math.max(input.lastIndexOf('\x1b'), input.lastIndexOf('\x9b')) + if (start === -1) { + return '' + } + const tail = input.slice(start) + if (tail.length > PRIVATE_MODE_SCAN_TAIL_LIMIT) { + return '' + } + if (tail === '\x1b' || tail === '\x1b[' || tail === '\x9b') { + return tail + } + if (tail.startsWith('\x1b[?')) { + return this.isIncompleteParams(tail.slice(3)) ? tail : '' + } + if (tail.startsWith('\x9b?')) { + return this.isIncompleteParams(tail.slice(2)) ? tail : '' + } + return '' + } + + private isIncompleteParams(params: string): boolean { + return /^[0-9;]*$/.test(params) + } +} diff --git a/src/main/daemon/terminal-osc-cwd-title-scanner.ts b/src/main/daemon/terminal-osc-cwd-title-scanner.ts new file mode 100644 index 00000000000..f3736d7c009 --- /dev/null +++ b/src/main/daemon/terminal-osc-cwd-title-scanner.ts @@ -0,0 +1,53 @@ +import { extractLastOscTitle } from '../../shared/agent-detection' +import { parseFileUriPath } from './osc7-file-uri' +import { extractOscScanTail, scanOsc7Uris } from './osc7-uri-extraction' + +const OSC_SCAN_TAIL_LIMIT = 4096 + +/** Mirror of the OSC sequences the emulator tracks outside xterm: OSC 7 cwd + * updates and OSC 0/2 titles. Keeps an unterminated-sequence tail so + * sequences split across PTY chunks still parse. Uses the bounded regex-free + * scanners so giant pasted chunks stay cheap. */ +export type TerminalOscCwdTitleScannerOptions = { + pathFlavor?: 'posix' | 'win32' + remotePosixAuthority?: boolean +} + +export class TerminalOscCwdTitleScanner { + private scanTail = '' + private readonly parseOptions: TerminalOscCwdTitleScannerOptions + cwd: string | null = null + lastTitle: string | null = null + + constructor(options: TerminalOscCwdTitleScannerOptions = {}) { + this.parseOptions = options + } + + scan(data: string): void { + // Why the pre-filter: this runs on the daemon's per-chunk hot path; flood + // chunks with no OSC introducer must not pay the title/URI walks + // (measured share of a 2.2x ingest regression — findings log 2026-07-03). + // Correctness across splits: an OSC intro spanning chunks either left a + // non-empty scanTail or this chunk ends with a bare ESC, which + // extractOscScanTail retains for the next call. + if (this.scanTail.length === 0 && !data.includes('\x1b]')) { + this.scanTail = data.endsWith('\x1b') ? extractOscScanTail(data, OSC_SCAN_TAIL_LIMIT) : '' + return + } + const input = this.scanTail.length === 0 ? data : this.scanTail + data + this.scanTail = extractOscScanTail(input, OSC_SCAN_TAIL_LIMIT) + scanOsc7Uris(input, (uri) => { + const parsed = parseFileUriPath(uri, { + pathFlavor: this.parseOptions.pathFlavor, + remotePosixAuthority: this.parseOptions.remotePosixAuthority + }) + if (parsed) { + this.cwd = parsed + } + }) + const lastTitle = extractLastOscTitle(input) + if (lastTitle !== null) { + this.lastTitle = lastTitle + } + } +} diff --git a/src/main/daemon/terminal-snapshot-ansi-buffers.ts b/src/main/daemon/terminal-snapshot-ansi-buffers.ts new file mode 100644 index 00000000000..e0b16d56d8f --- /dev/null +++ b/src/main/daemon/terminal-snapshot-ansi-buffers.ts @@ -0,0 +1,21 @@ +import type { TerminalModes } from './types' + +export function splitTerminalSnapshotAnsi( + snapshotAnsi: string, + modes: TerminalModes +): { snapshotAnsi: string; scrollbackAnsi: string } { + if (!modes.alternateScreen) { + return { snapshotAnsi, scrollbackAnsi: '' } + } + const alternateScreenMarker = '\x1b[?1049h' + const start = snapshotAnsi.lastIndexOf(alternateScreenMarker) + if (start === -1) { + return { snapshotAnsi, scrollbackAnsi: '' } + } + // Why: rehydrateSequences owns the alt-screen transition. Keeping the + // normal buffer separate lets an already-alt renderer rebuild it safely. + return { + scrollbackAnsi: snapshotAnsi.slice(0, start), + snapshotAnsi: snapshotAnsi.slice(start + alternateScreenMarker.length) + } +} diff --git a/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts b/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts new file mode 100644 index 00000000000..b54227652dd --- /dev/null +++ b/src/main/daemon/terminal-snapshot-serialize-roundtrip.test.ts @@ -0,0 +1,254 @@ +// Round-trip guards for two @xterm/addon-serialize defects that garbled +// hidden-terminal snapshot restores (serialize a buffer, replay into a fresh +// identical terminal, compare): +// +// BUG B (fixed by config/patches/@xterm__addon-serialize@*.patch): the SGR +// attribute diff emitted bold/dim set params before the shared intensity +// reset 22, so "1;22" wiped a freshly set bold and a bare "22" dropped a +// still-set bold/dim. +// +// BUG C (hardened Orca-side via serializeWithAbsoluteCursor): a final content +// row filled exactly to the right margin leaves replay wrap-pending, and the +// addon's RELATIVE cursor restore then lands one column short. +import './xterm-env-polyfill' +import { describe, expect, it } from 'vitest' +import { Terminal } from '@xterm/headless' +import { SerializeAddon } from '@xterm/addon-serialize' +import { HeadlessEmulator } from './headless-emulator' +import { serializeWithAbsoluteCursor } from '../../shared/terminal-serialize-absolute-cursor' + +type TerminalHarness = { terminal: Terminal; addon: SerializeAddon } + +function createTerminal(cols = 10, rows = 5, scrollback = 100): TerminalHarness { + const terminal = new Terminal({ cols, rows, scrollback, allowProposedApi: true }) + const addon = new SerializeAddon() + terminal.loadAddon(addon) + return { terminal, addon } +} + +function write(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, () => resolve())) +} + +async function replay(data: string, cols = 10, rows = 5, scrollback = 100): Promise { + const { terminal } = createTerminal(cols, rows, scrollback) + await write(terminal, data) + return terminal +} + +function cellAt( + terminal: Terminal, + viewportRow: number, + col: number +): NonNullable< + ReturnType>['getCell']> +> { + const buffer = terminal.buffer.active + const line = buffer.getLine(buffer.baseY + viewportRow) + if (!line) { + throw new Error(`no line at viewport row ${viewportRow}`) + } + const cell = line.getCell(col) + if (!cell) { + throw new Error(`no cell at ${viewportRow},${col}`) + } + return cell +} + +function visibleText(terminal: Terminal): string[] { + const buffer = terminal.buffer.active + const lines: string[] = [] + for (let row = 0; row < terminal.rows; row += 1) { + lines.push(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '') + } + return lines +} + +async function roundTripStyles(source: string): Promise { + const { terminal, addon } = createTerminal() + await write(terminal, source) + return replay(addon.serialize()) +} + +describe('SGR intensity round-trip (BUG B, addon patch)', () => { + it('restores bold set immediately after dim is cleared (minimized repro)', async () => { + const restored = await roundTripStyles('\x1b[2mA\x1b[22m\x1b[1mB') + const b = cellAt(restored, 0, 1) + expect(b.getChars()).toBe('B') + expect(!!b.isBold()).toBe(true) + expect(!!b.isDim()).toBe(false) + }) + + it('keeps bold when dim is dropped from a bold+dim run', async () => { + const restored = await roundTripStyles('\x1b[1;2mA\x1b[22m\x1b[1mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isBold()).toBe(true) + expect(!!b.isDim()).toBe(false) + }) + + it('keeps dim when bold is dropped from a bold+dim run', async () => { + const restored = await roundTripStyles('\x1b[1;2mA\x1b[22m\x1b[2mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isDim()).toBe(true) + expect(!!b.isBold()).toBe(false) + }) + + it('non-regression: bold after normal text', async () => { + const restored = await roundTripStyles('A\x1b[1mB') + expect(!!cellAt(restored, 0, 0).isBold()).toBe(false) + expect(!!cellAt(restored, 0, 1).isBold()).toBe(true) + }) + + it('non-regression: dim after bold', async () => { + const restored = await roundTripStyles('\x1b[1mA\x1b[22m\x1b[2mB') + const a = cellAt(restored, 0, 0) + const b = cellAt(restored, 0, 1) + expect(!!a.isBold()).toBe(true) + expect(!!a.isDim()).toBe(false) + expect(!!b.isDim()).toBe(true) + expect(!!b.isBold()).toBe(false) + }) + + it('non-regression: bold+dim accumulation survives', async () => { + const restored = await roundTripStyles('\x1b[1mA\x1b[2mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isBold()).toBe(true) + expect(!!b.isDim()).toBe(true) + }) + + it('non-regression: italic after underline is cleared (dedicated resets)', async () => { + const restored = await roundTripStyles('\x1b[4mA\x1b[24m\x1b[3mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isItalic()).toBe(true) + expect(!!b.isUnderline()).toBe(false) + }) + + it('non-regression: underline after italic is cleared (dedicated resets)', async () => { + const restored = await roundTripStyles('\x1b[3mA\x1b[23m\x1b[4mB') + const b = cellAt(restored, 0, 1) + expect(!!b.isUnderline()).toBe(true) + expect(!!b.isItalic()).toBe(false) + }) + + it('non-regression: underline dropped alongside bold set', async () => { + const restored = await roundTripStyles('\x1b[4mA\x1b[24m\x1b[1;4mB\x1b[24mC') + const c = cellAt(restored, 0, 2) + expect(!!c.isBold()).toBe(true) + expect(!!c.isUnderline()).toBe(false) + }) +}) + +describe('cursor restore after wrap-pending replay (BUG C, absolute-cursor hardening)', () => { + const REPRO = '0123456789\x1b[3;5H' + + it('documents the upstream defect: plain serialize lands one column short', async () => { + // Why this pin: the Orca hardening exists only because of this relative- + // restore defect. If an addon bump makes this fail, the hardening can go. + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, REPRO) + expect(terminal.buffer.active.cursorX).toBe(4) + const restored = await replay(addon.serialize()) + expect(restored.buffer.active.cursorX).toBe(3) + }) + + it('HeadlessEmulator snapshot restores the exact cursor (minimized repro)', async () => { + const emulator = new HeadlessEmulator({ cols: 10, rows: 5 }) + expect(emulator.writeSync(REPRO)).toBe(true) + const snapshot = emulator.getSnapshot() + const restored = await replay(snapshot.snapshotAnsi) + expect(restored.buffer.active.cursorX).toBe(4) + expect(restored.buffer.active.cursorY).toBe(2) + expect(visibleText(restored)[0]).toBe('0123456789') + emulator.dispose() + }) + + it('serializeWithAbsoluteCursor restores the exact cursor', async () => { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, REPRO) + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(restored.buffer.active.cursorX).toBe(4) + expect(restored.buffer.active.cursorY).toBe(2) + }) + + it('never changes already-correct restores at various cursor positions', async () => { + const positions = ['\x1b[1;1H', '\x1b[2;4H', '\x1b[5;10H', '\x1b[4;1H'] + for (const cup of positions) { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, `hello\r\nworld${cup}`) + const plainRestore = await replay(addon.serialize()) + const hardenedRestore = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(hardenedRestore.buffer.active.cursorX).toBe(terminal.buffer.active.cursorX) + expect(hardenedRestore.buffer.active.cursorY).toBe(terminal.buffer.active.cursorY) + expect(hardenedRestore.buffer.active.cursorX).toBe(plainRestore.buffer.active.cursorX) + expect(hardenedRestore.buffer.active.cursorY).toBe(plainRestore.buffer.active.cursorY) + expect(visibleText(hardenedRestore)).toEqual(visibleText(plainRestore)) + } + }) + + it('leaves a wrap-pending source untouched so replay stays wrap-pending', async () => { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, '0123456789') + // cursorX == cols marks pending wrap; a CUP would clamp and clear it. + expect(terminal.buffer.active.cursorX).toBe(10) + const plain = addon.serialize() + expect(serializeWithAbsoluteCursor(addon, terminal)).toBe(plain) + const restored = await replay(plain) + await write(restored, 'Z') + expect(visibleText(restored)[1]).toBe('Z') + }) + + it('handles a wrap-pending row that is NOT the final content row', async () => { + const { terminal, addon } = createTerminal(10, 5) + // Row 0 fills to the margin and wraps into row 1, then the cursor moves. + await write(terminal, '0123456789ABC\x1b[2;2H') + expect(terminal.buffer.active.cursorY).toBe(1) + expect(terminal.buffer.active.cursorX).toBe(1) + const plainRestore = await replay(addon.serialize()) + const hardenedRestore = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(hardenedRestore.buffer.active.cursorX).toBe(1) + expect(hardenedRestore.buffer.active.cursorY).toBe(1) + expect(visibleText(hardenedRestore)).toEqual(visibleText(plainRestore)) + expect(hardenedRestore.buffer.active.cursorX).toBe(plainRestore.buffer.active.cursorX) + expect(hardenedRestore.buffer.active.cursorY).toBe(plainRestore.buffer.active.cursorY) + }) + + it('restores alt-screen snapshots without disturbing correct positioning', async () => { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, 'shell$\x1b[?1049h\x1b[2J\x1b[HTUI ROW\x1b[2;3H') + expect(terminal.buffer.active.type).toBe('alternate') + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(restored.buffer.active.type).toBe('alternate') + expect(restored.buffer.active.cursorX).toBe(2) + expect(restored.buffer.active.cursorY).toBe(1) + expect(visibleText(restored)[0]).toBe('TUI ROW') + }) + + it('fixes the wrap-pending off-by-one inside the alt screen too', async () => { + const { terminal, addon } = createTerminal(10, 5) + await write(terminal, '\x1b[?1049h0123456789\x1b[3;5H') + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal)) + expect(restored.buffer.active.type).toBe('alternate') + expect(restored.buffer.active.cursorX).toBe(4) + expect(restored.buffer.active.cursorY).toBe(2) + }) + + it('restores scrolled-back buffers with the cursor at its base-relative spot', async () => { + const { terminal, addon } = createTerminal(10, 3, 50) + for (let i = 0; i < 8; i += 1) { + await write(terminal, `line${i}\r\n`) + } + await write(terminal, '\x1b[2;3H') + const source = { x: terminal.buffer.active.cursorX, y: terminal.buffer.active.cursorY } + const restored = await replay(serializeWithAbsoluteCursor(addon, terminal), 10, 3, 50) + expect(restored.buffer.active.cursorX).toBe(source.x) + expect(restored.buffer.active.cursorY).toBe(source.y) + expect(visibleText(restored)).toEqual(visibleText(terminal)) + expect(restored.buffer.active.length).toBe(terminal.buffer.active.length) + }) + + it('keeps empty buffers serializing to an empty string', async () => { + const { terminal, addon } = createTerminal(10, 5) + expect(addon.serialize()).toBe('') + expect(serializeWithAbsoluteCursor(addon, terminal)).toBe('') + }) +}) diff --git a/src/main/daemon/terminal-view-attribute-responder.ts b/src/main/daemon/terminal-view-attribute-responder.ts new file mode 100644 index 00000000000..68ff83c349f --- /dev/null +++ b/src/main/daemon/terminal-view-attribute-responder.ts @@ -0,0 +1,191 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): OSC 4/10/11/12 and DSR ?996n responder handlers for the runtime + * headless emulator. The headless xterm core has no theme service, so these + * handlers compute replies from the renderer's pushed attribute snapshot, + * with per-PTY OSC SET mutations layered on top — mirroring exactly what the + * renderer's ThemeService reports for a visible pane. Replies route through + * the caller's emit sink, which the slice-1 forwarding window already gates, + * so seeded/replayed bytes and delivered chunks never produce a reply. + */ +import type { Terminal } from '@xterm/headless' +import { + formatXColorRgbSpec, + parseXColorSpec, + TERMINAL_VIEW_ANSI_COLOR_COUNT, + type TerminalViewAttributes, + type TerminalViewRgb +} from '../../shared/terminal-view-attributes' + +type ViewAttributeParser = Pick + +export type TerminalViewAttributeResponderDeps = { + parser: ViewAttributeParser + /** Last renderer push, or null before the first push. Null means SILENCE + * for every view-attribute query — a fabricated default would resurrect + * the default-black OSC-11 bug (design invariant 3). */ + getBaseAttributes: () => TerminalViewAttributes | null + /** Must already be replay/forwarding-window gated by the caller. */ + emitReply: (reply: string) => void +} + +export type TerminalViewAttributeResponder = { + /** A changed renderer attribute push replaces the whole palette, exactly + * like xterm's ThemeService `_setTheme` overwrites OSC-SET-mutated colors + * on a visible pane's theme apply. Identical re-pushes (fresh renderer + * process) are filtered in main's store and never reach this. */ + clearColorOverrides: () => void +} + +type SpecialColorSlot = 'foreground' | 'background' | 'cursor' + +// OSC 10/11/12 stack extra params onto consecutive slots (xterm's +// _setOrReportSpecialColor): `OSC 10;?;?` reports foreground then background. +const SPECIAL_COLOR_SLOTS: SpecialColorSlot[] = ['foreground', 'background', 'cursor'] +const SPECIAL_COLOR_IDENTS: Record = { + foreground: '10', + background: '11', + cursor: '12' +} + +function isValidColorIndex(value: number): boolean { + return value >= 0 && value < TERMINAL_VIEW_ANSI_COLOR_COUNT +} + +// Mirror of xterm's rgb.relativeLuminance2 (common/Color.ts, WCAG formula) — +// the math CoreBrowserTerminal._reportColorScheme answers ?996n with. +function relativeLuminance([r, g, b]: TerminalViewRgb): number { + const linear = (channel: number): number => { + const c = channel / 255 + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + } + return linear(r) * 0.2126 + linear(g) * 0.7152 + linear(b) * 0.0722 +} + +export function installTerminalViewAttributeResponder( + deps: TerminalViewAttributeResponderDeps +): TerminalViewAttributeResponder { + // Why per-instance maps: SET mutations are per PTY (one emulator per PTY); + // they die with the emulator at teardown, like every other model state. + // They deliberately survive a reveal→re-hide cycle even though the revealed + // xterm restores without palette mutations (SerializeAddon emits no OSC + // color SETs): the TUI never reset its SET, so holding it is + // protocol-correct — the visible-side loss is the pre-existing restore + // limitation, not this model's. + const ansiOverrides = new Map() + const specialOverrides = new Map() + + const reportColor = (ident: string, rgb: TerminalViewRgb): void => { + // Why ST (not BEL) and 16-bit channels: byte-for-byte parity with the + // renderer xterm's reply (CoreBrowserTerminal._handleColorEvent). + deps.emitReply(`\x1b]${ident};${formatXColorRgbSpec(rgb)}\x1b\\`) + } + + const handleSpecialColor = (data: string, offset: number): boolean => { + const slots = data.split(';') + for (let i = 0; i < slots.length; ++i, ++offset) { + if (offset >= SPECIAL_COLOR_SLOTS.length) { + break + } + const slot = SPECIAL_COLOR_SLOTS[offset] + if (slots[i] === '?') { + const base = deps.getBaseAttributes() + if (base) { + reportColor(SPECIAL_COLOR_IDENTS[slot], specialOverrides.get(slot) ?? base[slot]) + } + } else { + const rgb = parseXColorSpec(slots[i]) + if (rgb) { + specialOverrides.set(slot, rgb) + } + } + } + // True consumes the sequence; the headless core's own OSC 10/11/12 + // handler only fires an onColor event nothing consumes. + return true + } + + deps.parser.registerOscHandler(4, (data) => { + const slots = data.split(';') + while (slots.length > 1) { + const idx = slots.shift() as string + const spec = slots.shift() as string + if (!/^\d+$/.test(idx)) { + continue + } + const index = Number.parseInt(idx, 10) + if (!isValidColorIndex(index)) { + continue + } + if (spec === '?') { + const base = deps.getBaseAttributes() + if (base) { + reportColor(`4;${index}`, ansiOverrides.get(index) ?? base.ansi[index]) + } + } else { + const rgb = parseXColorSpec(spec) + if (rgb) { + ansiOverrides.set(index, rgb) + } + } + } + return true + }) + deps.parser.registerOscHandler(10, (data) => handleSpecialColor(data, 0)) + deps.parser.registerOscHandler(11, (data) => handleSpecialColor(data, 1)) + deps.parser.registerOscHandler(12, (data) => handleSpecialColor(data, 2)) + + // OSC 104/110/111/112 restore the themed color — dropping the override + // falls back to the pushed base, the model twin of ThemeService.restoreColor. + deps.parser.registerOscHandler(104, (data) => { + if (!data) { + ansiOverrides.clear() + return true + } + for (const slot of data.split(';')) { + if (/^\d+$/.test(slot)) { + ansiOverrides.delete(Number.parseInt(slot, 10)) + } + } + return true + }) + deps.parser.registerOscHandler(110, () => { + specialOverrides.delete('foreground') + return true + }) + deps.parser.registerOscHandler(111, () => { + specialOverrides.delete('background') + return true + }) + deps.parser.registerOscHandler(112, () => { + specialOverrides.delete('cursor') + return true + }) + + deps.parser.registerCsiHandler({ prefix: '?', final: 'n' }, (params) => { + if (params[0] !== 996) { + // Fall through to the core for every other private DSR (?6n CPR etc.). + return false + } + const base = deps.getBaseAttributes() + if (base) { + // Why luminance and not base.colorSchemeMode: a visible xterm answers + // ?996n from the relative luminance of the CURRENT (OSC-SET-mutated) + // background vs foreground (CoreBrowserTerminal._reportColorScheme), + // so a dark terminal theme in a light app mode still answers dark. + // colorSchemeMode is the app mode and feeds the 2031/997 path only. + const background = specialOverrides.get('background') ?? base.background + const foreground = specialOverrides.get('foreground') ?? base.foreground + const dark = relativeLuminance(background) < relativeLuminance(foreground) + deps.emitReply(`\x1b[?997;${dark ? 1 : 2}n`) + } + return true + }) + + return { + clearColorOverrides: () => { + ansiOverrides.clear() + specialOverrides.clear() + } + } +} diff --git a/src/main/daemon/types.ts b/src/main/daemon/types.ts index faf0df30996..663166e6fca 100644 --- a/src/main/daemon/types.ts +++ b/src/main/daemon/types.ts @@ -3,11 +3,13 @@ import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges // ─── Protocol Version ──────────────────────────────────────────────── import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' -// Why: daemons survive app updates; bump for IPC shape or baked behavior that -// wrapper refresh cannot deliver, so old daemons reject unsupported RPCs. -export const PROTOCOL_VERSION = 19 +// Why: daemons can survive app updates. Bump for IPC wire-shape changes, or +// when daemon-baked behavior cannot be delivered by on-disk wrapper refresh. +// Why: bump when adding daemon wire behavior so same-version old daemons do +// not silently accept the handshake and then reject new RPCs. +export const PROTOCOL_VERSION = 20 export const PREVIOUS_DAEMON_PROTOCOL_VERSIONS = [ - 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18 + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19 ] as const // ─── Session State Machine ────────────────────────────────────────── @@ -18,23 +20,27 @@ export type ShellReadyState = 'pending' | 'ready' | 'timed_out' | 'unsupported' // ─── Terminal Snapshot ────────────────────────────────────────────── export type TerminalSnapshot = { snapshotAnsi: string - /** Scrollback portion only (rows above the visible viewport). Write this - * to preserve history without interfering with TUI repaints. */ + /** Trailing incomplete escape sequence the emulator ingested but xterm's + * parser is still holding (a PTY read ended mid-escape). Restorers must + * write this LAST — after their own post-replay resets, immediately before + * post-snapshot live chunks — so the continuation bytes complete it + * exactly as live (Bug E / #7329, notes/garble-fuzz-divergences.md). Its + * bytes are already counted by the snapshot seq. */ + pendingEscapeTailAnsi?: string + /** Normal buffer captured separately while snapshotAnsi holds an active + * alternate buffer. Empty for normal-screen snapshots. */ scrollbackAnsi: string oscLinks?: TerminalOscLinkRange[] rehydrateSequences: string - /** The trailing partial escape sequence left unparsed in the emulator when a - * PTY read ended mid-escape. serialize() cannot carry it (it lives in the - * parser, not the buffer), so the restorer must write it LAST — after any - * post-snapshot reset — so the next live chunk's continuation completes the - * sequence instead of rendering literally (#7329). */ - pendingEscapeTailAnsi?: string cwd: string | null modes: TerminalModes cols: number rows: number scrollbackLines: number lastTitle?: string + /** Absolute UTF-16 character count ingested by this live daemon session. + * Optional because persisted snapshots and older v19 daemons lack it. */ + outputSequence?: number } export type TerminalModes = { @@ -45,9 +51,14 @@ export type TerminalModes = { sgrMousePixelsMode?: boolean applicationCursor: boolean alternateScreen: boolean - /** Kitty keyboard protocol flags (CSI > u) the session's TUI negotiated; - * 0/absent when inactive. SerializeAddon cannot capture these, so the - * emulator mirrors them for snapshot rehydration. */ + /** Kitty keyboard protocol flags (CSI = u pushes) for emulator re-seed + * parity ONLY. Consumed by the daemon warm-reattach path: the spawn + * result threads them into seedHeadlessTerminal, which re-applies them to + * the fresh runtime emulator (HeadlessEmulator.applyKittyKeyboardFlags) + * so hidden `CSI ? u` answers the real flags instead of ?0u. + * rehydrateSequences must never push these into a renderer xterm — + * POST_REPLAY_REATTACH_RESET's deliberate kitty reset stays authoritative + * (terminal-query-authority.md §kitty). */ kittyKeyboardFlags?: number } @@ -134,6 +145,38 @@ export type ResizeRequest = { } } +// ─── Producer flow control (v19+) ─────────────────────────────────── +// Why fire-and-forget notifications (like write/resize): pause/resume ride the +// hot data path and are best-effort — the daemon-side 5s failsafe, not an RPC +// reply, is what guarantees a paused shell can never stay wedged. +export type PausePtyRequest = { + id: string + type: 'pausePty' + payload: { + sessionId: string + } +} + +export type ResumePtyRequest = { + id: string + type: 'resumePty' + payload: { + sessionId: string + } +} + +// Why the notification stays backward-tolerated: unknown notify types are +// swallowed by old daemons. The adapter's v20 capability gate separately +// prevents v19 thinning without a sequence-safe recovery snapshot. +export type SetSessionBackgroundRequest = { + id: string + type: 'setSessionBackground' + payload: { + sessionId: string + background: boolean + } +} + export type KillRequest = { id: string type: 'kill' @@ -217,6 +260,7 @@ export type GetSnapshotRequest = { type: 'getSnapshot' payload: { sessionId: string + scrollbackRows?: number } } @@ -277,6 +321,9 @@ export type DaemonRequest = | CancelCreateOrAttachRequest | WriteRequest | ResizeRequest + | PausePtyRequest + | ResumePtyRequest + | SetSessionBackgroundRequest | KillRequest | SignalRequest | ListSessionsRequest @@ -351,30 +398,9 @@ export type DaemonSessionInfo = SessionInfo & { protocolVersion: number } -// ─── Events (Daemon → Client, on stream socket) ──────────────────── - -export type DataEvent = { - type: 'event' - event: 'data' - sessionId: string - payload: { data: string } -} - -export type ExitEvent = { - type: 'event' - event: 'exit' - sessionId: string - payload: { code: number } -} - -export type TerminalErrorEvent = { - type: 'event' - event: 'terminalError' - sessionId: string - payload: { message: string } -} - -export type DaemonEvent = DataEvent | ExitEvent | TerminalErrorEvent +// Stream-socket event shapes live in daemon-stream-events.ts; re-exported so +// existing importers keep one types entry point. +export * from './daemon-stream-events' // ─── Binary Frame Protocol (Daemon ↔ PTY Subprocess) ──────────────── // @@ -399,23 +425,10 @@ export const FRAME_MAX_PAYLOAD = 1024 * 1024 // 1MB export const NOTIFY_PREFIX = 'notify_' // ─── Error types ──────────────────────────────────────────────────── -export class TerminalAttachCanceledError extends Error { - constructor(sessionId: string) { - super(`Attach canceled for session ${sessionId}`) - this.name = 'TerminalAttachCanceledError' - } -} - -export class DaemonProtocolError extends Error { - constructor(message: string) { - super(message) - this.name = 'DaemonProtocolError' - } -} - -export class SessionNotFoundError extends Error { - constructor(sessionId: string) { - super(`Session not found: ${sessionId}`) - this.name = 'SessionNotFoundError' - } -} +// Re-exported so existing importers of `./types` keep working; the classes +// live in daemon-errors.ts (this file is capped for wire-shape declarations). +export { + TerminalAttachCanceledError, + DaemonProtocolError, + SessionNotFoundError +} from './daemon-errors' diff --git a/src/main/index.ts b/src/main/index.ts index 2ff6ca879fc..975e3a4596b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -179,6 +179,7 @@ import { type SyntheticTitleSpinnerEntry } from './synthetic-title-spinner' import { shouldSendSyntheticTitleFrame } from './synthetic-title-visibility' +import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing' import { isCrashReportReason } from '../shared/crash-reporting' import { getSyntheticAgentTitleProfile, @@ -187,6 +188,7 @@ import { } from '../shared/synthetic-agent-title' import type { AgentStatusState } from '../shared/agent-status-types' import { resolveTuiAgentPermissionMode } from '../shared/tui-agent-permissions' +import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import { KeybindingService } from './keybindings/keybinding-service' import { applyElectronProxySettings } from './network/proxy-settings' import { preserveAgentAuthBeforeRestart } from './agent-auth-restart-preservation' @@ -1504,7 +1506,17 @@ function sendSyntheticTitle(ptyId: string, data: string, options: { force?: bool ) { return } - mainWindow.webContents.send('pty:data', { id: ptyId, data }) + // Why: feed the per-PTY tracker directly (never onPtyData — emulator state, + // tails, transcripts, and stats must not see fabricated bytes) so synthetic + // titles/BELs reach pty:sideEffect consumers when main holds side-effect + // authority. + runtime?.ingestSyntheticTitleFrame(ptyId, data) + // Why: only the kill-switch-off renderer still byte-parses synthetic frames; + // under main authority the copy would just mint phantom ACKs for unmetered + // bytes (see synthetic-title-frame-routing.ts). + if (shouldCopySyntheticTitleFrameToPtyData(store?.getSettings())) { + mainWindow.webContents.send('pty:data', { id: ptyId, data }) + } } function isSyntheticTitleWindowVisible(): boolean { @@ -1805,6 +1817,20 @@ app.whenReady().then(async () => { onTerminalAgentStatus: (event) => { agentHookServer.ingestTerminalStatus(event) }, + // Why: derived title/bell/agent facts ride one batched main→renderer + // channel (terminal-side-effect-authority.md). The renderer's authority + // kill switch decides whether to consume. Headless serve never creates a + // window, so the dep is omitted entirely — the runtime then skips fact + // batch construction and the per-chunk bell walk. + ...(isServeMode + ? {} + : { + onTerminalSideEffects: (batch: TerminalSideEffectBatch) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('pty:sideEffect', batch) + } + } + }), // Why: hook-reported agent status is the same source the desktop sidebar // reads. worktree.ps pulls it at query time so mobile shows the same agents. getAgentStatusSnapshot: () => agentHookServer.getStatusSnapshot(), diff --git a/src/main/ipc/pty-hidden-delivery-gate.test.ts b/src/main/ipc/pty-hidden-delivery-gate.test.ts new file mode 100644 index 00000000000..c85c4e2c8e0 --- /dev/null +++ b/src/main/ipc/pty-hidden-delivery-gate.test.ts @@ -0,0 +1,118 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + clearHiddenRendererPtyDeliveryState, + getHiddenRendererPtyDeliveryDebug, + isHiddenPtyDeliveryGateEnabled, + markHiddenRendererPty, + recordHiddenRendererPtyDataDrop, + resetRendererScopedHiddenPtyDeliveryState, + setRendererPtyDeliveryInterest, + shouldDropHiddenRendererPtyData, + unmarkHiddenRendererPty +} from './pty-hidden-delivery-gate' + +const PTY_ID = 'pty-1' + +describe('pty hidden delivery gate', () => { + beforeEach(() => { + _resetHiddenRendererPtyDeliveryGateForTest() + }) + + it('only operates when both kill switches are on (default on)', () => { + expect(isHiddenPtyDeliveryGateEnabled(undefined)).toBe(true) + expect(isHiddenPtyDeliveryGateEnabled({})).toBe(true) + expect(isHiddenPtyDeliveryGateEnabled({ terminalHiddenDeliveryGate: false })).toBe(false) + expect(isHiddenPtyDeliveryGateEnabled({ terminalMainSideEffectAuthority: false })).toBe(false) + }) + + it('drops only hidden PTYs without registered delivery interest', () => { + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + + markHiddenRendererPty(PTY_ID) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true) + expect(shouldDropHiddenRendererPtyData(PTY_ID, { terminalHiddenDeliveryGate: false })).toBe( + false + ) + + setRendererPtyDeliveryInterest(PTY_ID, true) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + setRendererPtyDeliveryInterest(PTY_ID, false) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(true) + }) + + it('requests the restore marker exactly once per drop episode, re-armed by unmark', () => { + markHiddenRendererPty(PTY_ID) + expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(true) + expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(false) + + // Why: unmark consumes the latch (and re-emits via its own return value); + // the next hidden period's first drop reports again. + unmarkHiddenRendererPty(PTY_ID) + markHiddenRendererPty(PTY_ID) + expect(recordHiddenRendererPtyDataDrop(PTY_ID, 10).shouldEmitRestoreMarker).toBe(true) + }) + + it('keeps drop memory when an already-dropped PTY is re-marked hidden', () => { + // Why: a hidden remount or renderer reload re-marks without an unhide in + // between — clearing the latch there would make reveal skip the restore. + markHiddenRendererPty(PTY_ID) + recordHiddenRendererPtyDataDrop(PTY_ID, 10) + markHiddenRendererPty(PTY_ID) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) + }) + + it('reports drops on unhide so reveal can heal a replaced renderer view', () => { + markHiddenRendererPty(PTY_ID) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(false) + + markHiddenRendererPty(PTY_ID) + recordHiddenRendererPtyDataDrop(PTY_ID, 10) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + }) + + it('clears renderer-scoped state on reload while preserving drop memory', () => { + markHiddenRendererPty(PTY_ID) + recordHiddenRendererPtyDataDrop(PTY_ID, 10) + setRendererPtyDeliveryInterest('pty-2', true) + markHiddenRendererPty('pty-2') + + resetRendererScopedHiddenPtyDeliveryState() + + // Hidden marks and interest holds died with the old renderer process. + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0 + }) + // pty-2's leaked interest is gone: re-marking gates it again. + markHiddenRendererPty('pty-2') + expect(shouldDropHiddenRendererPtyData('pty-2', {})).toBe(true) + // Drop memory survives so the new renderer's first unhide still restores. + markHiddenRendererPty(PTY_ID) + expect(unmarkHiddenRendererPty(PTY_ID).droppedWhileHidden).toBe(true) + }) + + it('clears all per-PTY state on teardown and tracks debug counters', () => { + markHiddenRendererPty(PTY_ID) + setRendererPtyDeliveryInterest('pty-2', true) + recordHiddenRendererPtyDataDrop(PTY_ID, 7) + recordHiddenRendererPtyDataDrop(PTY_ID, 5) + + expect(getHiddenRendererPtyDeliveryDebug()).toEqual({ + hiddenDeliveryGatedPtyCount: 1, + deliveryInterestPtyCount: 1, + hiddenDeliveryDroppedChars: 12, + hiddenDeliveryDroppedChunks: 2 + }) + + clearHiddenRendererPtyDeliveryState(PTY_ID) + clearHiddenRendererPtyDeliveryState('pty-2') + expect(getHiddenRendererPtyDeliveryDebug()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0 + }) + expect(shouldDropHiddenRendererPtyData(PTY_ID, {})).toBe(false) + }) +}) diff --git a/src/main/ipc/pty-hidden-delivery-gate.ts b/src/main/ipc/pty-hidden-delivery-gate.ts new file mode 100644 index 00000000000..77230f7300c --- /dev/null +++ b/src/main/ipc/pty-hidden-delivery-gate.ts @@ -0,0 +1,154 @@ +/** + * Main-side hidden-delivery gate for renderer PTY byte delivery (Phase 4 of + * the terminal model/view architecture). + * + * The renderer marks a PTY hidden when no visible view consumes its bytes; + * main then drops renderer-bound delivery AFTER model ingestion — the runtime + * already parsed the chunk, and reveal restores from the model snapshot via + * the existing seq-guarded machinery. Any renderer party that still needs raw + * bytes (dispatcher sidecars) registers delivery + * interest, which suppresses the gate for that PTY. + * See docs/reference/terminal-side-effect-authority.md (Open Items). + */ +import type { GlobalSettings } from '../../shared/types' + +export type HiddenPtyDeliveryGateSettings = Pick< + GlobalSettings, + 'terminalMainSideEffectAuthority' | 'terminalHiddenDeliveryGate' +> + +const hiddenRendererPtys = new Set() +// Why: sidecar consumers (paste-draft pacing, background agent launches, +// automation observers, and the kill-switch-off parked 2031 responder) need +// live bytes even while no visible view exists. Any +// registered interest suppresses the gate for that PTY. +const deliveryInterestRendererPtys = new Set() +// Why: reveal must restore from the model only when bytes were actually +// dropped. Doubles as the one-shot marker latch: the first gated drop emits a +// restore marker, and the latch is consumed only by unmark (which re-emits) +// or full PTY teardown — never by re-marking hidden, so drop memory survives +// hidden remounts and renderer reloads. +const droppedSinceHiddenPtys = new Set() + +let droppedHiddenDeliveryChars = 0 +let droppedHiddenDeliveryChunks = 0 + +/** Gate kill switches, both read main-side: the gate only operates under main + * side-effect authority AND the gate-specific setting (both default on). */ +export function isHiddenPtyDeliveryGateEnabled( + settings: HiddenPtyDeliveryGateSettings | null | undefined +): boolean { + return ( + settings?.terminalMainSideEffectAuthority !== false && + settings?.terminalHiddenDeliveryGate !== false + ) +} + +/** Renderer-reported "no visible view needs bytes" bit. Never clears drop + * memory: a hidden remount or renderer reload re-marks an already-dropped + * PTY, and erasing the latch there would make the eventual reveal skip the + * restore. Unmark is the only consumer of the latch. */ +export function markHiddenRendererPty(id: string): void { + hiddenRendererPtys.add(id) +} + +/** Clears the hidden bit. Returns whether bytes were dropped while hidden so + * the caller can emit a restore marker to the now-visible renderer. */ +export function unmarkHiddenRendererPty(id: string): { droppedWhileHidden: boolean } { + hiddenRendererPtys.delete(id) + const droppedWhileHidden = droppedSinceHiddenPtys.delete(id) + return { droppedWhileHidden } +} + +export function isHiddenRendererPty(id: string): boolean { + return hiddenRendererPtys.has(id) +} + +/** For freeze diagnostics only: hidden ptys must appear in the per-pty report + * table even when the gate dropped every byte before any send/accounting. */ +export function getHiddenRendererPtyIds(): string[] { + return [...hiddenRendererPtys] +} + +/** Renderer-side ref-counted interest, surfaced as boolean transitions. */ +export function setRendererPtyDeliveryInterest(id: string, interested: boolean): void { + if (interested) { + deliveryInterestRendererPtys.add(id) + } else { + deliveryInterestRendererPtys.delete(id) + } +} + +export function shouldDropHiddenRendererPtyData( + id: string, + settings: HiddenPtyDeliveryGateSettings | null | undefined +): boolean { + return ( + isHiddenPtyDeliveryGateEnabled(settings) && + hiddenRendererPtys.has(id) && + !deliveryInterestRendererPtys.has(id) + ) +} + +/** Record one gated drop. Returns whether the caller should emit the one-shot + * empty restore-marker chunk (first drop since this PTY went hidden). */ +export function recordHiddenRendererPtyDataDrop( + id: string, + chars: number +): { shouldEmitRestoreMarker: boolean } { + droppedHiddenDeliveryChars += chars + droppedHiddenDeliveryChunks += 1 + if (droppedSinceHiddenPtys.has(id)) { + return { shouldEmitRestoreMarker: false } + } + droppedSinceHiddenPtys.add(id) + return { shouldEmitRestoreMarker: true } +} + +/** Renderer process replaced (reload / crash): its ref-counted interest + * holds and hidden marks died with it, so keeping them would gate (or + * force-feed) PTYs no live renderer party asked about. Drop memory is + * preserved — surviving daemon/SSH PTYs may have dropped bytes the old + * renderer never restored; the new renderer's first hidden/visible sync + * re-marks or unmarks and the unmark path re-emits the restore marker. */ +export function resetRendererScopedHiddenPtyDeliveryState(): void { + hiddenRendererPtys.clear() + deliveryInterestRendererPtys.clear() +} + +/** Full per-PTY teardown — wired into clearProviderPtyState so every exit + * path (local, daemon, SSH, connection teardown) releases gate state. */ +export function clearHiddenRendererPtyDeliveryState(id: string): void { + hiddenRendererPtys.delete(id) + deliveryInterestRendererPtys.delete(id) + droppedSinceHiddenPtys.delete(id) +} + +export type HiddenRendererPtyDeliveryDebug = { + hiddenDeliveryGatedPtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number +} + +export function getHiddenRendererPtyDeliveryDebug(): HiddenRendererPtyDeliveryDebug { + return { + hiddenDeliveryGatedPtyCount: hiddenRendererPtys.size, + deliveryInterestPtyCount: deliveryInterestRendererPtys.size, + hiddenDeliveryDroppedChars: droppedHiddenDeliveryChars, + hiddenDeliveryDroppedChunks: droppedHiddenDeliveryChunks + } +} + +export function resetHiddenRendererPtyDeliveryDebugCounters(): void { + droppedHiddenDeliveryChars = 0 + droppedHiddenDeliveryChunks = 0 +} + +/** Test seam: reset all module state between tests. */ +export function _resetHiddenRendererPtyDeliveryGateForTest(): void { + hiddenRendererPtys.clear() + deliveryInterestRendererPtys.clear() + droppedSinceHiddenPtys.clear() + resetHiddenRendererPtyDeliveryDebugCounters() +} diff --git a/src/main/ipc/pty-producer-flow-control.test.ts b/src/main/ipc/pty-producer-flow-control.test.ts new file mode 100644 index 00000000000..ae7fcb6b655 --- /dev/null +++ b/src/main/ipc/pty-producer-flow-control.test.ts @@ -0,0 +1,137 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + PRODUCER_FLOW_HIGH_WATERMARK_CHARS, + PRODUCER_FLOW_LOW_WATERMARK_CHARS, + PRODUCER_PAUSE_REASSERT_INTERVAL_MS, + PtyProducerFlowController +} from './pty-producer-flow-control' + +const HIGH = PRODUCER_FLOW_HIGH_WATERMARK_CHARS +const LOW = PRODUCER_FLOW_LOW_WATERMARK_CHARS + +describe('PtyProducerFlowController', () => { + let pauseProducer: ReturnType void>> + let resumeProducer: ReturnType void>> + let controller: PtyProducerFlowController + + beforeEach(() => { + vi.useFakeTimers() + pauseProducer = vi.fn<(id: string) => void>() + resumeProducer = vi.fn<(id: string) => void>() + controller = new PtyProducerFlowController({ + pauseProducer, + resumeProducer + }) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('does not pause at or below the high watermark', () => { + controller.update('pty-1', 0) + controller.update('pty-1', LOW) + controller.update('pty-1', HIGH) + expect(pauseProducer).not.toHaveBeenCalled() + expect(controller.isPaused('pty-1')).toBe(false) + }) + + it('pauses exactly once when pending crosses the high watermark, not per chunk', () => { + controller.update('pty-1', HIGH + 1) + controller.update('pty-1', HIGH + 64 * 1024) + controller.update('pty-1', HIGH + 128 * 1024) + expect(pauseProducer).toHaveBeenCalledTimes(1) + expect(pauseProducer).toHaveBeenCalledWith('pty-1') + expect(controller.isPaused('pty-1')).toBe(true) + }) + + it('resumes exactly once when pending drains below the low watermark', () => { + controller.update('pty-1', HIGH + 1) + controller.update('pty-1', LOW - 1) + expect(resumeProducer).toHaveBeenCalledTimes(1) + expect(resumeProducer).toHaveBeenCalledWith('pty-1') + expect(controller.isPaused('pty-1')).toBe(false) + // A second drain report on the now-unpaused pty must not resume again. + controller.update('pty-1', 0) + expect(resumeProducer).toHaveBeenCalledTimes(1) + }) + + it('holds hysteresis: no flapping while pending sits between the watermarks', () => { + controller.update('pty-1', HIGH + 1) + expect(pauseProducer).toHaveBeenCalledTimes(1) + // Draining but still above LOW: stay paused, no extra calls either way. + controller.update('pty-1', HIGH - 16 * 1024) + controller.update('pty-1', 128 * 1024) + controller.update('pty-1', LOW) + expect(pauseProducer).toHaveBeenCalledTimes(1) + expect(resumeProducer).not.toHaveBeenCalled() + expect(controller.isPaused('pty-1')).toBe(true) + // An unpaused pty hovering in the same band must not pause. + controller.update('pty-2', LOW + 1) + controller.update('pty-2', HIGH) + expect(pauseProducer).toHaveBeenCalledTimes(1) + }) + + it('re-asserts the pause after the failsafe interval while still flooded', () => { + controller.update('pty-1', HIGH + 1) + expect(pauseProducer).toHaveBeenCalledTimes(1) + + // Within the failsafe window: no re-assert even far above HIGH. + vi.advanceTimersByTime(PRODUCER_PAUSE_REASSERT_INTERVAL_MS - 1) + controller.update('pty-1', HIGH * 4) + expect(pauseProducer).toHaveBeenCalledTimes(1) + + // After the window (daemon failsafe has auto-resumed by now): re-pause. + vi.advanceTimersByTime(1) + controller.update('pty-1', HIGH * 4) + expect(pauseProducer).toHaveBeenCalledTimes(2) + + // The re-assert re-stamps the clock — no immediate third pause. + controller.update('pty-1', HIGH * 4) + expect(pauseProducer).toHaveBeenCalledTimes(2) + }) + + it('release resumes only ptys that are actually paused', () => { + controller.update('paused-pty', HIGH + 1) + controller.release('paused-pty') + controller.release('never-paused-pty') + expect(resumeProducer).toHaveBeenCalledTimes(1) + expect(resumeProducer).toHaveBeenCalledWith('paused-pty') + expect(controller.isPaused('paused-pty')).toBe(false) + }) + + it('releaseAll resumes every paused pty', () => { + controller.update('pty-1', HIGH + 1) + controller.update('pty-2', HIGH + 1) + controller.update('pty-3', LOW) + controller.releaseAll() + expect(resumeProducer).toHaveBeenCalledTimes(2) + expect(resumeProducer).toHaveBeenCalledWith('pty-1') + expect(resumeProducer).toHaveBeenCalledWith('pty-2') + expect(controller.isPaused('pty-1')).toBe(false) + expect(controller.isPaused('pty-2')).toBe(false) + }) + + it('keeps bookkeeping consistent when the transport throws', () => { + pauseProducer.mockImplementation(() => { + throw new Error('provider gone') + }) + resumeProducer.mockImplementation(() => { + throw new Error('provider gone') + }) + expect(() => controller.update('pty-1', HIGH + 1)).not.toThrow() + expect(controller.isPaused('pty-1')).toBe(true) + expect(() => controller.update('pty-1', 0)).not.toThrow() + expect(controller.isPaused('pty-1')).toBe(false) + }) + + it('tracks watermark state per pty independently', () => { + controller.update('pty-1', HIGH + 1) + controller.update('pty-2', HIGH + 1) + controller.update('pty-1', 0) + expect(pauseProducer).toHaveBeenCalledTimes(2) + expect(resumeProducer).toHaveBeenCalledTimes(1) + expect(controller.isPaused('pty-1')).toBe(false) + expect(controller.isPaused('pty-2')).toBe(true) + }) +}) diff --git a/src/main/ipc/pty-producer-flow-control.ts b/src/main/ipc/pty-producer-flow-control.ts new file mode 100644 index 00000000000..48a9fc0b678 --- /dev/null +++ b/src/main/ipc/pty-producer-flow-control.ts @@ -0,0 +1,105 @@ +// Producer-side PTY flow control (notes/terminal-performance-initiative.md §5). +// Main tracks per-PTY renderer-pending chars; past HIGH it asks the provider to +// pause the actual PTY read (node-pty pause() → kernel backpressure → the +// flooding shell blocks on write), and below LOW it resumes. The wide +// HIGH/LOW gap is deliberate hysteresis so a draining queue cannot flap +// pause/resume once per flush slice. + +export const PRODUCER_FLOW_HIGH_WATERMARK_CHARS = 256 * 1024 +export const PRODUCER_FLOW_LOW_WATERMARK_CHARS = 32 * 1024 +// Why: the daemon auto-resumes a pause after its 5s lost-resume failsafe. If +// pending is still above HIGH after that window, the pause must be re-asserted +// or a sustained flood would run unthrottled after the first failsafe fires. +export const PRODUCER_PAUSE_REASSERT_INTERVAL_MS = 5_000 + +export type ProducerFlowControlTransport = { + pauseProducer: (id: string) => void + resumeProducer: (id: string) => void +} + +export class PtyProducerFlowController { + private transport: ProducerFlowControlTransport + private highWatermarkChars: number + private lowWatermarkChars: number + private reassertIntervalMs: number + private pausedAtByPty = new Map() + + constructor( + transport: ProducerFlowControlTransport, + opts: { + highWatermarkChars?: number + lowWatermarkChars?: number + reassertIntervalMs?: number + } = {} + ) { + this.transport = transport + this.highWatermarkChars = opts.highWatermarkChars ?? PRODUCER_FLOW_HIGH_WATERMARK_CHARS + this.lowWatermarkChars = opts.lowWatermarkChars ?? PRODUCER_FLOW_LOW_WATERMARK_CHARS + this.reassertIntervalMs = opts.reassertIntervalMs ?? PRODUCER_PAUSE_REASSERT_INTERVAL_MS + } + + /** Reports the current pending chars for a PTY. Fires pause exactly once at + * the HIGH crossing (re-asserted only after the failsafe interval) and + * resume exactly once when pending drains below LOW. */ + update(id: string, pendingChars: number): void { + const pausedAt = this.pausedAtByPty.get(id) + if (pausedAt === undefined) { + if (pendingChars > this.highWatermarkChars) { + this.pausedAtByPty.set(id, Date.now()) + this.safePause(id) + } + return + } + if (pendingChars < this.lowWatermarkChars) { + this.pausedAtByPty.delete(id) + this.safeResume(id) + return + } + if ( + pendingChars > this.highWatermarkChars && + Date.now() - pausedAt >= this.reassertIntervalMs + ) { + this.pausedAtByPty.set(id, Date.now()) + this.safePause(id) + } + } + + /** Resumes a PTY if it was paused. For teardown paths (exit, kill) where + * the pending bookkeeping is being dropped rather than drained. */ + release(id: string): void { + if (this.pausedAtByPty.delete(id)) { + this.safeResume(id) + } + } + + /** Resumes every paused PTY. For wholesale bookkeeping wipes (window + * destroyed) — a local PTY left paused here would stay wedged forever. */ + releaseAll(): void { + // Deleting the visited entry during Map key iteration is spec-safe. + for (const id of this.pausedAtByPty.keys()) { + this.release(id) + } + } + + isPaused(id: string): boolean { + return this.pausedAtByPty.has(id) + } + + // Why swallow: pause/resume are optimizations riding the terminal data + // path — a provider throw must never break delivery or exit handling. + private safePause(id: string): void { + try { + this.transport.pauseProducer(id) + } catch { + /* best-effort */ + } + } + + private safeResume(id: string): void { + try { + this.transport.resumeProducer(id) + } catch { + /* best-effort */ + } + } +} diff --git a/src/main/ipc/pty.test.ts b/src/main/ipc/pty.test.ts index 407f1710436..66a0c1c0750 100644 --- a/src/main/ipc/pty.test.ts +++ b/src/main/ipc/pty.test.ts @@ -9,6 +9,7 @@ import { TERMINAL_INPUT_MAX_BYTES } from '../../shared/terminal-input' import { CLIPBOARD_TEXT_MEASURE_YIELD_CODE_UNITS } from '../../shared/clipboard-text' +import { redactPtyIdForDiagnostics } from '../../shared/pty-delivery-diagnostics' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../shared/constants' const isWindowsHost = process.platform === 'win32' @@ -93,7 +94,11 @@ const { vi.mock('electron', () => ({ app: { isPackaged: true, - getPath: getPathMock + getPath: getPathMock, + getVersion: () => '0.0.0-test' + }, + powerMonitor: { + on: vi.fn() }, nativeTheme: { shouldUseDarkColors: true @@ -192,6 +197,11 @@ import { unregisterSshPtyProvider, getLocalPtyProvider } from './pty' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + isHiddenRendererPty +} from './pty-hidden-delivery-gate' +import { OrcaRuntimeService } from '../runtime/orca-runtime' import { hasLiveClaudePtys, markClaudePtySpawned } from '../claude-accounts/live-pty-gate' import * as livePtyGate from '../claude-accounts/live-pty-gate' import { @@ -237,6 +247,9 @@ describe('registerPtyHandlers', () => { const handlers = new Map unknown>() const mainWindow = { isDestroyed: () => false, + isFocused: () => true, + isVisible: () => true, + isMinimized: () => false, webContents: { on: vi.fn(), send: vi.fn(), @@ -324,6 +337,9 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.on.mockReset() mainWindow.webContents.send.mockReset() mainWindow.webContents.removeListener.mockReset() + // Why: hidden-delivery gate state is module-level by design (PTY-keyed, + // not window-keyed); tests must not leak hidden bits across cases. + _resetHiddenRendererPtyDeliveryGateForTest() // Why: mirror real Electron — ipcMain.handle throws on a duplicate channel // unless removeHandler cleared it first. This catches a re-registration @@ -401,7 +417,21 @@ describe('registerPtyHandlers', () => { afterEach(() => { _resetWslCachesForTests() vi.useRealTimers() - unregisterSshPtyProvider('ssh-1') + // Why: sshProviders is module-level state; any id left registered leaks + // into later tests (pty:listSessions sweeps every registered provider). + for (const leakedConnectionId of [ + 'ssh-1', + 'ssh-a', + 'ssh-b', + 'ssh-expired-runtime', + 'ssh-fresh-fail', + 'ssh-reattach-1', + 'ssh-reattach-fail', + 'ssh-reattach-ok', + 'ssh-runtime-env' + ]) { + unregisterSshPtyProvider(leakedConnectionId) + } setLocalPtyProvider(new LocalPtyProvider()) if (savedProcessPlatform) { Object.defineProperty(process, 'platform', savedProcessPlatform) @@ -542,12 +572,21 @@ describe('registerPtyHandlers', () => { const spawn = vi.fn(async (options: { sessionId?: string }) => ({ id: options.sessionId ?? 'daemon-pty' })) + const write = vi.fn() + const pauseProducer = vi.fn() + const resumeProducer = vi.fn() let dataHandler: ((payload: { id: string; data: string }) => void) | null = null let exitHandler: ((payload: { id: string; code: number }) => void) | null = null + let backgroundStreamHandler: + | ((payload: { id: string; kind: 'dataGap'; droppedChars: number }) => void) + | null = null + const getBufferSnapshot = vi.fn() setLocalPtyProvider({ spawn, - write: vi.fn(), + write, resize: vi.fn(), + pauseProducer, + resumeProducer, kill: vi.fn(), shutdown: vi.fn(), sendSignal: vi.fn(), @@ -564,6 +603,13 @@ describe('registerPtyHandlers', () => { return () => {} }), onReplay: vi.fn(() => () => {}), + onBackgroundStreamEvent: vi.fn( + (handler: (payload: { id: string; kind: 'dataGap'; droppedChars: number }) => void) => { + backgroundStreamHandler = handler + return () => {} + } + ), + getBufferSnapshot, onExit: vi.fn((handler: (payload: { id: string; code: number }) => void) => { exitHandler = handler return () => {} @@ -575,20 +621,29 @@ describe('registerPtyHandlers', () => { } as never) return { spawn, + write, + pauseProducer, + resumeProducer, + getBufferSnapshot, emitData: (id: string, data: string) => dataHandler?.({ id, data }), - emitExit: (id: string, code = 0) => exitHandler?.({ id, code }) + emitExit: (id: string, code = 0) => exitHandler?.({ id, code }), + emitDataGap: (id: string, droppedChars: number) => + backgroundStreamHandler?.({ id, kind: 'dataGap', droppedChars }) } } function getPtyAckDataListener(): ( event: unknown, - args: { id: string; charCount: number } + args: { id: string; charCount?: number; processedChars?: number } ) => void { const ackCall = onMock.mock.calls.find((call: unknown[]) => call[0] === 'pty:ackData') if (!ackCall) { throw new Error('missing pty:ackData listener') } - return ackCall[1] as (event: unknown, args: { id: string; charCount: number }) => void + return ackCall[1] as ( + event: unknown, + args: { id: string; charCount?: number; processedChars?: number } + ) => void } function getPtySetActiveRendererPtyListener(): ( @@ -654,6 +709,32 @@ describe('registerPtyHandlers', () => { ) => void } + function getPtySetHiddenRendererPtyListener(): ( + event: unknown, + args: { id: string; hidden: boolean } + ) => void { + const hiddenCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:setHiddenRendererPty' + ) + if (!hiddenCall) { + throw new Error('missing pty:setHiddenRendererPty listener') + } + return hiddenCall[1] as (event: unknown, args: { id: string; hidden: boolean }) => void + } + + function getPtySetDeliveryInterestListener(): ( + event: unknown, + args: { id: string; interested: boolean } + ) => void { + const interestCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:setPtyDeliveryInterest' + ) + if (!interestCall) { + throw new Error('missing pty:setPtyDeliveryInterest listener') + } + return interestCall[1] as (event: unknown, args: { id: string; interested: boolean }) => void + } + /** Helper: trigger pty:spawn and return the env passed to node-pty. */ async function spawnAndGetEnv( argsEnv?: Record, @@ -1632,6 +1713,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -1817,6 +1899,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -3342,6 +3425,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn(() => 13), @@ -3375,11 +3459,16 @@ describe('registerPtyHandlers', () => { const result = await pendingSpawn daemon.emitData(result.id, 'daemon output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) daemon.emitExit(result.id, 0) expect(daemon.spawn).toHaveBeenCalledTimes(1) - expect(runtime.onPtyData).toHaveBeenCalledWith(result.id, 'daemon output', expect.any(Number)) + expect(runtime.onPtyData).toHaveBeenCalledWith( + result.id, + 'daemon output', + expect.any(Number), + 'daemon output'.length + ) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: result.id, data: 'daemon output', @@ -3396,76 +3485,6 @@ describe('registerPtyHandlers', () => { } }) - // Why: the unsent main->renderer backlog was only bounded by renderer ACKs. A - // background-throttled/frozen renderer (Win/Linux) stops ACKing while a busy - // pane keeps producing, so the per-pty pending string grew at raw PTY rate - // (MB->GB). The cap trims it to the most-recent span and flags droppedBacklog - // exactly once so the renderer rebuilds the dropped span from the main snapshot. - it('caps the unsent pending backlog and flags droppedBacklog once when the renderer never acks', async () => { - vi.useFakeTimers() - let seq = 0 - const runtime = { - setPtyController: vi.fn(), - registerPty: vi.fn(), - onPtySpawned: vi.fn(), - onPtyExit: vi.fn(), - onPtyData: vi.fn((_id: string, data: string) => { - seq += data.length - return seq - }), - createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-cap'), - registerPreAllocatedHandleForPty: vi.fn() - } - try { - registerPtyHandlers( - mainWindow as never, - runtime as never, - undefined, - undefined, - undefined, - undefined, - { awaitLocalPtyStartup: () => Promise.resolve() } - ) - const pendingSpawn = handlers.get('pty:spawn')!(null, { - cols: 80, - rows: 24, - sessionId: 'backlog-cap-session' - }) as Promise<{ id: string }> - await Promise.resolve() - const daemon = installObservableDaemonTestProvider() - rebindLocalProviderListeners() - const result = await pendingSpawn - - // Never ack: in-flight pins at the high-water and the flush gates, so the - // unsent backlog would grow unbounded. Emit far more than the 2 MB cap. - const HUGE = 'x'.repeat(5 * 1024 * 1024) - daemon.emitData(result.id, HUGE) - await vi.advanceTimersByTimeAsync(50) - - const dataSends = mainWindow.webContents.send.mock.calls.filter( - (call) => call[0] === 'pty:data' && (call[1] as { id: string }).id === result.id - ) - expect(dataSends.length).toBeGreaterThan(0) - // Sanity bound only: with no acks the sent total is already gated by the - // pre-existing in-flight high-water caps, so this holds independent of the - // trim. The actual retained-backlog cap is proven by droppedBacklog below. - const totalChars = dataSends.reduce( - (sum, call) => sum + (call[1] as { data: string }).data.length, - 0 - ) - expect(totalChars).toBeLessThan(3 * 1024 * 1024) - // The trim signals the renderer exactly once, on the first emitted chunk. - expect((dataSends[0][1] as { droppedBacklog?: boolean }).droppedBacklog).toBe(true) - expect( - dataSends.filter( - (call) => (call[1] as { droppedBacklog?: boolean }).droppedBacklog === true - ) - ).toHaveLength(1) - } finally { - vi.useRealTimers() - } - }) - // Why: the cap and its flag must never fire in the common case (renderer keeps // up), so ordinary small output carries no droppedBacklog. it('does not flag droppedBacklog for ordinary small output under the cap', async () => { @@ -3519,6 +3538,7 @@ describe('registerPtyHandlers', () => { const runtime = { setPtyController: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -4216,6 +4236,7 @@ describe('registerPtyHandlers', () => { } as never) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn() } @@ -4502,6 +4523,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_wrong'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -4553,6 +4575,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -4592,6 +4615,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5039,6 +5063,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5110,6 +5135,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5196,6 +5222,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5293,6 +5320,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5401,6 +5429,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5494,6 +5523,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), getDriver: vi.fn(() => ({ kind: 'host' })), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5699,6 +5729,7 @@ describe('registerPtyHandlers', () => { createPreAllocatedTerminalHandle: vi.fn(() => 'term_remote'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5757,6 +5788,7 @@ describe('registerPtyHandlers', () => { preAllocateHandleForPty: vi.fn(() => 'term_trusted'), registerPreAllocatedHandleForPty: vi.fn(), registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), onPtyData: vi.fn() @@ -5804,6 +5836,7 @@ describe('registerPtyHandlers', () => { it('ignores renderer-provided ORCA_TERMINAL_HANDLE for local PTY spawns', async () => { const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_trusted'), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -5831,6 +5864,7 @@ describe('registerPtyHandlers', () => { }) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), preAllocateHandleForPty: vi.fn(() => 'term_wsl'), onPtySpawned: vi.fn(), onPtyExit: vi.fn(), @@ -6865,7 +6899,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData('background output') expect(mainWindow.webContents.send).not.toHaveBeenCalled() - vi.advanceTimersByTime(7) + vi.advanceTimersByTime(1) expect(mainWindow.webContents.send).not.toHaveBeenCalled() vi.advanceTimersByTime(1) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { @@ -6894,7 +6928,7 @@ describe('registerPtyHandlers', () => { setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) mockProc.emitData('visible output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'visible output' @@ -6904,7 +6938,7 @@ describe('registerPtyHandlers', () => { setRendererPtyVisible(null, { id: spawnResult.id, visible: false }) mockProc.emitData('\x1b[2Khidden-width redraw') setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -6938,7 +6972,7 @@ describe('registerPtyHandlers', () => { // Reloaded page's dispatcher re-registers, releasing held sends (§1b). handleRendererDispatcherReady() mockProc.emitData('reload-gap output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -6949,7 +6983,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) mockProc.emitData('visible output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -7398,7 +7432,7 @@ describe('registerPtyHandlers', () => { resizePty(null, { id: spawnResult.id, cols: 72, rows: 24 }) setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) mockProc.emitData('\x1b[2Khidden-resize redraw') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -7409,7 +7443,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() resizePty(null, { id: spawnResult.id, cols: 80, rows: 24 }) mockProc.emitData('visible repaint') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -7442,7 +7476,7 @@ describe('registerPtyHandlers', () => { setRendererPtyVisible(null, { id: spawnResult.id, visible: true }) writePty(mainWindowIpcEvent, { id: spawnResult.id, data: 'x' }) mockProc.emitData('x') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, @@ -7477,7 +7511,7 @@ describe('registerPtyHandlers', () => { expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'ready' @@ -7511,7 +7545,7 @@ describe('registerPtyHandlers', () => { expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(mockProc.proc.write).toHaveBeenCalledWith('\x1b]11;rgb:1111/1111/1111\x1b\\') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'ready' @@ -7543,7 +7577,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData(`${query}ready`) expect(mockProc.proc.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: `${query}ready` @@ -7577,7 +7611,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData(`${command}ready`) expect(mockProc.proc.write).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: `${command}ready` @@ -7638,7 +7672,7 @@ describe('registerPtyHandlers', () => { expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]11;rgb:1111/1111/1111\x1b\\') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'daemon-ready' @@ -7713,7 +7747,7 @@ describe('registerPtyHandlers', () => { id: spawnResult.id, data: '\x1b]10;?\x1b\\\x1b]11;?\x1b\\ready' }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]10;rgb:eeee/eeee/eeee\x1b\\') expect(write).toHaveBeenCalledWith(spawnResult.id, '\x1b]11;rgb:1111/1111/1111\x1b\\') @@ -7753,7 +7787,7 @@ describe('registerPtyHandlers', () => { id: spawnResult.id, data: '\x1b[20;2Hredraw' }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() @@ -7809,7 +7843,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData(largeOutput) expect(mainWindow.webContents.send).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: largeOutput @@ -7845,7 +7879,7 @@ describe('registerPtyHandlers', () => { } expect(mainWindow.webContents.send).toHaveBeenCalledTimes(64) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(65) expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(65, 'pty:data', { id: spawnResult.id, @@ -7883,7 +7917,7 @@ describe('registerPtyHandlers', () => { id: spawnResult.id, data: redraw }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) } finally { vi.useRealTimers() @@ -7915,7 +7949,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData('redraw') expect(mainWindow.webContents.send).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: `${pendingOutput}redraw` @@ -7950,7 +7984,7 @@ describe('registerPtyHandlers', () => { secondProc.emitData('second-terminal-output') firstProc.emitData(`${firstChunk}${firstRemainder}`) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(1, 'pty:data', { @@ -7996,7 +8030,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() firstProc.emitData('x'.repeat(600 * 1024)) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 31; index++) { vi.advanceTimersByTime(1) } @@ -8021,7 +8055,7 @@ describe('registerPtyHandlers', () => { }) secondProc.emitData('second-terminal-output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledTimes(33) expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(33, 'pty:data', { @@ -8060,6 +8094,775 @@ describe('registerPtyHandlers', () => { } }) + it('caps per-PTY pending output while the renderer is starved and heals via a droppedOutput sentinel', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const ackData = getPtyAckDataListener() + mainWindow.webContents.send.mockClear() + + // Saturate the renderer in-flight window (512 KB) with no ACKs — the + // frozen/starved-renderer shape from the field reports. + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(2) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + + // Keep flooding well past the 2 MB per-PTY pending cap. Main must not + // buffer this unboundedly (previously: unbounded string concat). + mockProc.emitData('y'.repeat(3 * 1024 * 1024)) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 0 + }) + + // Later output while dropped must stay O(1), not start re-accumulating. + mockProc.emitData('z'.repeat(64 * 1024)) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 0 + }) + + // Renderer recovers and ACKs: the flush must deliver the droppedOutput + // sentinel so the pane repaints from the main-owned snapshot. + mainWindow.webContents.send.mockClear() + ackData(null, { id: spawn.id, charCount: 512 * 1024 }) + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawn.id, + data: '', + droppedOutput: true + }) + + // Fresh output after the sentinel flows normally again. + mainWindow.webContents.send.mockClear() + mockProc.emitData('back to normal') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawn.id, + data: 'back to normal' + }) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('carves reply-eliciting queries out of a pending-cap bulk drop so probes survive', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawn = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const ackData = getPtyAckDataListener() + mainWindow.webContents.send.mockClear() + + // Saturate the in-flight window so everything after buffers in pendingData. + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(2) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + + // Flood past the pending cap WITH an embedded DSR probe — the program + // that wrote it blocks on the reply (the bench DSR timeout). + mockProc.emitData(`${'y'.repeat(2 * 1024 * 1024)}\x1b[6n${'y'.repeat(1024 * 1024)}`) + // While latched, a later probe must also be carved out (bounded). + mockProc.emitData(`${'z'.repeat(32 * 1024)}\x1b[0c${'z'.repeat(32 * 1024)}`) + + mainWindow.webContents.send.mockClear() + ackData(null, { id: spawn.id, charCount: 512 * 1024 }) + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawn.id, + data: '\x1b[6n\x1b[0c', + droppedOutput: true + }) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('scales the pending-output cap with the scrollback setting', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + // 50k-row scrollback ⇒ 6 MB pending cap instead of the 2 MB floor. + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => ({ + terminalScrollbackRows: 50_000 + })) as never) + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' }) + mainWindow.webContents.send.mockClear() + + // Saturate the in-flight window with no ACKs, then buffer 3 MB — over + // the floor, under the scaled cap: it must be retained, not dropped. + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(2) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + mockProc.emitData('y'.repeat(3 * 1024 * 1024)) + expect(getPtyRendererDeliveryDebugSnapshot().pendingChars).toBeGreaterThan(3 * 1024 * 1024) + + // The scaled cap still bounds a runaway flood. + mockProc.emitData('z'.repeat(4 * 1024 * 1024)) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 0 + }) + } finally { + errorSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('pauses the producer at the pending high watermark and resumes after drain', async () => { + vi.useFakeTimers() + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + + // Flood in 64KB chunks like a `yes`-style producer that honors pause — + // node-pty pause() stops the fd read, so a real producer stops emitting. + const chunk = 'x'.repeat(64 * 1024) + let chunks = 0 + while (provider.pauseProducer.mock.calls.length === 0 && chunks < 100) { + provider.emitData('flood-pty', chunk) + chunks++ + } + + // Pause fires exactly once, on the first chunk past the 256KB high + // watermark (the 5th 64KB chunk), not once per chunk. + expect(provider.pauseProducer).toHaveBeenCalledTimes(1) + expect(provider.pauseProducer).toHaveBeenCalledWith('flood-pty') + expect(chunks).toBe(5) + // Bounded: main buffered at most HIGH + one chunk while paused. + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + pendingPtyCount: 1, + pendingChars: 320 * 1024, + peakPendingChars: 320 * 1024 + }) + + // Drain to the renderer. Resume must fire exactly once — when pending + // drops below the 32KB low watermark — with no pause/resume flapping + // while pending crosses the 32-256KB hysteresis band. + vi.runAllTimers() + expect(provider.resumeProducer).toHaveBeenCalledTimes(1) + expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty') + expect(provider.pauseProducer).toHaveBeenCalledTimes(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingChars: 0 }) + } finally { + vi.useRealTimers() + } + }) + + it('resumes a paused producer when the PTY exits before draining', async () => { + vi.useFakeTimers() + try { + const provider = installObservableDaemonTestProvider() + registerPtyHandlers(mainWindow as never) + mainWindow.webContents.send.mockClear() + + provider.emitData('flood-pty', 'x'.repeat(320 * 1024)) + expect(provider.pauseProducer).toHaveBeenCalledTimes(1) + + // Exit while pending is still above the low watermark: the exit path + // must release the pause instead of leaving a stale mark behind. + provider.emitExit('flood-pty', 0) + expect(provider.resumeProducer).toHaveBeenCalledTimes(1) + expect(provider.resumeProducer).toHaveBeenCalledWith('flood-pty') + } finally { + vi.useRealTimers() + } + }) + + const DELIVERY_RESYNC_UNANSWERED_WARNING = + '[pty] delivery resync probe unanswered — renderer IPC unresponsive' + + function countResyncUnansweredWarnings(warnSpy: { mock: { calls: unknown[][] } }): number { + return warnSpy.mock.calls.filter((call) => call[0] === DELIVERY_RESYNC_UNANSWERED_WARNING) + .length + } + + function getPtyDataSendCalls(): unknown[][] { + return mainWindow.webContents.send.mock.calls.filter( + (call: unknown[]) => call[0] === 'pty:data' + ) + } + + function getDeliveryResyncProbeCalls(): unknown[][] { + return mainWindow.webContents.send.mock.calls.filter( + (call: unknown[]) => call[0] === 'pty:requestDeliveryResync' + ) + } + + function getDeliveryResyncResponseListener(): ( + event: unknown, + args: { requestId: number; processedCharsByPty: Record } + ) => void { + const responseCall = onMock.mock.calls.find( + (call: unknown[]) => call[0] === 'pty:deliveryResyncResponse' + ) + if (!responseCall) { + throw new Error('missing pty:deliveryResyncResponse listener') + } + return responseCall[1] as ( + event: unknown, + args: { requestId: number; processedCharsByPty: Record } + ) => void + } + + /** Saturates one PTY to its 512 KiB in-flight cap so delivery is fully + * gated for that PTY. Leaves 88 KiB pending and no timers scheduled. */ + async function spawnAndSaturateRendererDeliveryGate( + mockProc: ReturnType + ): Promise<{ id: string }> { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(8) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + return spawnResult + } + + it('self-heals lost ACKs when a later cumulative ACK arrives', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + expect(getPtyDataSendCalls()).toHaveLength(32) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + rendererInFlightPtyCount: 1 + }) + + // Every per-chunk ACK was lost, but the next ACK carries the renderer's + // full cumulative total — the debt clears without any timer or reset. + const ackData = getPtyAckDataListener() + ackData(null, { id: spawnResult.id, processedChars: 512 * 1024 }) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + rendererInFlightPtyCount: 0 + }) + + vi.runOnlyPendingTimers() + expect(getPtyDataSendCalls()).toHaveLength(33) + } finally { + vi.useRealTimers() + } + }) + + it('applies cumulative ACKs idempotently and ignores stale reordered totals', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + const ackData = getPtyAckDataListener() + + ackData(null, { id: spawnResult.id, processedChars: 256 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 256 * 1024, + maxRendererInFlightCharsByPty: 256 * 1024 + }) + + // Replayed duplicate credits nothing further. + ackData(null, { id: spawnResult.id, processedChars: 256 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 256 * 1024 + }) + + // A stale reordered total can never move accounting backwards. + ackData(null, { id: spawnResult.id, processedChars: 128 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 256 * 1024 + }) + } finally { + vi.useRealTimers() + } + }) + + it('tolerates mixed legacy delta and cumulative ACK payloads', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + const ackData = getPtyAckDataListener() + + // Legacy delta shape (no processedChars) still credits per chunk. + ackData(null, { id: spawnResult.id, charCount: 16 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 496 * 1024 + }) + + // A cumulative total then supersedes without double-crediting the delta. + ackData(null, { id: spawnResult.id, processedChars: 512 * 1024 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + rendererInFlightPtyCount: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('forwards only newly acknowledged cumulative bytes to provider ACK backpressure', async () => { + vi.useFakeTimers() + const acknowledgeDataEvent = vi.fn() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + setLocalPtyProvider({ + spawn: vi.fn(async () => ({ id: 'cumulative-pty' })), + write: vi.fn(), + resize: vi.fn(), + shutdown: vi.fn(), + sendSignal: vi.fn(), + getCwd: vi.fn(), + getInitialCwd: vi.fn(), + clearBuffer: vi.fn(), + acknowledgeDataEvent, + hasChildProcesses: vi.fn(), + getForegroundProcess: vi.fn(), + serialize: vi.fn(), + revive: vi.fn(), + onData: vi.fn((callback) => { + mockProc.proc.onData((data: string) => callback({ id: 'cumulative-pty', data })) + return () => {} + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: vi.fn(), + getDefaultShell: vi.fn(), + getProfiles: vi.fn() + } as never) + registerPtyHandlers(mainWindow as never) + const ackData = getPtyAckDataListener() + mainWindow.webContents.send.mockClear() + + mockProc.emitData('remote-output') + vi.advanceTimersByTime(8) + + // Why: cumulative totals are clamped to what main actually sent, and a + // replayed total must credit SSH/relay flow control with zero, not + // duplicate bytes. + ackData(null, { id: 'cumulative-pty', processedChars: 1024 }) + ackData(null, { id: 'cumulative-pty', processedChars: 1024 }) + + expect(acknowledgeDataEvent).toHaveBeenNthCalledWith( + 1, + 'cumulative-pty', + 'remote-output'.length + ) + expect(acknowledgeDataEvent).toHaveBeenNthCalledWith(2, 'cumulative-pty', 0) + } finally { + vi.useRealTimers() + } + }) + + it('probes for a delivery resync when data arrives for a fully gated PTY and reconciles on reply', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + + mockProc.emitData('stuck-output') + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) + const probePayload = getDeliveryResyncProbeCalls()[0]![1] as { requestId: number } + + // Only one probe may be outstanding at a time. + mockProc.emitData('still-stuck') + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) + + const respondDeliveryResync = getDeliveryResyncResponseListener() + respondDeliveryResync(null, { + requestId: probePayload.requestId, + processedCharsByPty: { [spawnResult.id]: 512 * 1024 } + }) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + rendererInFlightPtyCount: 0 + }) + + // The reconciled gate lets the held pendingData flush again (one 2ms + // batch window = one 16KB slice). + vi.advanceTimersByTime(2) + expect(getPtyDataSendCalls()).toHaveLength(33) + } finally { + vi.useRealTimers() + } + }) + + it('ignores resync replies with stale request ids', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + mockProc.emitData('stuck-output') + const probePayload = getDeliveryResyncProbeCalls()[0]![1] as { requestId: number } + + const respondDeliveryResync = getDeliveryResyncResponseListener() + respondDeliveryResync(null, { + requestId: probePayload.requestId + 41, + processedCharsByPty: { [spawnResult.id]: 512 * 1024 } + }) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024 + }) + } finally { + vi.useRealTimers() + } + }) + + it('clears an unanswered resync probe, warns once per silent streak, and never mutates counters', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + await spawnAndSaturateRendererDeliveryGate(mockProc) + + mockProc.emitData('stuck-output') + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) + + vi.advanceTimersByTime(4_999) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(0) + + vi.advanceTimersByTime(1) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(1) + expect(warnSpy).toHaveBeenCalledWith( + DELIVERY_RESYNC_UNANSWERED_WARNING, + expect.objectContaining({ + rendererInFlightChars: 512 * 1024, + pendingPtyCount: 1 + }) + ) + // No blind reset: counters and pending output are untouched. + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + pendingChars: 88 * 1024 + 'stuck-output'.length + }) + expect(getPtyDataSendCalls()).toHaveLength(32) + + // The cleared flag lets the next gated arrival probe again, but a + // still-silent renderer does not spam a second warn. + mockProc.emitData('still-stuck') + expect(getDeliveryResyncProbeCalls()).toHaveLength(2) + vi.advanceTimersByTime(5_000) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024 + }) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('clears resync probe state when the window is destroyed', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + let destroyed = false + const destroyableWindow = { + isDestroyed: () => destroyed, + isFocused: () => true, + isVisible: () => true, + isMinimized: () => false, + webContents: { on: vi.fn(), send: vi.fn(), removeListener: vi.fn() } + } + + try { + registerPtyHandlers(destroyableWindow as never) + await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24, cwd: '/tmp' }) + destroyableWindow.webContents.send.mockClear() + mockProc.emitData('x'.repeat(600 * 1024)) + vi.advanceTimersByTime(8) + for (let index = 0; index < 32; index++) { + vi.advanceTimersByTime(1) + } + mockProc.emitData('stuck-output') + // The outstanding probe's hygiene timeout is the only remaining timer; + // the dispatcher-ready handshake already drained the pending flush. + expect(vi.getTimerCount()).toBe(1) + + destroyed = true + mockProc.emitData('post-destroy output') + + expect(vi.getTimerCount()).toBe(0) + vi.advanceTimersByTime(60_000) + expect(countResyncUnansweredWarnings(warnSpy)).toBe(0) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + // ── Renderer-initiated delivery health/heal (pty:reportRendererDeliveryState) ── + // Field wedge repro (v1.4.121-rc.0 snapshot): push delivery dead, invoke + // alive. The solicited-resync probe above rides push and can never be + // answered in that state; this invoke lane is the recovery that can. + + function reportRendererDeliveryState(args: { + receivedCharsByPty: Record + processedCharsByPty: Record + heal?: boolean + rendererPtyDataListenerCount?: number | null + }): { + inFlightTotalChars: number + inFlightPtyCount: number + msSinceLastAck: number | null + writtenOff?: { id: string; markerSeq?: number; writtenOffChars: number }[] + } { + const handler = handlers.get('pty:reportRendererDeliveryState') + if (!handler) { + throw new Error('missing pty:reportRendererDeliveryState handler') + } + return handler(null, args) as ReturnType + } + + it('reports delivery health over invoke without mutating any delivery state', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + await spawnAndSaturateRendererDeliveryGate(mockProc) + + // The field wedge in miniature: renderer received nothing, no ACK ever. + const health = reportRendererDeliveryState({ + receivedCharsByPty: {}, + processedCharsByPty: {} + }) + + expect(health).toMatchObject({ + inFlightTotalChars: 512 * 1024, + inFlightPtyCount: 1, + msSinceLastAck: null + }) + expect(health.writtenOff).toBeUndefined() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + pendingChars: 88 * 1024 + }) + } finally { + vi.useRealTimers() + } + }) + + it('merges cumulative processed totals from a health report as a repair lane', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + + // Lost-ACK variant: renderer processed everything; only the ACK + // messages vanished. A plain report (no heal) must drain the debt. + const health = reportRendererDeliveryState({ + receivedCharsByPty: { [spawnResult.id]: 512 * 1024 }, + processedCharsByPty: { [spawnResult.id]: 512 * 1024 } + }) + + expect(health).toMatchObject({ inFlightTotalChars: 0, inFlightPtyCount: 0 }) + expect(health.writtenOff).toBeUndefined() + // Fully reopened gate drains one 16K slice per batcher tick (0/1/2 ms). + vi.advanceTimersByTime(2) + expect(getPtyDataSendCalls()).toHaveLength(35) + } finally { + vi.useRealTimers() + } + }) + + it('heals a dead push channel: writes off unreceived bytes and returns restore markers', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + + const healed = reportRendererDeliveryState({ + receivedCharsByPty: {}, + processedCharsByPty: {}, + heal: true, + rendererPtyDataListenerCount: 1 + }) + + // The 512 KiB the renderer provably never received is written off; the + // 88 KiB still pending is dropped because the snapshot restore covers + // everything at or before the marker (hidden-drop parity). + expect(healed.writtenOff).toEqual([{ id: spawnResult.id, writtenOffChars: 512 * 1024 }]) + expect(healed).toMatchObject({ inFlightTotalChars: 0, inFlightPtyCount: 0 }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 0, + pendingChars: 0, + pendingDroppedChars: 88 * 1024 + }) + expect(warnSpy).toHaveBeenCalledWith( + '[pty] delivery heal: wrote off renderer-bound bytes lost in push channel', + expect.objectContaining({ rendererPtyDataListenerCount: 1 }) + ) + + // Delivery is unwedged: fresh output flows to the renderer again. + mockProc.emitData('after-heal') + vi.advanceTimersByTime(2) + expect(getPtyDataSendCalls()).toHaveLength(33) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('never writes off bytes the renderer received but has not parsed yet', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + + // Parse backpressure, not a wedge: every byte arrived, ACK credit is + // deferred to the scheduler consume point and will still repay this. + const health = reportRendererDeliveryState({ + receivedCharsByPty: { [spawnResult.id]: 512 * 1024 }, + processedCharsByPty: {}, + heal: true, + rendererPtyDataListenerCount: 1 + }) + + expect(health.writtenOff).toBeUndefined() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 512 * 1024, + pendingChars: 88 * 1024 + }) + } finally { + vi.useRealTimers() + } + }) + + it('refuses a heal while main has seen a recent ACK', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + const spawnResult = await spawnAndSaturateRendererDeliveryGate(mockProc) + const ackData = getPtyAckDataListener() + ackData(null, { id: spawnResult.id, processedChars: 16 * 1024 }) + + // Some pty still round-trips ACKs — whatever the renderer thinks, the + // channel is not dead, so a heal request must not destroy accounting. + const blocked = reportRendererDeliveryState({ + receivedCharsByPty: {}, + processedCharsByPty: {}, + heal: true + }) + expect(blocked.writtenOff).toBeUndefined() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightChars: 496 * 1024 + }) + + // Once main-side ACK silence crosses the floor, the same heal proceeds. + // (The ACK freed a 16K window slot, so one more pending slice shipped + // during the advance — the write-off covers 512K un-received again.) + vi.advanceTimersByTime(10_000) + const healed = reportRendererDeliveryState({ + receivedCharsByPty: {}, + processedCharsByPty: {}, + heal: true + }) + expect(healed.writtenOff).toEqual([{ id: spawnResult.id, writtenOffChars: 512 * 1024 }]) + } finally { + vi.useRealTimers() + } + }) + + it('zeroes renderer in-flight delivery counters when the renderer lifecycle resets', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + await spawnAndSaturateRendererDeliveryGate(mockProc) + const handleRendererLoading = getMainWindowWebContentsListener('did-start-loading') + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightPtyCount: 1, + rendererInFlightChars: 512 * 1024 + }) + + handleRendererLoading() + + // Why: reload kills the renderer dispatcher that would have ACKed, so + // keeping the counters would gate PTYs in the fresh renderer forever. + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + rendererInFlightPtyCount: 0, + rendererInFlightChars: 0 + }) + // Main now holds sends until the replacement page confirms its dispatcher + // is installed; the lifecycle reset arms a bounded handshake watchdog. + expect(vi.getTimerCount()).toBe(1) + + mockProc.emitData('after-reload') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(32) + + getPtyRendererDispatcherReadyListener()() + // One 2ms batch window releases the fresh page's held output. + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(33) + } finally { + vi.useRealTimers() + } + }) + it('forwards only actually in-flight bytes to provider ACK backpressure', async () => { vi.useFakeTimers() const acknowledgeDataEvent = vi.fn() @@ -8097,7 +8900,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() mockProc.emitData('remote-output') - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: 'remote-like-pty', @@ -8148,7 +8951,7 @@ describe('registerPtyHandlers', () => { for (const proc of bulkProcs) { proc.emitData('x'.repeat(600 * 1024)) } - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 400; index++) { vi.advanceTimersByTime(1) } @@ -8183,7 +8986,10 @@ describe('registerPtyHandlers', () => { data: 'a' }) interactiveProc.emitData(reserveChunk) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(529) + // Why: the reserve-exhausted send stays gated, and the fully gated + // arrival now also emits one delivery resync probe (not pty:data). + expect(getPtyDataSendCalls()).toHaveLength(529) + expect(getDeliveryResyncProbeCalls()).toHaveLength(1) } finally { vi.useRealTimers() } @@ -8214,7 +9020,7 @@ describe('registerPtyHandlers', () => { for (const proc of procs) { proc.emitData('x'.repeat(600 * 1024)) } - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 400; index++) { vi.advanceTimersByTime(1) } @@ -8255,7 +9061,7 @@ describe('registerPtyHandlers', () => { for (let index = 0; index < procs.length - 1; index++) { procs[index]!.emitData('x'.repeat(600 * 1024)) } - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 400; index++) { vi.advanceTimersByTime(1) } @@ -8264,13 +9070,18 @@ describe('registerPtyHandlers', () => { const activeIndex = procs.length - 1 procs[activeIndex]!.emitData('active-output') setActiveRendererPty(null, { id: spawns[activeIndex]!.id, active: true }) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) - expect(mainWindow.webContents.send).toHaveBeenCalledTimes(513) - expect(mainWindow.webContents.send).toHaveBeenNthCalledWith(513, 'pty:data', { - id: spawns[activeIndex]!.id, - data: 'active-output' - }) + // Why: the fully gated arrival also emits one delivery resync probe, so + // count pty:data sends rather than raw webContents.send calls. + expect(getPtyDataSendCalls()).toHaveLength(513) + expect(getPtyDataSendCalls()[512]).toEqual([ + 'pty:data', + { + id: spawns[activeIndex]!.id, + data: 'active-output' + } + ]) expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ activeRendererPtyCount: 1, pendingPtyCount: procs.length - 1, @@ -8298,7 +9109,7 @@ describe('registerPtyHandlers', () => { mainWindow.webContents.send.mockClear() activeProc.emitData('x'.repeat(768 * 1024)) - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) for (let index = 0; index < 31; index++) { vi.advanceTimersByTime(1) } @@ -8329,6 +9140,809 @@ describe('registerPtyHandlers', () => { } }) + describe('hidden renderer delivery gate', () => { + it('drops hidden PTY data after model ingestion and emits one out-of-band restore marker', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'hidden output') + vi.advanceTimersByTime(50) + + // Model ingestion still ran — only renderer delivery was dropped. + expect(runtime.onPtyData).toHaveBeenCalledWith( + result.id, + 'hidden output', + expect.any(Number), + 'hidden output'.length + ) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + // Why out-of-band: an in-band empty pty:data chunk is ambiguous with + // chunks fully consumed by renderer OSC-9999 stripping. + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'hidden-drop', + markerSeq: 42 + }) + + // Subsequent gated chunks drop silently — the marker is one-shot. + daemon.emitData(result.id, 'more hidden output') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 1, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + hiddenDeliveryDroppedChars: 'hidden output'.length + 'more hidden output'.length, + hiddenDeliveryDroppedChunks: 2, + pendingPtyCount: 0, + rendererInFlightChars: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('surfaces the hidden-yet-visible contradiction in the snapshot and warns on drop', async () => { + // Why: v1.4.124-rc.2.perf field snapshot — blank terminal with 2 ptys + // hidden-gated and 78MB dropped. The aggregate counts could not say + // whether the pane the user was staring at was one of them; this + // overlap counter + warn makes the next occurrence decisive. + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setVisible = getPtySetRendererPtyVisibleListener() + + // The two renderer visibility signals contradict: the pane reports + // itself visible while the hidden-delivery gate still holds it. + setVisible(null, { id: result.id, visible: true }) + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'starved visible output') + vi.advanceTimersByTime(50) + + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 1, + hiddenDeliveryGatedVisiblePtyCount: 1, + hiddenDeliveryDroppedChars: 'starved visible output'.length + }) + expect(warnSpy).toHaveBeenCalledWith( + '[pty] hidden-delivery gate is dropping bytes for a visible/active pty', + expect.objectContaining({ id: result.id, visible: true }) + ) + + // Unhiding resolves the contradiction. + setHidden(null, { id: result.id, hidden: false }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0 + }) + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('embeds one-paste freeze diagnostics: per-pty table and breadcrumb history', async () => { + vi.useFakeTimers() + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setVisible = getPtySetRendererPtyVisibleListener() + setVisible(null, { id: result.id, visible: true }) + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'starved visible output') + vi.advanceTimersByTime(50) + + const { diagnostics } = getPtyRendererDeliveryDebugSnapshot() + expect(diagnostics.appVersion).toBe('0.0.0-test') + expect(diagnostics.windowFocused).toBe(true) + expect(diagnostics.windowVisible).toBe(true) + const entry = diagnostics.perPty.find( + (candidate) => candidate.id === redactPtyIdForDiagnostics(result.id) + ) + expect(entry).toMatchObject({ + hidden: true, + visible: true, + inFlightChars: 0, + pendingChars: 0 + }) + // Why redaction is pinned here: daemon session ids embed worktree + // paths; the report must never carry the raw id. + expect(diagnostics.perPty.some((candidate) => candidate.id === result.id)).toBe(false) + const breadcrumbKinds = diagnostics.breadcrumbs.map((crumb) => crumb.kind) + expect(breadcrumbKinds).toContain('gate-mark') + expect(breadcrumbKinds).toContain('hidden-drop-visible') + } finally { + warnSpy.mockRestore() + vi.useRealTimers() + } + }) + + it('keeps the interactive bypass gated for hidden PTYs', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const writeListener = getPtyWriteListener() + const setHidden = getPtySetHiddenRendererPtyListener() + + writeListener(mainWindowIpcEvent, { id: spawnResult.id, data: 'a' }) + setHidden(null, { id: spawnResult.id, hidden: true }) + mainWindow.webContents.send.mockClear() + + // A keystroke-sized redraw would take the immediate path when visible. + mockProc.emitData('\x1b[20;2Hredraw') + vi.advanceTimersByTime(2) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it('suppresses the gate while renderer delivery interest is registered', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setInterest = getPtySetDeliveryInterestListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + setInterest(null, { id: spawnResult.id, interested: true }) + mockProc.emitData('sidecar bytes') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'sidecar bytes' + }) + + setInterest(null, { id: spawnResult.id, interested: false }) + mainWindow.webContents.send.mockClear() + mockProc.emitData('gated bytes') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + ['terminalHiddenDeliveryGate', { terminalHiddenDeliveryGate: false }], + ['terminalMainSideEffectAuthority', { terminalMainSideEffectAuthority: false }] + ])('keeps delivery when the %s kill switch is off', async (_name, settings) => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => settings) as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('still delivered') + vi.advanceTimersByTime(2) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'still delivered' + }) + } finally { + vi.useRealTimers() + } + }) + + it('drops queued pending data when a PTY is marked hidden', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + mockProc.emitData('queued before hidden') + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + setHidden(null, { id: spawnResult.id, hidden: true }) + + // The queued bytes are model-owned; only the restore marker goes out. + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ pendingPtyCount: 0 }) + } finally { + vi.useRealTimers() + } + }) + + it('re-emits the restore marker on unhide and resumes delivery', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('dropped while hidden') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Why: a renderer reload can replace the view that latched + // restore-needed; unhide repeats the marker so the live view heals. + setHidden(null, { id: spawnResult.id, hidden: false }) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'unhide' + }) + + mockProc.emitData('visible again') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: 'visible again' + }) + } finally { + vi.useRealTimers() + } + }) + + it('does not emit an unhide marker when nothing was dropped', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + setHidden(null, { id: spawnResult.id, hidden: false }) + + expect(mainWindow.webContents.send).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + it('clears gate state on PTY exit', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + + setHidden(null, { id: spawnResult.id, hidden: true }) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 1 + }) + + mockProc.emitExit(0) + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0, + deliveryInterestPtyCount: 0 + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps drop memory across a hidden remount so reveal still restores', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: spawnResult.id, hidden: true }) + mockProc.emitData('dropped while hidden') + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Why: a hidden remount (tab move, parking handoff) re-marks the PTY + // without an unhide in between. The fresh view never saw the first + // marker, so re-marking must NOT erase the drop memory. + setHidden(null, { id: spawnResult.id, hidden: true }) + setHidden(null, { id: spawnResult.id, hidden: false }) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'unhide' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps drop memory across a renderer reload while clearing hidden/interest state', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + // Why daemon provider: it survives renderer reloads (the scenario + // under test) and keeps the LocalPtyProvider orphan-kill handler off + // this webContents, so 'did-finish-load' maps to the gate reset only. + const reloadHandlers = mainWindow.webContents.on.mock.calls + .filter((call: unknown[]) => call[0] === 'did-finish-load') + .map((call: unknown[]) => call[1] as () => void) + expect(reloadHandlers).toHaveLength(1) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + mainWindow.webContents.send.mockClear() + + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'dropped while hidden') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Renderer reload: hidden marks die with the old renderer, but the + // dropped bytes were never restored — memory must survive. + reloadHandlers[0]() + expect(getPtyRendererDeliveryDebugSnapshot()).toMatchObject({ + hiddenDeliveryGatedPtyCount: 0 + }) + + // The reloaded pane's first sync re-marks hidden, then reveals. + setHidden(null, { id: result.id, hidden: true }) + setHidden(null, { id: result.id, hidden: false }) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'unhide', + markerSeq: 42 + }) + } finally { + vi.useRealTimers() + } + }) + + it('clears leaked delivery interest on renderer reload so the gate re-engages', async () => { + vi.useFakeTimers() + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + const daemon = installObservableDaemonTestProvider() + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const reloadHandlers = mainWindow.webContents.on.mock.calls + .filter((call: unknown[]) => call[0] === 'did-finish-load') + .map((call: unknown[]) => call[1] as () => void) + expect(reloadHandlers).toHaveLength(1) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session' + })) as { id: string } + const setHidden = getPtySetHiddenRendererPtyListener() + const setInterest = getPtySetDeliveryInterestListener() + mainWindow.webContents.send.mockClear() + + // A sidecar holds interest, so hidden bytes still flow. + setInterest(null, { id: result.id, interested: true }) + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'sidecar bytes') + vi.advanceTimersByTime(50) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith( + 'pty:data', + expect.objectContaining({ id: result.id, data: 'sidecar bytes' }) + ) + + // Why: the renderer reload killed the sidecar's ref count without a + // release IPC — the leaked hold must not force-feed the PTY forever. + reloadHandlers[0]() + mainWindow.webContents.send.mockClear() + setHidden(null, { id: result.id, hidden: true }) + daemon.emitData(result.id, 'gated after reload') + vi.advanceTimersByTime(50) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: result.id, + reason: 'hidden-drop', + markerSeq: 42 + }) + } finally { + vi.useRealTimers() + } + }) + }) + + describe('hidden-at-spawn mark (initiallyHidden)', () => { + // terminal-query-authority.md §races: the renderer declares hidden-at- + // spawn so main marks the PTY before its first byte — the spawn-time + // query window where neither side replied (the non-codex DA1 loss) is + // closed by the gate + responder owning queries from byte one. + function createRuntimeMock() { + return { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(() => 42), + getPtyOutputSequence: vi.fn(() => 42), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + createPreAllocatedTerminalHandle: vi.fn(() => 'terminal-handle-1'), + registerPreAllocatedHandleForPty: vi.fn() + } + } + + it('marks a daemon PTY hidden before spawn resolves so byte zero is gated', async () => { + vi.useFakeTimers() + const runtime = createRuntimeMock() + const daemon = installObservableDaemonTestProvider() + const spawnGate = makeDeferred() + daemon.spawn.mockImplementation(async (options: { sessionId?: string }) => { + await spawnGate.promise + return { id: options.sessionId ?? 'daemon-pty' } + }) + try { + registerPtyHandlers(mainWindow as never, runtime as never) + const spawnPromise = handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + }) as Promise<{ id: string }> + // Let the handler run up to the awaited provider.spawn. + await Promise.resolve() + mainWindow.webContents.send.mockClear() + + // Daemon PTYs can emit prompt bytes before spawn() resolves — the + // pre-spawn mark must already gate them. + expect(isHiddenRendererPty('daemon-session')).toBe(true) + daemon.emitData('daemon-session', 'pre-spawn prompt\x1b[c') + vi.advanceTimersByTime(50) + expect(runtime.onPtyData).toHaveBeenCalledWith( + 'daemon-session', + 'pre-spawn prompt\x1b[c', + expect.any(Number), + 'pre-spawn prompt\x1b[c'.length + ) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: 'daemon-session', + reason: 'hidden-drop', + markerSeq: 42 + }) + + spawnGate.resolve() + const result = await spawnPromise + expect(isHiddenRendererPty(result.id)).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('clears the pre-spawn hidden mark when the spawn fails', async () => { + const daemon = installObservableDaemonTestProvider() + daemon.spawn.mockRejectedValue(new Error('spawn exploded')) + registerPtyHandlers(mainWindow as never) + + await expect( + handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + }) + ).rejects.toThrow('spawn exploded') + + // A later visible attach reusing this session id must not start gated. + expect(isHiddenRendererPty('daemon-session')).toBe(false) + }) + + it('marks local PTYs hidden after spawn, before their first data task', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp', + initiallyHidden: true + })) as { id: string } + mainWindow.webContents.send.mockClear() + + expect(isHiddenRendererPty(spawnResult.id)).toBe(true) + mockProc.emitData('first chunk') + vi.advanceTimersByTime(2) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'hidden-drop' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps spawns without the flag delivering to the renderer (visible unchanged)', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + expect(isHiddenRendererPty(spawnResult.id)).toBe(false) + mockProc.emitData('visible output') + vi.advanceTimersByTime(2) + + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: spawnResult.id, + data: 'visible output' + }) + } finally { + vi.useRealTimers() + } + }) + + it('answers DA1 from the model on the first chunk of a hidden-at-spawn PTY', async () => { + // End-to-end through a REAL runtime: spawn-marked → first chunk dropped + // → runtime emulator parses the query → reply written to the provider + // input path (the renderer never saw the bytes; main is the answerer). + const daemon = installObservableDaemonTestProvider() + const runtime = new OrcaRuntimeService({ + getRepo: () => undefined, + getRepos: () => [], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getGitHubCache: () => ({ pr: {}, issue: {} }) as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true + }) + } as never) + + registerPtyHandlers(mainWindow as never, runtime as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + sessionId: 'daemon-session', + initiallyHidden: true + })) as { id: string } + + daemon.emitData(result.id, '\x1b[c') + // Settle the per-PTY emulator writeChain (and the reply it forwards). + await runtime.serializeMainTerminalBuffer(result.id) + + expect(daemon.write).toHaveBeenCalledWith(result.id, '\x1b[?1;2c') + }) + }) + + it('caps pending renderer delivery per PTY with oldest-drop and one restore marker', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + // 3 MB in one starved pending entry: the scrollback-scaled cap (2 MB at + // default settings) drops the buffered bytes to O(1) memory. One + // out-of-band restore marker fires; the droppedOutput sentinel then + // routes the pane through the main-owned snapshot repaint. + mockProc.emitData('x'.repeat(1024 * 1024) + 'y'.repeat(2 * 1024 * 1024)) + + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: spawnResult.id, + reason: 'pending-cap' + }) + + // A second overflow before the entry drains must not re-mark. + mockProc.emitData('z'.repeat(64 * 1024)) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(1) + + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenCalledTimes(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: '', + droppedOutput: true + }) + } finally { + vi.useRealTimers() + } + }) + + it.each([ + ['terminalHiddenDeliveryGate', { terminalHiddenDeliveryGate: false }], + ['terminalMainSideEffectAuthority', { terminalMainSideEffectAuthority: false }] + ])( + 'keeps the pending cap active without a restore marker when the %s kill switch is off', + async (_name, settings) => { + // Why: the scrollback-scaled pending cap ships independently of the gate + // (#7150) — the droppedOutput sentinel repaints the pane from the + // main-owned snapshot even with the model/view kill switches off. Only the + // gate's out-of-band pty:modelRestoreNeeded marker is switch-scoped. + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + + try { + registerPtyHandlers(mainWindow as never, undefined, undefined, (() => settings) as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + mainWindow.webContents.send.mockClear() + + mockProc.emitData('x'.repeat(3 * 1024 * 1024)) + + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith( + 'pty:modelRestoreNeeded', + expect.anything() + ) + + vi.advanceTimersByTime(2) + expect(mainWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: spawnResult.id, + data: '', + droppedOutput: true + }) + } finally { + vi.useRealTimers() + } + } + ) + it('batches stale PTY output after the interactive window expires', async () => { vi.useFakeTimers() const mockProc = createMockProc() @@ -8353,7 +9967,7 @@ describe('registerPtyHandlers', () => { mockProc.emitData('stale redraw') expect(mainWindow.webContents.send).not.toHaveBeenCalled() - vi.advanceTimersByTime(8) + vi.advanceTimersByTime(2) expect(mainWindow.webContents.send).toHaveBeenCalledWith('pty:data', { id: spawnResult.id, data: 'stale redraw' @@ -8500,6 +10114,34 @@ describe('registerPtyHandlers', () => { expect(mockProc.proc.write).not.toHaveBeenCalled() }) + it('silently drops writes to a live PTY after ownership loss until pty:listSessions rebuilds it (frozen-terminal repro)', async () => { + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + registerPtyHandlers(mainWindow as never) + const result = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24 + })) as { id: string } + const write = getPtyWriteListener() + + write(mainWindowIpcEvent, { id: result.id, data: 'alive' }) + expect(mockProc.proc.write).toHaveBeenCalledWith('alive') + + // Field failure shape (Discord #performance / #2836): a pane can keep + // rendering with a ptyId whose ownership entry is gone while the provider + // still holds the live PTY — every keystroke then vanishes with no error, + // no log, and no signal back to the renderer. + deletePtyOwnership(result.id) + write(mainWindowIpcEvent, { id: result.id, data: 'dropped' }) + expect(mockProc.proc.write).not.toHaveBeenCalledWith('dropped') + + // pty:listSessions rebuilds ownership from provider sessions — the + // revival lever the frozen-pane e2e probes depend on. + await handlers.get('pty:listSessions')!(null, undefined) + write(mainWindowIpcEvent, { id: result.id, data: 'revived' }) + expect(mockProc.proc.write).toHaveBeenCalledWith('revived') + }) + it('chunks large acknowledged pty writes before provider writes', async () => { const mockProc = createMockProc() spawnMock.mockReturnValue(mockProc.proc) @@ -8583,6 +10225,7 @@ describe('registerPtyHandlers', () => { } as never) const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), seedHeadlessTerminal: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), @@ -8848,6 +10491,7 @@ describe('registerPtyHandlers', () => { } const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), onPtyExit: vi.fn(), @@ -8888,6 +10532,7 @@ describe('registerPtyHandlers', () => { } const runtime = { setPtyController: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), onPtySpawned: vi.fn(), onPtyData: vi.fn(), onPtyExit: vi.fn(), @@ -8896,16 +10541,23 @@ describe('registerPtyHandlers', () => { spawnMock.mockReturnValue(proc) registerPtyHandlers(mainWindow as never, runtime as never) - const didFinishLoad = mainWindow.webContents.on.mock.calls.find( - ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined - expect(didFinishLoad).toBeTypeOf('function') + // Why both: a reload fires the hidden-delivery gate reset AND the orphan + // cleanup; invoke every registered listener like a real did-finish-load. + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + expect(didFinishLoadHandlers.length).toBeGreaterThan(0) + const didFinishLoad = (): void => { + for (const handler of didFinishLoadHandlers) { + handler() + } + } await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 }) // The first load after spawn only advances generation. The second one sees // this PTY as belonging to a prior page load and kills it as orphaned. - didFinishLoad?.() - didFinishLoad?.() + didFinishLoad() + didFinishLoad() expect(onDataDisposable.dispose.mock.invocationCallOrder[0]).toBeLessThan( killSpy.mock.invocationCallOrder[0] @@ -8918,6 +10570,9 @@ describe('registerPtyHandlers', () => { it('removes the previous orphan-cleanup listener from its original webContents', () => { const firstWindow = { isDestroyed: () => false, + isFocused: () => true, + isVisible: () => true, + isMinimized: () => false, webContents: { on: vi.fn(), send: vi.fn(), @@ -8926,6 +10581,9 @@ describe('registerPtyHandlers', () => { } const secondWindow = { isDestroyed: () => false, + isFocused: () => true, + isVisible: () => true, + isMinimized: () => false, webContents: { on: vi.fn(), send: vi.fn(), @@ -8934,10 +10592,12 @@ describe('registerPtyHandlers', () => { } registerPtyHandlers(firstWindow as never) - const didFinishLoad = firstWindow.webContents.on.mock.calls.find( + // Two listeners on the first (LocalPtyProvider) window: the renderer-gate + // reset and the orphan cleanup. + const firstWindowLoadHandlers = firstWindow.webContents.on.mock.calls.filter( ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined - expect(didFinishLoad).toBeTypeOf('function') + ) + expect(firstWindowLoadHandlers).toHaveLength(2) setLocalPtyProvider({ spawn: vi.fn(), @@ -8952,13 +10612,20 @@ describe('registerPtyHandlers', () => { } as never) registerPtyHandlers(secondWindow as never) - expect(firstWindow.webContents.removeListener).toHaveBeenCalledWith( - 'did-finish-load', - didFinishLoad - ) + // Every first-window load listener was detached from its webContents. + for (const [, handler] of firstWindowLoadHandlers) { + expect(firstWindow.webContents.removeListener).toHaveBeenCalledWith( + 'did-finish-load', + handler + ) + } + // The non-Local provider keeps orphan cleanup off the second window — + // only the renderer-gate reset listener remains. expect( - secondWindow.webContents.on.mock.calls.some(([eventName]) => eventName === 'did-finish-load') - ).toBe(false) + secondWindow.webContents.on.mock.calls.filter( + ([eventName]) => eventName === 'did-finish-load' + ) + ).toHaveLength(1) }) // Why (#5787): a crash/freeze-recovery reload re-fires did-finish-load on the @@ -8995,10 +10662,14 @@ describe('registerPtyHandlers', () => { undefined, { isRecoveryReloadInFlight } ) - const didFinishLoad = mainWindow.webContents.on.mock.calls.find( - ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined - expect(didFinishLoad).toBeTypeOf('function') + // This branch registers two did-finish-load listeners (renderer delivery-gate + // reset first, orphan sweep second); a real reload fires both, so must we — + // otherwise the suppression assertion passes vacuously without reaching the sweep. + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + expect(didFinishLoadHandlers.length).toBeGreaterThan(0) + const didFinishLoad = (): void => didFinishLoadHandlers.forEach((handler) => handler()) const spawnResult = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { id: string @@ -9006,8 +10677,8 @@ describe('registerPtyHandlers', () => { // Without the guard the second load would sweep this PTY as a prior-generation // orphan. Under recovery-in-flight neither load may touch it. - didFinishLoad?.() - didFinishLoad?.() + didFinishLoad() + didFinishLoad() expect(killSpy).not.toHaveBeenCalled() expect(runtime.onPtyExit).not.toHaveBeenCalled() @@ -9050,9 +10721,12 @@ describe('registerPtyHandlers', () => { undefined, { isRecoveryReloadInFlight } ) - const didFinishLoad = mainWindow.webContents.on.mock.calls.find( - ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined + // This branch registers two did-finish-load listeners (renderer delivery-gate + // reset first, orphan sweep second); a real reload fires both, so must we. + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + const didFinishLoad = (): void => didFinishLoadHandlers.forEach((handler) => handler()) const spawnResult = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { id: string @@ -9060,8 +10734,8 @@ describe('registerPtyHandlers', () => { // First load only advances the generation; the second sees this PTY as a // prior-load orphan. With the flag false the guard must NOT suppress the sweep. - didFinishLoad?.() - didFinishLoad?.() + didFinishLoad() + didFinishLoad() expect(killSpy).toHaveBeenCalled() expect(runtime.onPtyExit).toHaveBeenCalledWith(spawnResult.id, -1) @@ -9092,9 +10766,12 @@ describe('registerPtyHandlers', () => { undefined, { isRecoveryReloadInFlight } ) - const didFinishLoad = mainWindow.webContents.on.mock.calls.find( - ([eventName]) => eventName === 'did-finish-load' - )?.[1] as (() => void) | undefined + // Fire ALL did-finish-load listeners (gate reset + orphan sweep), as a + // real reload does — the sweep listener is the one under test. + const didFinishLoadHandlers = mainWindow.webContents.on.mock.calls + .filter(([eventName]) => eventName === 'did-finish-load') + .map(([, handler]) => handler as () => void) + const didFinishLoad = (): void => didFinishLoadHandlers.forEach((handler) => handler()) spawnMock.mockReturnValue({ onData: vi.fn(() => makeDisposable()), @@ -9109,7 +10786,7 @@ describe('registerPtyHandlers', () => { // Advance the generation without sweeping (recovery-in-flight), then spawn a // second PTY so the two live in different load generations. - didFinishLoad?.() + didFinishLoad() spawnMock.mockReturnValue({ onData: vi.fn(() => makeDisposable()), @@ -9122,7 +10799,7 @@ describe('registerPtyHandlers', () => { }) const ptyB = (await handlers.get('pty:spawn')!(null, { cols: 80, rows: 24 })) as { id: string } - didFinishLoad?.() + didFinishLoad() expect(killSpyA).not.toHaveBeenCalled() expect(killSpyB).not.toHaveBeenCalled() @@ -9384,6 +11061,7 @@ describe('registerPtyHandlers', () => { it('returns a hidden-output recovery snapshot with clamped scrollback', async () => { const runtime = { setPtyController: vi.fn(), + getPtyOutputSequence: vi.fn(() => 42), serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ data: 'snapshot\r\n', cols: 120, @@ -9404,14 +11082,146 @@ describe('registerPtyHandlers', () => { expect(runtime.serializeHiddenOutputRecoveryBuffer).toHaveBeenCalledWith('pty-1', { scrollbackRows: 50_000 }) + // Why pendingDeliveryStartSeq === seq: the pending renderer-delivery + // queue is empty, so the renderer's post-restore duplicate window is + // empty too — low-seq live chunks (fresh seq domain) must not be + // dropped against the snapshot baseline. expect(result).toEqual({ data: 'snapshot\r\n', cols: 120, rows: 40, cwd: '/projects/restored', seq: 42, + pendingDeliveryStartSeq: 42, source: 'headless' }) }) + + it('uses the complete provider model after daemon stream thinning', async () => { + const provider = installObservableDaemonTestProvider() + provider.getBufferSnapshot.mockResolvedValue({ + data: 'complete daemon scrollback\r\n', + cols: 100, + rows: 30, + seq: 900, + source: 'headless' + }) + const runtime = { + setPtyController: vi.fn(), + getPtyOutputSequence: vi.fn(() => 640), + notePtyDataGap: vi.fn(), + onPtyExit: vi.fn(), + serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ + data: 'kept tail only\r\n', + cols: 100, + rows: 30, + seq: 640, + source: 'headless' + }) + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + provider.emitDataGap('daemon-pty', 512) + + const result = await handlers.get('pty:getMainBufferSnapshot')!(null, { + id: 'daemon-pty', + opts: { scrollbackRows: 5000 } + }) + + expect(runtime.notePtyDataGap).toHaveBeenCalledWith('daemon-pty', 512) + expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-pty', { + scrollbackRows: 5000 + }) + expect(runtime.serializeHiddenOutputRecoveryBuffer).not.toHaveBeenCalled() + expect(result).toEqual({ + data: 'complete daemon scrollback\r\n', + cols: 100, + rows: 30, + seq: 900, + source: 'headless', + // Bytes between main's current absolute seq and the daemon snapshot + // may still be queued on the stream socket and must dedupe on arrival. + pendingDeliveryStartSeq: 640 + }) + provider.emitExit('daemon-pty') + }) + + it("never paints main's incomplete tail when a required provider snapshot is unavailable", async () => { + const provider = installObservableDaemonTestProvider() + provider.getBufferSnapshot.mockResolvedValue(null) + const runtime = { + setPtyController: vi.fn(), + getPtyOutputSequence: vi.fn(() => 640), + notePtyDataGap: vi.fn(), + onPtyExit: vi.fn(), + serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ + data: 'kept tail only\r\n', + cols: 100, + rows: 30, + seq: 640, + source: 'headless' + }) + } + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + provider.emitDataGap('daemon-pty', 512) + + const result = await handlers.get('pty:getMainBufferSnapshot')!(null, { + id: 'daemon-pty', + opts: { scrollbackRows: 5000 } + }) + + expect(provider.getBufferSnapshot).toHaveBeenCalledWith('daemon-pty', { + scrollbackRows: 5000 + }) + expect(runtime.serializeHiddenOutputRecoveryBuffer).not.toHaveBeenCalled() + expect(result).toBeNull() + provider.emitExit('daemon-pty') + }) + + it('reports where the undelivered pending backlog starts alongside the snapshot', async () => { + vi.useFakeTimers() + const mockProc = createMockProc() + spawnMock.mockReturnValue(mockProc.proc) + const runtime = { + setPtyController: vi.fn(), + registerPty: vi.fn(), + noteTerminalSpawnCommand: vi.fn(), + onPtySpawned: vi.fn(), + onPtyExit: vi.fn(), + onPtyData: vi.fn(), + preAllocateHandleForPty: vi.fn(() => null), + getPtyOutputSequence: vi.fn(() => 2_472), + hasRemoteTerminalViewSubscriber: vi.fn(() => false), + serializeHiddenOutputRecoveryBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot\r\n', + cols: 100, + rows: 30, + seq: 2_472, + source: 'headless' + }) + } + try { + handlers.clear() + registerPtyHandlers(mainWindow as never, runtime as never) + const spawnResult = (await handlers.get('pty:spawn')!(null, { + cols: 80, + rows: 24, + cwd: '/tmp' + })) as { id: string } + + // Starved pending entry: bytes ingested up to seq 2_472 but not yet + // flushed to the renderer — they can still arrive after the snapshot. + mockProc.emitData('frame-bytes') + + const result = (await handlers.get('pty:getMainBufferSnapshot')!(null, { + id: spawnResult.id + })) as { pendingDeliveryStartSeq?: number } + + expect(result.pendingDeliveryStartSeq).toBe(2_472 - 'frame-bytes'.length) + } finally { + vi.useRealTimers() + } + }) }) }) diff --git a/src/main/ipc/pty.ts b/src/main/ipc/pty.ts index 7564b09b750..0ca456f7607 100644 --- a/src/main/ipc/pty.ts +++ b/src/main/ipc/pty.ts @@ -12,12 +12,28 @@ import { type IpcMainInvokeEvent, type WebContents, ipcMain, - app + app, + powerMonitor } from 'electron' export { getBashShellReadyRcfileContent } from '../providers/local-pty-shell-ready' import type { OrcaRuntimeService } from '../runtime/orca-runtime' import type { Store } from '../persistence' import type { GlobalSettings, TuiAgent } from '../../shared/types' +import { terminalOutputBacklogCapChars } from '../../shared/terminal-scrollback-policy' +import type { + PtyDeliveryWriteOff, + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../../shared/pty-renderer-delivery-health' +import { extractHiddenStartupRendererQueryData } from '../../shared/terminal-reply-query-extraction' +import { + type PtyMainDeliveryDiagnostics, + type PtyPerPtyDeliveryDiagnostics, + EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, + createPtyDeliveryBreadcrumbRing, + redactPtyIdForDiagnostics +} from '../../shared/pty-delivery-diagnostics' +import { recordCrashBreadcrumb } from '../crash-reporting/crash-breadcrumb-store' import type { SleepingAgentLaunchConfig } from '../../shared/agent-session-resume' import type { ProjectExecutionRuntimeResolution } from '../../shared/project-execution-runtime' import { @@ -48,6 +64,7 @@ import { parseAppSshPtyId, toAppSshPtyId, toRelaySshPtyId } from '../providers/s import { createPtySpawnTiming } from './pty-spawn-timing' import { mintPtySessionId, isSafePtySessionId } from '../daemon/pty-session-id' import { addNodePtyRecoveryHint } from '../daemon/node-pty-error-hints' +import { recordDaemonStreamBacklogEvent } from '../daemon/daemon-stream-backlog-probe' import type { ClaudeRuntimeAuthPreparation } from '../claude-accounts/runtime-auth-service' import type { ClaudeAccountSelectionTarget } from '../claude-accounts/runtime-selection' import { CLAUDE_AUTH_ENV_VARS, hasClaudeAuthEnvConflict } from '../claude-accounts/environment' @@ -96,6 +113,29 @@ import { import { parseWslPath } from '../wsl' import { mergePersistedWindowsPath } from '../pty/windows-environment-path' import { addOrcaWslInteropEnv } from '../pty/wsl-orca-env' +import { PtyProducerFlowController } from './pty-producer-flow-control' +import { + clearHiddenRendererPtyDeliveryState, + getHiddenRendererPtyDeliveryDebug, + getHiddenRendererPtyIds, + isHiddenPtyDeliveryGateEnabled, + isHiddenRendererPty, + markHiddenRendererPty, + recordHiddenRendererPtyDataDrop, + resetHiddenRendererPtyDeliveryDebugCounters, + resetRendererScopedHiddenPtyDeliveryState, + setRendererPtyDeliveryInterest, + shouldDropHiddenRendererPtyData, + unmarkHiddenRendererPty +} from './pty-hidden-delivery-gate' +import { + clearNativeWindowsConptyPty, + isNativeWindowsLocalPtySpawn, + markNativeWindowsConptyPty +} from '../runtime/terminal-model-query-authority' +import { setTerminalViewAttributes } from '../runtime/terminal-view-attribute-store' +import { validateTerminalViewAttributes } from '../../shared/terminal-view-attributes' +import type { PtyModelRestoreReason } from '../../shared/pty-model-restore-marker' import type { CodexAccountSelectionTarget } from '../codex-accounts/runtime-selection' import { isHostCodexHomeForWsl, isWslCodexHomeForHost } from '../pty/codex-home-wsl-env' import { buildConfiguredProxyEnv, type NetworkProxySettings } from '../../shared/network-proxy' @@ -125,6 +165,10 @@ type FreshLocalFallbackProvider = IPtyProvider & { } const sshProviders = new Map() const SYNTHETIC_KILL_EXIT_DUPLICATE_WINDOW_MS = 30_000 +// Why: producer flow control changes terminal physics — a flooding shell now +// blocks on write instead of buffering in main. Kill switch: flip this one +// line to disable pause/resume entirely without untangling the wiring. +const PRODUCER_FLOW_CONTROL_ENABLED = true // Why: PTY IDs are assigned at spawn time with a connectionId, but subsequent // write/resize/kill calls only carry the PTY ID. This map lets us route // post-spawn operations to the correct provider without the renderer needing @@ -1069,6 +1113,14 @@ export function clearProviderPtyState(id: string): void { pendingHiddenRendererResizeOutputPtys.delete(id) deliveredHiddenRendererResizeOutputPtys.delete(id) clearStartupTerminalColorQueryReplies(id) + // Why: every PTY teardown path funnels through here (local exit, daemon + // shutdown, SSH exit/connection teardown) — hidden/interest gate bits must + // not outlive the PTY or a reused map entry could silently gate a new one. + clearHiddenRendererPtyDeliveryState(id) + clearBackgroundedDeliverySyncForPty(id) + providerSnapshotRequiredPtys.delete(id) + // Why: the Phase-5 ConPTY DA1 spawn record must not leak onto a reused id. + clearNativeWindowsConptyPty(id) const paneKey = ptyPaneKey.get(id) const stillOwnsPaneKey = paneKey ? paneKeyPtyId.get(paneKey) === id : false // Why: drop the memory-collector registration so a dead PTY does not keep @@ -1144,10 +1196,25 @@ export function setPtyOwnership(id: string, connectionId: string | null): void { // duplicate listeners that forward every event twice. let localDataUnsub: (() => void) | null = null let localExitUnsub: (() => void) | null = null +let localBackgroundStreamUnsub: (() => void) | null = null let didFinishLoadHandler: (() => void) | null = null let didFinishLoadWebContents: WebContents | null = null let rendererLifecycleResetWebContents: WebContents | null = null let rendererLifecycleResetHandler: (() => void) | null = null +// Why: the hidden-delivery gate's interest/hidden registries mirror renderer +// state (ref-counted holds, per-pane hidden marks). A reload or renderer +// crash destroys the owners without unregistering, so the registries are +// reset whenever the renderer process is replaced +// (resetRendererScopedHiddenPtyDeliveryState preserves drop memory). +let rendererGateResetLoadHandler: (() => void) | null = null +let rendererGateResetGoneHandler: (() => void) | null = null +let rendererGateResetWebContents: WebContents | null = null +// Why: the backgrounded-delivery dedupe map lives in the registerPtyHandlers +// closure but teardown funnels through module-scope clearProviderPtyState. +let clearBackgroundedDeliverySyncForPty: (id: string) => void = () => {} +// Why: after daemon keep-tail thinning, main's mirror contains only the kept +// tail. Recovery must keep consulting the daemon's complete model until exit. +const providerSnapshotRequiredPtys = new Set() // Why: did-start-loading also fires for in-page subframe loads (e.g. the // sandboxed srcDoc iframes notebook HTML output renders), which are not renderer // lifecycle resets. A dedicated handler filters those via isLoadingMainFrame so a @@ -1180,6 +1247,18 @@ export type PtyRendererDeliveryDebugSnapshot = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + /** Hidden-gated ptys the renderer ALSO reports visible/active — a + * contradiction that should be zero; nonzero means the user may be staring + * at a pane main is deliberately starving (v1.4.124-rc.2.perf field lead). */ + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + /** One-paste freeze diagnostics: per-pty delivery table + event history. */ + diagnostics: PtyMainDeliveryDiagnostics // Why: a nonzero lastLifecycleResetClearedChars is the exact signature of the // leaked-delivery-accounting freeze this reset fixes; the count tracks how // many renderer lifecycle resets have run since launch. @@ -1192,6 +1271,30 @@ export type PtyRendererDeliveryDebugSnapshot = { rendererDispatcherReadyForcedCount: number } +// Why module scope: breadcrumb writers live both inside registerPtyHandlers +// (gate marks, heals) and outside it (renderer lifecycle resets). +const mainDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing() +let lastPowerSuspendAtMs: number | null = null +let lastPowerResumeAtMs: number | null = null +let powerSignalBreadcrumbsInstalled = false + +// Why: both field freeze variants correlate with display sleep; suspend/resume +// timestamps in the report let us line breadcrumbs up against the wake. +function installPowerSignalBreadcrumbs(): void { + if (powerSignalBreadcrumbsInstalled) { + return + } + powerSignalBreadcrumbsInstalled = true + powerMonitor.on('suspend', () => { + lastPowerSuspendAtMs = Date.now() + mainDeliveryBreadcrumbs.record('power-suspend') + }) + powerMonitor.on('resume', () => { + lastPowerResumeAtMs = Date.now() + mainDeliveryBreadcrumbs.record('power-resume') + }) +} + const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapshot = { pendingPtyCount: 0, pendingChars: 0, @@ -1206,6 +1309,14 @@ const EMPTY_PTY_RENDERER_DELIVERY_DEBUG_SNAPSHOT: PtyRendererDeliveryDebugSnapsh peakRendererInFlightChars: 0, peakMaxRendererInFlightCharsByPty: 0, ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0, + diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, rendererLifecycleResetCount: 0, lastLifecycleResetClearedChars: 0, rendererPtyDispatcherReady: false, @@ -1241,6 +1352,9 @@ function clearDidFinishLoadHandler(): void { } function markRendererPtysHiddenForRendererLifecycleReset(): void { + // A reload/crash in the history is load-bearing context for any freeze + // report ("did the user already reload before capturing?"). + mainDeliveryBreadcrumbs.record('renderer-lifecycle-reset') // Why: renderer-owned hints die with the page; keep known-visibility state so // surviving daemon/SSH PTYs fail closed until the new renderer reports again. activeRendererPtys.clear() @@ -1293,6 +1407,23 @@ function registerRendererLifecycleResetHandlers(webContents: WebContents): void webContents.on('destroyed', rendererLifecycleResetHandler) } +function clearRendererGateResetHandlers(): void { + if (rendererGateResetWebContents) { + if (rendererGateResetLoadHandler) { + rendererGateResetWebContents.removeListener('did-finish-load', rendererGateResetLoadHandler) + } + if (rendererGateResetGoneHandler) { + rendererGateResetWebContents.removeListener( + 'render-process-gone', + rendererGateResetGoneHandler + ) + } + } + rendererGateResetLoadHandler = null + rendererGateResetGoneHandler = null + rendererGateResetWebContents = null +} + // Why: the "Restart daemon" flow needs to detach listeners from the current // adapter *after* synthetic pty:exit events fan out (so the renderer receives // them) but *before* replaceDaemonProvider swaps in the new adapter (so the @@ -1301,8 +1432,10 @@ function registerRendererLifecycleResetHandlers(webContents: WebContents): void export function unbindLocalProviderListeners(): void { localDataUnsub?.() localExitUnsub?.() + localBackgroundStreamUnsub?.() localDataUnsub = null localExitUnsub = null + localBackgroundStreamUnsub = null } // ─── IPC Registration ─────────────────────────────────────────────── @@ -1353,12 +1486,15 @@ export function registerPtyHandlers( ipcMain.removeHandler('pty:settlePaneSerializer') ipcMain.removeHandler('pty:clearPendingPaneSerializer') ipcMain.removeHandler('pty:getMainBufferSnapshot') + ipcMain.removeHandler('pty:sideEffectSnapshot') ipcMain.removeHandler('pty:getRendererDeliveryDebugSnapshot') ipcMain.removeHandler('pty:resetRendererDeliveryDebug') + ipcMain.removeHandler('pty:reportRendererDeliveryState') ipcMain.removeHandler('pty:writeAccepted') ipcMain.removeAllListeners('pty:write') ipcMain.removeAllListeners('pty:ackColdRestore') ipcMain.removeAllListeners('pty:ackData') + ipcMain.removeAllListeners('pty:deliveryResyncResponse') ipcMain.removeAllListeners('pty:serializeBuffer:response') // Configure the local provider with app-specific hooks. @@ -1434,10 +1570,12 @@ export function registerPtyHandlers( data: string startSeq?: number containsBackgroundOutput?: boolean - /** Set once this pty's unsent backlog was trimmed past the cap; rides the - * next payload so the renderer rebuilds the dropped span from the main - * headless snapshot (see appendPendingPtyData / dataCallback). */ - droppedBacklog?: boolean + // Why droppedOutput (not main's droppedBacklog trim): this branch bounds + // the unsent backlog with the O(1) drop-to-sentinel + query-salvage + + // snapshot-restore mechanism below, which strictly supersedes main's + // #7630 keep-2MB-tail trim — carrying both would race two cap policies + // over the same buffer. + droppedOutput?: true } type PtyDataPayload = { @@ -1446,37 +1584,70 @@ export function registerPtyHandlers( seq?: number rawLength?: number background?: boolean - droppedBacklog?: boolean + droppedOutput?: boolean } const pendingData = new Map() - const rendererInFlightCharsByPty = new Map() + // Why: one restore marker per overflow episode — cleared when the entry + // fully drains so a later overflow re-marks the renderer exactly once. + const pendingOverflowMarkedPtys = new Set() + // Why: TCP-style cumulative delivery accounting. Relative in-flight counters + // make every lost ACK a permanent debt; monotonic sent/acked totals self-heal + // as soon as any later ACK (or resync reply) reports the renderer's full + // processed count. + type RendererPtyDeliveryAccounting = { + sentChars: number + ackedChars: number + lastSendAtMs: number + lastAckAtMs: number | null + } + const rendererDeliveryAccountingByPty = new Map() const trustedTerminalHandleEnv = new Set() let flushTimer: ReturnType | null = null let rendererInFlightTotalChars = 0 - const PTY_BATCH_INTERVAL_MS = 8 + let pendingDroppedChars = 0 + let deliveryResyncRequestSerial = 0 + let deliveryResyncOutstandingRequestId: number | null = null + let deliveryResyncTimer: ReturnType | null = null + let deliveryResyncUnansweredWarnLogged = false + let lastAckReceivedAtMs: number | null = null + // Why 2ms: pairs with the daemon stream batcher (see + // daemon-stream-data-batcher.ts) — both hops charged an expected + // half-window per chunk; 2ms keeps flood coalescing at negligible IPC + // overhead while cutting the pipeline's fixed latency tax. + const PTY_BATCH_INTERVAL_MS = 2 const PTY_BATCH_DRAIN_CONTINUE_MS = 1 const PTY_BATCH_FLUSH_CHUNK_CHARS = 16 * 1024 const PTY_BATCH_FLUSH_MAX_WRITES = 2 const PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS = 512 * 1024 const PTY_RENDERER_TOTAL_IN_FLIGHT_HIGH_WATER_CHARS = 8 * 1024 * 1024 + // Why: while the renderer cannot receive (frozen, starved, mid-reload), a + // chatty PTY used to grow its pendingData string without bound — main-process + // heap ballooning that a renderer reload cannot clear (main's #7630 fixed the + // same Win/Linux background-throttled-renderer leak with a 2MB tail trim; + // this branch's sentinel mechanism supersedes it). Beyond this cap the + // buffered bytes are dropped and the pane heals from the main-owned buffer + // snapshot via the droppedOutput sentinel (renderer hidden-output restore). + // Why read settings live: the cap scales with the user's scrollback setting + // so power users don't lose lines their scrollback would have retained. + const pendingDataCapChars = (): number => + terminalOutputBacklogCapChars(getSettings?.().terminalScrollbackRows) // Why: self-heal bound for the dispatcher-ready gate — if a reloaded page never // sends pty:rendererDispatcherReady (lost IPC), force sends back on after this // window so a dropped handshake can't itself become a permanent hold. const PTY_DISPATCHER_READY_WATCHDOG_MS = 10_000 - // Why: the in-flight caps above bound only SENT-but-unacked bytes; the unsent - // `pendingData` backlog is drained only when the renderer ACKs. A background- - // throttled/frozen renderer (Win/Linux — macOS disables throttling) stops - // ACKing while a busy pane keeps producing, so without this the per-pty queue - // grows at raw PTY throughput (MB->GB). Cap it (matching the daemon 2 MB - // pendingOutput and renderer 2 MB scheduler caps); the trimmed span is - // recovered from the main headless buffer via the renderer's hidden-output - // snapshot restore, flagged by droppedBacklog — so no output is lost. - const PENDING_DATA_MAX_CHARS = 2 * 1024 * 1024 const PTY_RENDERER_INTERACTIVE_RESERVE_CHARS = 256 * 1024 // Why: active panes need a bounded lane through old hidden bulk output so a // keystroke redraw can reach the renderer before every background ACK lands. const PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS = 512 * 1024 + // Why: request/response hygiene only — this timeout never mutates delivery + // state. It clears the outstanding-probe flag so a later gated arrival can + // probe again, and logs once per silent streak for field diagnosis. + const PTY_DELIVERY_RESYNC_TIMEOUT_MS = 5_000 + // Why: a heal write-off destroys delivery accounting; require main to have + // seen zero ACKs for this long too, independent of the renderer's own + // two-silent-ticks evidence, before believing the channel is dead. + const PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS = 10_000 // Why: keep the immediate path bounded to keystroke-sized TUI redraws; // large output and non-interactive output must still use the batcher. const INTERACTIVE_OUTPUT_WINDOW_MS = 100 @@ -1502,12 +1673,75 @@ export function registerPtyHandlers( let rendererPtyDispatcherReady = false let dispatcherReadyWatchdogTimer: ReturnType | null = null - function getMaxMapValue(values: Iterable): number { - let max = 0 - for (const value of values) { - max = Math.max(max, value) + // Why: watermark-driven producer pause/resume (terminal-performance + // initiative §5). Signal source is per-PTY pendingData only — renderer + // in-flight is already bounded by the ACK window above, while pendingData + // is what grows without bound when the renderer cannot keep up. Providers + // without support (SSH, legacy daemon protocol) surface no pauseProducer + // and the call chain no-ops; the pending cap still bounds memory then. + const producerFlowControl = new PtyProducerFlowController({ + pauseProducer: (id) => tryGetProviderForPty(id)?.pauseProducer?.(id), + resumeProducer: (id) => tryGetProviderForPty(id)?.resumeProducer?.(id) + }) + + function updateProducerFlowControl(id: string): void { + if (!PRODUCER_FLOW_CONTROL_ENABLED) { + return } - return max + producerFlowControl.update(id, pendingData.get(id)?.data.length ?? 0) + } + + // Why this exists: hidden ptys are exempt from pendingData flow control + // (their bytes are dropped after model ingestion, so pendingData never + // grows), which let background agents run 100MB+ ahead of main in the + // daemon's stream-socket buffer and bury the visible pane's echo. The + // provider transport keep-tail thins backgrounded ptys' monitoring stream + // under backlog; this sync tells it which ptys qualify. + // Why keyed on the visibility registry (NOT gate marks or gate-effective + // shouldDrop): delivery claims and raw-byte sidecars describe transport + // ownership, while thinning asks the semantic question "does any visible + // view show this PTY?" Remote view subscribers (mobile/web live terminals) + // consume raw bytes from main's fan-out, so their presence vetoes thinning + // outright. Dedupe keeps visibility-sync churn off the wire; `?? false` + // also swallows the initial + // not-background state. + const backgroundedDeliverySyncByPty = new Map() + function syncPtyBackgroundedDelivery(id: string, caller: string): void { + const background = + rendererPtyIsKnownHidden(id) && !(runtime?.hasRemoteTerminalViewSubscriber(id) ?? false) + if ((backgroundedDeliverySyncByPty.get(id) ?? false) === background) { + return + } + const provider = tryGetProviderForPty(id) + if (!provider?.setPtyBackgrounded) { + return + } + recordDaemonStreamBacklogEvent('mainBackgroundSync', { + sessionIdSuffix: id.slice(-10), + background, + caller, + known: rendererVisibilityKnownPtys.has(id), + visible: visibleRendererPtys.has(id) + }) + backgroundedDeliverySyncByPty.set(id, background) + provider.setPtyBackgrounded(id, background) + } + clearBackgroundedDeliverySyncForPty = (id: string) => { + backgroundedDeliverySyncByPty.delete(id) + } + if (runtime) { + runtime.onRemoteTerminalViewPresenceChanged = (id) => + syncPtyBackgroundedDelivery(id, 'remote-view') + } + function resyncBackgroundedDeliveriesAfterGateReset(): void { + for (const id of backgroundedDeliverySyncByPty.keys()) { + syncPtyBackgroundedDelivery(id, 'gate-reset') + } + } + + function getRendererInFlightCharsForPty(id: string): number { + const accounting = rendererDeliveryAccountingByPty.get(id) + return accounting ? accounting.sentChars - accounting.ackedChars : 0 } function readCurrentPtyRendererDeliveryDebugSnapshot(): PtyRendererDeliveryDebugSnapshot { @@ -1518,13 +1752,38 @@ export function registerPtyHandlers( pendingChars += chars maxPendingCharsByPty = Math.max(maxPendingCharsByPty, chars) } + const hiddenDeliveryDebug = getHiddenRendererPtyDeliveryDebug() + let rendererInFlightPtyCount = 0 + let maxRendererInFlightCharsByPty = 0 + for (const accounting of rendererDeliveryAccountingByPty.values()) { + const inFlight = accounting.sentChars - accounting.ackedChars + if (inFlight > 0) { + rendererInFlightPtyCount++ + } + maxRendererInFlightCharsByPty = Math.max(maxRendererInFlightCharsByPty, inFlight) + } + // Why: the two renderer visibility signals must agree; a pty both + // hidden-gated and reported visible means main is starving a pane the + // user can see (v1.4.124-rc.2.perf blank-terminal field lead). + let hiddenDeliveryGatedVisiblePtyCount = 0 + for (const id of visibleRendererPtys) { + if (isHiddenRendererPty(id)) { + hiddenDeliveryGatedVisiblePtyCount++ + } + } + let hiddenDeliveryGatedActivePtyCount = 0 + for (const id of activeRendererPtys) { + if (isHiddenRendererPty(id)) { + hiddenDeliveryGatedActivePtyCount++ + } + } return { pendingPtyCount: pendingData.size, pendingChars, maxPendingCharsByPty, - rendererInFlightPtyCount: rendererInFlightCharsByPty.size, + rendererInFlightPtyCount, rendererInFlightChars: rendererInFlightTotalChars, - maxRendererInFlightCharsByPty: getMaxMapValue(rendererInFlightCharsByPty.values()), + maxRendererInFlightCharsByPty, activeRendererPtyCount: activeRendererPtys.size, flushScheduled: flushTimer !== null, peakPendingChars, @@ -1532,6 +1791,11 @@ export function registerPtyHandlers( peakRendererInFlightChars, peakMaxRendererInFlightCharsByPty, ackGatedFlushSkipCount, + ...hiddenDeliveryDebug, + hiddenDeliveryGatedVisiblePtyCount, + hiddenDeliveryGatedActivePtyCount, + pendingDroppedChars, + diagnostics: buildMainDeliveryDiagnostics(), rendererLifecycleResetCount, lastLifecycleResetClearedChars, rendererPtyDispatcherReady, @@ -1539,6 +1803,81 @@ export function registerPtyHandlers( } } + const DELIVERY_DIAGNOSTICS_MAX_PTYS = 30 + + // Built only when the debug snapshot is actually read — never on the data + // path. Aggregate counters can't say WHICH pty is wedged or WHEN the state + // arose; this per-pty table + both-process breadcrumb history can. + function buildMainDeliveryDiagnostics(): PtyMainDeliveryDiagnostics { + const now = Date.now() + // Hidden/visible/active set members are included even with no accounting + // entry: a pty gated before its first byte is exactly the wedge case the + // table must surface. + const ids = new Set([ + ...rendererDeliveryAccountingByPty.keys(), + ...pendingData.keys(), + ...getHiddenRendererPtyIds(), + ...visibleRendererPtys, + ...activeRendererPtys + ]) + const perPty: PtyPerPtyDeliveryDiagnostics[] = [] + for (const id of ids) { + const accounting = rendererDeliveryAccountingByPty.get(id) + perPty.push({ + id: redactPtyIdForDiagnostics(id), + sentChars: accounting?.sentChars ?? 0, + ackedChars: accounting?.ackedChars ?? 0, + inFlightChars: accounting ? accounting.sentChars - accounting.ackedChars : 0, + pendingChars: pendingData.get(id)?.data.length ?? 0, + hidden: isHiddenRendererPty(id), + visible: visibleRendererPtys.has(id), + active: activeRendererPtys.has(id), + msSinceLastSend: accounting ? now - accounting.lastSendAtMs : null, + msSinceLastAck: accounting?.lastAckAtMs == null ? null : now - accounting.lastAckAtMs + }) + } + perPty.sort((a, b) => b.inFlightChars + b.pendingChars - (a.inFlightChars + a.pendingChars)) + const windowAlive = !mainWindow.isDestroyed() + return { + appVersion: app.getVersion(), + mainUptimeMs: Math.round(process.uptime() * 1000), + windowFocused: windowAlive ? mainWindow.isFocused() : null, + windowVisible: windowAlive ? mainWindow.isVisible() : null, + windowMinimized: windowAlive ? mainWindow.isMinimized() : null, + msSinceLastPowerSuspend: lastPowerSuspendAtMs === null ? null : now - lastPowerSuspendAtMs, + msSinceLastPowerResume: lastPowerResumeAtMs === null ? null : now - lastPowerResumeAtMs, + perPty: perPty.slice(0, DELIVERY_DIAGNOSTICS_MAX_PTYS), + breadcrumbs: mainDeliveryBreadcrumbs.snapshot() + } + } + + // Why rate-limited: the contradiction persists chunk after chunk while + // latched; one line per minute keeps field logs readable but present. + let lastHiddenDropContradictionWarnAtMs = 0 + function warnIfDroppingHiddenBytesForVisiblePty(id: string, droppedChars: number): void { + if (!visibleRendererPtys.has(id) && !activeRendererPtys.has(id)) { + return + } + // Recorded before the warn rate limit: the ring coalesces repeats itself, + // and the contradiction must appear in the freeze report either way. + mainDeliveryBreadcrumbs.record('hidden-drop-visible', { + id: redactPtyIdForDiagnostics(id), + droppedChars + }) + const now = Date.now() + if (now - lastHiddenDropContradictionWarnAtMs < 60_000) { + return + } + lastHiddenDropContradictionWarnAtMs = now + console.warn('[pty] hidden-delivery gate is dropping bytes for a visible/active pty', { + id, + droppedChars, + visible: visibleRendererPtys.has(id), + active: activeRendererPtys.has(id), + ...readCurrentPtyRendererDeliveryDebugSnapshot() + }) + } + function recordPtyRendererDeliveryPressure(): void { // Why: this fires on every PTY delivery event (per send, per flush, per // onData append). Update the four diagnostic peaks directly instead of @@ -1555,9 +1894,18 @@ export function registerPtyHandlers( peakPendingChars = Math.max(peakPendingChars, pendingChars) peakMaxPendingCharsByPty = Math.max(peakMaxPendingCharsByPty, maxPendingCharsByPty) peakRendererInFlightChars = Math.max(peakRendererInFlightChars, rendererInFlightTotalChars) + // Why derived per entry: this branch tracks cumulative sent/acked totals + // (TCP-style), not a per-pty in-flight map — in-flight is the difference. + let maxRendererInFlightCharsByPty = 0 + for (const accounting of rendererDeliveryAccountingByPty.values()) { + maxRendererInFlightCharsByPty = Math.max( + maxRendererInFlightCharsByPty, + accounting.sentChars - accounting.ackedChars + ) + } peakMaxRendererInFlightCharsByPty = Math.max( peakMaxRendererInFlightCharsByPty, - getMaxMapValue(rendererInFlightCharsByPty.values()) + maxRendererInFlightCharsByPty ) } @@ -1568,6 +1916,8 @@ export function registerPtyHandlers( peakRendererInFlightChars = 0 peakMaxRendererInFlightCharsByPty = 0 ackGatedFlushSkipCount = 0 + pendingDroppedChars = 0 + resetHiddenRendererPtyDeliveryDebugCounters() recordPtyRendererDeliveryPressure() } resetRendererDeliveryAccountingForLifecycleReset = () => { @@ -1577,9 +1927,15 @@ export function registerPtyHandlers( // which are fed before the pendingData append so they always superset it. lastLifecycleResetClearedChars = rendererInFlightTotalChars rendererLifecycleResetCount += 1 - rendererInFlightCharsByPty.clear() + // Why: pending bytes and outstanding credits belonged to the dead page. + // Release producer pauses before clearing them so no shell stays wedged. + producerFlowControl.releaseAll() + clearDeliveryResyncProbe() + deliveryResyncUnansweredWarnLogged = false + rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 pendingData.clear() + pendingOverflowMarkedPtys.clear() // Why: the reloading page's pty:data listener is gone until it re-registers // and re-sends the handshake; hold sends until then so the boot window can't // re-pin the gate with bytes dropped into a listener-less page. @@ -1630,8 +1986,7 @@ export function registerPtyHandlers( id: string, data: string, startSeq: number | undefined, - containsBackgroundOutput: boolean | undefined, - droppedBacklog?: boolean + containsBackgroundOutput: boolean | undefined ): PtyDataPayload { const payload: PtyDataPayload = { id, data } if (typeof startSeq === 'number') { @@ -1641,9 +1996,6 @@ export function registerPtyHandlers( if (containsBackgroundOutput === true) { payload.background = true } - if (droppedBacklog === true) { - payload.droppedBacklog = true - } return payload } @@ -1660,15 +2012,147 @@ export function registerPtyHandlers( const ptyLimit = PTY_RENDERER_IN_FLIGHT_HIGH_WATER_CHARS + (options.interactive === true ? PTY_RENDERER_ACTIVE_PTY_IN_FLIGHT_RESERVE_CHARS : 0) - return ( - (rendererInFlightCharsByPty.get(id) ?? 0) < ptyLimit && - rendererInFlightTotalChars < totalLimit + return getRendererInFlightCharsForPty(id) < ptyLimit && rendererInFlightTotalChars < totalLimit + } + + // Why: max-merge on cumulative totals is idempotent and reorder-tolerant — + // a replayed or out-of-order ACK can never double-credit, and a lost ACK + // self-heals when any later ACK reports the full processed count. Returns + // the newly acknowledged delta so provider (SSH/daemon) backpressure is only + // credited for bytes main actually tracked in flight, never negative. + function applyCumulativeAck(id: string, processedChars: number): number { + const accounting = rendererDeliveryAccountingByPty.get(id) + if (!accounting) { + return 0 + } + // Clamped to sentChars so a corrupt payload cannot drive in-flight negative. + const nextAckedChars = Math.min( + accounting.sentChars, + Math.max(accounting.ackedChars, processedChars) ) + const acknowledged = nextAckedChars - accounting.ackedChars + accounting.ackedChars = nextAckedChars + if (acknowledged > 0) { + accounting.lastAckAtMs = Date.now() + } + rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged) + return acknowledged + } + + function clearDeliveryResyncProbe(): void { + deliveryResyncOutstandingRequestId = null + if (deliveryResyncTimer) { + clearTimeout(deliveryResyncTimer) + deliveryResyncTimer = null + } + } + + // Why: event-triggered verified-state recovery. Data arriving for a fully + // gated PTY is the deterministic signal that delivery may be stuck on lost + // ACKs (e.g. dropped across a system suspend); ask the renderer for its + // authoritative processed totals instead of resetting on a wall-clock guess. + function requestDeliveryResyncForGatedPty(): void { + if (deliveryResyncOutstandingRequestId !== null || mainWindow.isDestroyed()) { + return + } + deliveryResyncRequestSerial += 1 + const requestId = deliveryResyncRequestSerial + deliveryResyncOutstandingRequestId = requestId + deliveryResyncTimer = setTimeout(() => { + if (deliveryResyncOutstandingRequestId !== requestId) { + return + } + clearDeliveryResyncProbe() + // Why: no state mutation on timeout — a renderer that cannot answer has + // dead IPC, and only a reload cures that. Log once per silent streak so + // field diagnosis is captured without spamming every probe cycle. + if (deliveryResyncUnansweredWarnLogged) { + return + } + deliveryResyncUnansweredWarnLogged = true + console.warn('[pty] delivery resync probe unanswered — renderer IPC unresponsive', { + msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, + ...readCurrentPtyRendererDeliveryDebugSnapshot() + }) + }, PTY_DELIVERY_RESYNC_TIMEOUT_MS) + deliveryResyncTimer.unref?.() + mainWindow.webContents.send('pty:requestDeliveryResync', { requestId }) + } + + // Why: bytes sent but never counted received by the renderer after a + // confirmed wedge are gone — no ACK message can ever repay them (unlike a + // lost ACK, which any later cumulative total heals). Write the debt off and + // hand back restore markers so panes repaint from the main-owned snapshot; + // the caller routes them locally because push markers cannot arrive. + function writeOffLostRendererDelivery( + report: PtyRendererDeliveryStateReport + ): PtyDeliveryWriteOff[] { + const writtenOff: PtyDeliveryWriteOff[] = [] + for (const [id, accounting] of rendererDeliveryAccountingByPty) { + if (accounting.sentChars - accounting.ackedChars <= 0) { + continue + } + const received = report.receivedCharsByPty?.[id] + const receivedChars = + typeof received === 'number' && Number.isFinite(received) ? Math.max(0, received) : 0 + // Why skip a parse-pending window: received-but-unparsed bytes sit alive + // in the renderer write queue; their deferred ACK still repays this debt. + if (receivedChars > accounting.ackedChars) { + continue + } + const acknowledged = applyCumulativeAck(id, accounting.sentChars) + if (acknowledged <= 0) { + continue + } + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + // Why drop pending: everything at or before markerSeq comes from the + // snapshot (hidden-drop parity); flushing pre-marker bytes afterward + // would double-paint what the restore already covers. + const pending = pendingData.get(id) + if (pending) { + pendingDroppedChars += pending.data.length + pendingData.delete(id) + pendingOverflowMarkedPtys.delete(id) + updateProducerFlowControl(id) + } + const markerSeq = runtime?.getPtyOutputSequence(id) + writtenOff.push({ + id, + ...(typeof markerSeq === 'number' ? { markerSeq } : {}), + writtenOffChars: acknowledged + }) + } + if (writtenOff.length > 0) { + clearDeliveryResyncProbe() + deliveryResyncUnansweredWarnLogged = false + mainDeliveryBreadcrumbs.record('delivery-heal-writeoff', { + writtenOffPtyCount: writtenOff.length, + writtenOffChars: writtenOff.reduce((sum, { writtenOffChars }) => sum + writtenOffChars, 0) + }) + console.warn('[pty] delivery heal: wrote off renderer-bound bytes lost in push channel', { + rendererPtyDataListenerCount: report.rendererPtyDataListenerCount ?? null, + msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, + writtenOffByPty: writtenOff.map(({ id, writtenOffChars }) => ({ id, writtenOffChars })), + ...readCurrentPtyRendererDeliveryDebugSnapshot() + }) + } + return writtenOff } function sendPtyDataToRenderer(id: string, payload: PtyDataPayload): void { const charCount = getPtyPayloadCharCount(payload) - rendererInFlightCharsByPty.set(id, (rendererInFlightCharsByPty.get(id) ?? 0) + charCount) + const accounting = rendererDeliveryAccountingByPty.get(id) + if (accounting) { + accounting.sentChars += charCount + accounting.lastSendAtMs = Date.now() + } else { + rendererDeliveryAccountingByPty.set(id, { + sentChars: charCount, + ackedChars: 0, + lastSendAtMs: Date.now(), + lastAckAtMs: null + }) + } rendererInFlightTotalChars += charCount recordPtyRendererDeliveryPressure() mainWindow.webContents.send('pty:data', payload) @@ -1701,6 +2185,26 @@ export function registerPtyHandlers( deliveredHiddenRendererResizeOutputPtys.delete(id) } + // Why: when main drops renderer delivery (hidden gate / pending cap), an + // explicit out-of-band pty:modelRestoreNeeded signal tells the renderer to + // latch model-restore-needed. It must NOT ride pty:data: an in-band empty + // chunk is indistinguishable from a chunk fully consumed by renderer-side + // OSC-9999 stripping, which spuriously restored visible panes. + function sendModelRestoreNeededMarker( + id: string, + reason: PtyModelRestoreReason, + markerSeq: number | undefined + ): void { + if (mainWindow.isDestroyed()) { + return + } + mainWindow.webContents.send('pty:modelRestoreNeeded', { + id, + reason, + ...(typeof markerSeq === 'number' ? { markerSeq } : {}) + }) + } + function getPendingPtyFlushEntries(): [string, PendingPtyData][] { const entries = Array.from(pendingData.entries()) const active: [string, PendingPtyData][] = [] @@ -1715,60 +2219,106 @@ export function registerPtyHandlers( return [...active, ...background] } - // Why: bound the unsent backlog to the most-recent PENDING_DATA_MAX_CHARS, - // advancing startSeq by the dropped-char count (same arithmetic flushPendingData - // uses when it slices) so the remaining tail's seq stays correct. Flags - // droppedBacklog so the next payload tells the renderer to rebuild the dropped - // span from the main headless snapshot. A no-op when under the cap (the common - // case: the renderer is ACKing and pendingData stays tiny). - function capPendingPtyData(pending: PendingPtyData): PendingPtyData { - if (pending.data.length <= PENDING_DATA_MAX_CHARS) { + const pendingDataDropWarnedPtys = new Set() + + // Why capped: the drop path guarantees O(1) memory per PTY; salvaged query + // bytes are tiny (a DSR probe is 4 chars) and anything past the cap means a + // pathological stream, where degrading to the plain sentinel is fine. + const DROPPED_QUERY_SALVAGE_MAX_CHARS = 4096 + + // Why: a bulk drop must not swallow reply-eliciting queries embedded in the + // flood (DSR 6n / CPR, DA1/DA2, DECRQM, OSC 10/11 probes). The program that + // wrote them blocks on the reply (the bench DSR timeout). Carve just the + // query bytes out and let them ride the droppedOutput sentinel — content is + // healed by the snapshot restore, so replies cannot double-fire. + function extractDroppedPtyQueryBytes(data: string): string { + if (!data.includes('\x1b')) { + return '' + } + const extracted = extractHiddenStartupRendererQueryData(data, '') + return extracted.statelessQueryData + extracted.statefulQueryData + extracted.oscColorQueryData + } + + function dropOversizedPendingPtyData(id: string, pending: PendingPtyData): PendingPtyData { + const capChars = pendingDataCapChars() + if (pending.droppedOutput === true || pending.data.length <= capChars) { return pending } - const trimmed = pending.data.slice(pending.data.length - PENDING_DATA_MAX_CHARS) - const droppedChars = pending.data.length - trimmed.length - const next: PendingPtyData = { data: trimmed, droppedBacklog: true } - if (typeof pending.startSeq === 'number') { - next.startSeq = pending.startSeq + droppedChars + if (!pendingDataDropWarnedPtys.has(id)) { + pendingDataDropWarnedPtys.add(id) + console.error( + `[pty] dropped ${pending.data.length} buffered chars for ${id}: renderer not receiving and per-PTY pending cap exceeded; pane will restore from the main-owned snapshot` + ) + // Why: field visibility for cap tuning — drop frequency and size decide + // whether the cap is too small (issue #2836 / #7017). No pty id: session + // ids can embed workspace paths. + recordCrashBreadcrumb('terminal_pending_output_dropped', { + droppedChars: pending.data.length, + capChars + }) } - if (pending.containsBackgroundOutput === true) { - next.containsBackgroundOutput = true + // Why: with the hidden-delivery gate rolled out, the model snapshot can + // recover the dropped middle — emit the out-of-band restore marker once + // per overflow episode alongside the droppedOutput sentinel so a fresh + // or reloaded view latches restore too. + if (isHiddenPtyDeliveryGateEnabled(getSettings?.()) && !pendingOverflowMarkedPtys.has(id)) { + pendingOverflowMarkedPtys.add(id) + sendModelRestoreNeededMarker(id, 'pending-cap', runtime?.getPtyOutputSequence(id)) + } + pendingDroppedChars += pending.data.length + // Why no trimmed content tail: a mid-stream gap would silently corrupt + // the pane. The droppedOutput sentinel routes the pane through + // hidden-output restore, which repaints from the authoritative main-owned + // buffer and realigns with the live stream by sequence. Only carved-out + // query bytes ride along so their replies survive the drop. + return { + data: extractDroppedPtyQueryBytes(pending.data).slice(0, DROPPED_QUERY_SALVAGE_MAX_CHARS), + droppedOutput: true } - return next } function appendPendingPtyData( + id: string, existing: PendingPtyData | undefined, data: string, startSeq: number | undefined, preservesSeq: boolean, containsBackgroundOutput: boolean ): PendingPtyData { + // Why: once over the cap, stay dropped at O(1) memory until the renderer + // can receive again — the restore sentinel supersedes any interim bytes. + // Queries arriving while latched still get carved out (bounded) so their + // replies survive the whole drop episode, not just the first burst. + if (existing?.droppedOutput === true) { + if (existing.data.length >= DROPPED_QUERY_SALVAGE_MAX_CHARS) { + return existing + } + const salvaged = extractDroppedPtyQueryBytes(data) + return salvaged ? { ...existing, data: existing.data + salvaged } : existing + } const nextContainsBackgroundOutput = existing?.containsBackgroundOutput === true || containsBackgroundOutput - // Carry a prior drop flag forward until it actually rides an outgoing payload. - const inheritedDropped = existing?.droppedBacklog === true - const base: PendingPtyData = { data: '' } if (!preservesSeq) { - base.data = (existing?.data ?? '') + data - } else if (!existing) { - base.data = data - if (typeof startSeq === 'number') { - base.startSeq = startSeq - } - } else { - base.data = existing.data + data - if (typeof existing.startSeq === 'number') { - base.startSeq = existing.startSeq - } + return dropOversizedPendingPtyData(id, { + data: (existing?.data ?? '') + data, + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + }) } - if (nextContainsBackgroundOutput) { - base.containsBackgroundOutput = true + if (!existing) { + return dropOversizedPendingPtyData(id, { + data, + ...(typeof startSeq === 'number' ? { startSeq } : {}), + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) + }) } - if (inheritedDropped) { - base.droppedBacklog = true + const next: PendingPtyData = { + data: existing.data + data, + ...(nextContainsBackgroundOutput ? { containsBackgroundOutput: true } : {}) } - return capPendingPtyData(base) + if (typeof existing.startSeq === 'number') { + next.startSeq = existing.startSeq + } + return dropOversizedPendingPtyData(id, next) } function schedulePendingDataFlush(delayMs: number): void { @@ -1809,8 +2359,13 @@ export function registerPtyHandlers( function flushPendingData(): void { flushTimer = null if (mainWindow.isDestroyed()) { + // Why: the bookkeeping is being wiped, so no future drain can ever + // resume these producers — release them now or local shells wedge. + producerFlowControl.releaseAll() + clearDeliveryResyncProbe() pendingData.clear() - rendererInFlightCharsByPty.clear() + pendingOverflowMarkedPtys.clear() + rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 clearDispatcherReadyWatchdog() recordPtyRendererDeliveryPressure() @@ -1822,15 +2377,39 @@ export function registerPtyHandlers( if (!rendererPtyDispatcherReady) { return } + const settings = getSettings?.() let writes = 0 for (const [id, pending] of getPendingPtyFlushEntries()) { if (writes >= PTY_BATCH_FLUSH_MAX_WRITES) { break } + // Why: hidden-gated bytes are dropped, never re-queued — the model + // already ingested them; reveal restores from the snapshot+seq machinery. + if (shouldDropHiddenRendererPtyData(id, settings)) { + pendingData.delete(id) + pendingOverflowMarkedPtys.delete(id) + updateProducerFlowControl(id) + const drop = recordHiddenRendererPtyDataDrop(id, pending.data.length) + warnIfDroppingHiddenBytesForVisiblePty(id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker(id, 'hidden-drop', runtime?.getPtyOutputSequence(id)) + } + continue + } if (!canSendPtyDataToRenderer(id, { interactive: activeRendererPtys.has(id) })) { continue } pendingData.delete(id) + if (pending.droppedOutput === true) { + updateProducerFlowControl(id) + // Why: the buffered bytes were dropped at the pending cap; tell the + // renderer so the pane repaints from the main-owned buffer snapshot + // instead of continuing a stream with a silent gap. data carries only + // the carved-out query bytes (see extractDroppedPtyQueryBytes). + sendPtyDataToRenderer(id, { id, data: pending.data, droppedOutput: true }) + writes++ + continue + } const { data } = pending const chunk = data.slice(0, PTY_BATCH_FLUSH_CHUNK_CHARS) const remaining = data.slice(PTY_BATCH_FLUSH_CHUNK_CHARS) @@ -1843,19 +2422,13 @@ export function registerPtyHandlers( nextPending.containsBackgroundOutput = true } pendingData.set(id, nextPending) + } else { + pendingOverflowMarkedPtys.delete(id) } - // Why: the drop flag rides the first emitted chunk (renderer restore is - // idempotent) and is intentionally not copied onto `remaining` above, so a - // single backlog trim signals the renderer exactly once. + updateProducerFlowControl(id) sendPtyDataToRenderer( id, - makePtyDataPayload( - id, - chunk, - pending.startSeq, - pending.containsBackgroundOutput, - pending.droppedBacklog - ) + makePtyDataPayload(id, chunk, pending.startSeq, pending.containsBackgroundOutput) ) writes++ } @@ -1913,25 +2486,40 @@ export function registerPtyHandlers( // tears down the terminal on pty:exit before the batch timer fires. const remaining = pendingData.get(payload.id) if (remaining) { - sendPtyDataToRenderer( - payload.id, - makePtyDataPayload( + if (remaining.droppedOutput === true) { + // Sentinel entry: only salvaged query bytes remain; keep the flag so + // the renderer knows the span was dropped (same as the flush loop). + sendPtyDataToRenderer(payload.id, { + id: payload.id, + data: remaining.data, + droppedOutput: true + }) + } else { + sendPtyDataToRenderer( payload.id, - remaining.data, - remaining.startSeq, - remaining.containsBackgroundOutput, - remaining.droppedBacklog + makePtyDataPayload( + payload.id, + remaining.data, + remaining.startSeq, + remaining.containsBackgroundOutput + ) ) - ) + } pendingData.delete(payload.id) } + // Why: exit drops this PTY's bookkeeping; resume (no-op on a dead PTY) + // rather than leave a stale paused mark behind for a reused id. + producerFlowControl.release(payload.id) + pendingOverflowMarkedPtys.delete(payload.id) lastInputAtByPty.delete(payload.id) interactiveOutputCharsByPty.delete(payload.id) rendererInFlightTotalChars = Math.max( 0, - rendererInFlightTotalChars - (rendererInFlightCharsByPty.get(payload.id) ?? 0) + rendererInFlightTotalChars - getRendererInFlightCharsForPty(payload.id) ) - rendererInFlightCharsByPty.delete(payload.id) + // Why: the renderer also drops its cumulative total on pty:exit, so a + // reused id restarts aligned at zero on both sides. + rendererDeliveryAccountingByPty.delete(payload.id) recordPtyRendererDeliveryPressure() mainWindow.webContents.send('pty:exit', payload) } @@ -1962,6 +2550,35 @@ export function registerPtyHandlers( const bindProviderListeners = (): void => { localDataUnsub?.() localExitUnsub?.() + localBackgroundStreamUnsub?.() + + // Keep-tail thinning facts from the daemon, in byte order with onData. + // The marker flips scan authority for the four transient-fact scanners; + // a gap resets main's cross-chunk parse state and forces the renderer to + // restore from the model snapshot (same seq-guard path as hidden drops) + // in case any view — eager buffer included — was receiving bytes. + localBackgroundStreamUnsub = + localProvider.onBackgroundStreamEvent?.((payload) => { + if (payload.kind === 'backgroundMarker') { + runtime?.setPtyTransientFactDelegation( + payload.id, + payload.background, + payload.scanSeedAnsi + ) + return + } + if (payload.kind === 'dataGap') { + providerSnapshotRequiredPtys.add(payload.id) + runtime?.notePtyDataGap(payload.id, payload.sequenceChars ?? payload.droppedChars) + sendModelRestoreNeededMarker( + payload.id, + 'hidden-drop', + runtime?.getPtyOutputSequence(payload.id) + ) + return + } + runtime?.emitDaemonPtyTransientFact(payload.id, payload.fact) + }) ?? null // Why: LocalPtyProvider routes data to the runtime via configure().onData, // but daemon-backed providers don't have configure(). Without this, daemon @@ -1974,9 +2591,16 @@ export function registerPtyHandlers( localDataUnsub = localProvider.onData((payload) => { const outputSeq = isLocalProvider ? runtime?.getPtyOutputSequence(payload.id) - : runtime?.onPtyData(payload.id, payload.data, Date.now()) + : runtime?.onPtyData( + payload.id, + payload.data, + Date.now(), + payload.sequenceChars ?? payload.data.length + ) const rendererData = answerStartupTerminalColorQueriesForPty(payload.id, payload.data) - const preservesSeq = rendererData === payload.data + const preservesSeq = + rendererData === payload.data && + (payload.sequenceChars === undefined || payload.sequenceChars === payload.data.length) const startSeq = preservesSeq ? getChunkStartSeq(outputSeq, payload.data) : undefined if (mainWindow.isDestroyed()) { // Why: clear the pending flush timer so it doesn't fire after the window @@ -1986,13 +2610,30 @@ export function registerPtyHandlers( clearTimeout(flushTimer) flushTimer = null } + producerFlowControl.releaseAll() + clearDeliveryResyncProbe() pendingData.clear() - rendererInFlightCharsByPty.clear() + pendingOverflowMarkedPtys.clear() + rendererDeliveryAccountingByPty.clear() rendererInFlightTotalChars = 0 clearDispatcherReadyWatchdog() recordPtyRendererDeliveryPressure() return } + const settings = getSettings?.() + // Why: hidden-delivery gate — runtime ingestion above already consumed + // the chunk; gated renderer delivery is DROPPED (never queued) and the + // reveal path restores from the model snapshot via the seq guard. The + // drop sits before the interactive bypass so gated PTYs take neither + // the immediate nor the batched renderer path. + if (shouldDropHiddenRendererPtyData(payload.id, settings)) { + const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length) + warnIfDroppingHiddenBytesForVisiblePty(payload.id, payload.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker(payload.id, 'hidden-drop', outputSeq) + } + return + } if (rendererData.length === 0) { return } @@ -2003,6 +2644,7 @@ export function registerPtyHandlers( } const existing = pendingData.get(payload.id) const pending = appendPendingPtyData( + payload.id, existing, rendererData, startSeq, @@ -2023,11 +2665,15 @@ export function registerPtyHandlers( // terminal output already handed to the renderer. The reserve is // bounded, and the per-PTY cap still prevents an active TUI runaway. if (!canSendPtyDataToRenderer(payload.id, { interactive: true })) { + requestDeliveryResyncForGatedPty() pendingData.set(payload.id, pending) + updateProducerFlowControl(payload.id) recordPtyRendererDeliveryPressure() return } pendingData.delete(payload.id) + updateProducerFlowControl(payload.id) + pendingOverflowMarkedPtys.delete(payload.id) clearFlushTimerIfIdle() // Why: agent TUIs redraw small prompt regions after every keystroke. // Waiting for the throughput batch timer adds visible input latency. @@ -2038,12 +2684,20 @@ export function registerPtyHandlers( ? { seq: pending.startSeq + nextData.length, rawLength: nextData.length } : {}), ...(pending.containsBackgroundOutput === true ? { background: true } : {}), - ...(pending.droppedBacklog === true ? { droppedBacklog: true } : {}) + ...(pending.droppedOutput === true ? { droppedOutput: true } : {}) }) return } pendingData.set(payload.id, pending) + updateProducerFlowControl(payload.id) recordPtyRendererDeliveryPressure() + // Why: probe on data arrival, not on flush skips — new output for a + // fully gated PTY is the moment stuck delivery becomes observable. + if ( + !canSendPtyDataToRenderer(payload.id, { interactive: activeRendererPtys.has(payload.id) }) + ) { + requestDeliveryResyncForGatedPty() + } if (!flushTimer) { schedulePendingDataFlush(PTY_BATCH_INTERVAL_MS) } @@ -2151,6 +2805,28 @@ export function registerPtyHandlers( }) } + // Why: a reload (did-finish-load) or renderer crash replaces the process + // that owned every delivery-interest hold and hidden mark; surviving + // daemon/SSH PTYs would otherwise stay force-fed (leaked interest defeats + // the gate) or stay gated against a renderer that never marked them. Drop + // memory is preserved — each pane's first sync re-marks/unmarks and the + // unmark path re-emits the restore marker for unrestored drops. + clearRendererGateResetHandlers() + rendererGateResetLoadHandler = () => { + resetRendererScopedHiddenPtyDeliveryState() + // Why: the daemon pacer must not keep throttling ptys whose hidden marks + // died with the renderer; the fresh renderer's first visibility sync + // re-marks the ones that are still hidden. + resyncBackgroundedDeliveriesAfterGateReset() + } + rendererGateResetGoneHandler = () => { + resetRendererScopedHiddenPtyDeliveryState() + resyncBackgroundedDeliveriesAfterGateReset() + } + rendererGateResetWebContents = mainWindow.webContents + mainWindow.webContents.on('did-finish-load', rendererGateResetLoadHandler) + mainWindow.webContents.on('render-process-gone', rendererGateResetGoneHandler) + // Kill orphaned PTY processes from previous page loads when the renderer reloads. // Why: only applies to LocalPtyProvider where PTYs live in the Electron main // process and can become orphaned on page reload. Daemon-backed sessions @@ -2465,6 +3141,18 @@ export function registerPtyHandlers( } } ptyOwnership.set(result.id, args.connectionId ?? null) + // Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY + // determination from the spawn record before any byte reaches the + // runtime emulator, so its DA1 override exists from byte zero. + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: daemonShellOverride + }) + ) { + markNativeWindowsConptyPty(result.id) + } const relayResultId = getRelayPtyId(args.connectionId, result.id) const persistSshLease = (): void => { if (!store || !args.connectionId) { @@ -2534,6 +3222,10 @@ export function registerPtyHandlers( : undefined ) } + // Why: arms main's per-PTY Command Code output detector from the launch + // command (renderer startupCommand parity); banner detection covers + // PTYs spawned without one. + runtime?.noteTerminalSpawnCommand?.(result.id, args.command ?? null) if (isClaudeLaunch) { markClaudePtySpawned(result.id) } @@ -2782,8 +3474,10 @@ export function registerPtyHandlers( cwd?: string | null lastTitle?: string seq?: number + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string } | null> => { if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { @@ -2791,13 +3485,61 @@ export function registerPtyHandlers( } const scrollbackRows = normalizeSnapshotScrollbackRows(args.opts?.scrollbackRows) try { - return await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { scrollbackRows }) + const runtimeSeqBeforeSnapshot = runtime.getPtyOutputSequence(args.id) + const providerSnapshotRequired = providerSnapshotRequiredPtys.has(args.id) + const providerSnapshot = providerSnapshotRequired + ? await tryGetProviderForPty(args.id)?.getBufferSnapshot?.(args.id, { + scrollbackRows + }) + : null + // Why: after a data gap, main's model contains only the retained tail. + // Returning it as a full snapshot would silently erase older scrollback. + if (providerSnapshotRequired && !providerSnapshot) { + return null + } + const snapshot = + providerSnapshot ?? + (await runtime.serializeHiddenOutputRecoveryBuffer(args.id, { + scrollbackRows + })) + if (!snapshot || typeof snapshot.seq !== 'number') { + return snapshot + } + // Why: sampled after serialize — every byte at or below snapshot.seq + // that can still reach the renderer sits in this pending queue. The + // renderer's post-restore dedupe bounds its duplicate window with it; + // without the bound a stale baseline silently swallows genuinely-new + // chunks whose seq domain sits below the snapshot counter. + const pending = pendingData.get(args.id) + if (pending && typeof pending.startSeq !== 'number') { + // Why: a seq-less backlog cannot be bounded — stay conservative. + return snapshot + } + return { + ...snapshot, + pendingDeliveryStartSeq: Math.min( + pending?.startSeq ?? (providerSnapshot ? runtimeSeqBeforeSnapshot : snapshot.seq), + snapshot.seq + ) + } } catch { return null } } ) + // Why: with main holding side-effect authority the renderer no longer + // derives titles from replayed bytes on (re)attach. This title-only replay + // snapshot restores title state — never historical bells/completions (the + // no-attention-replay rule, terminal-side-effect-authority.md). + ipcMain.handle('pty:sideEffectSnapshot', (_event, args: { id: string }) => { + if (!runtime || typeof args?.id !== 'string' || args.id.length === 0) { + return null + } + return runtime.getTerminalSideEffectSnapshot(args.id) + }) + + installPowerSignalBreadcrumbs() ipcMain.handle('pty:getRendererDeliveryDebugSnapshot', (): PtyRendererDeliveryDebugSnapshot => { return getPtyRendererDeliveryDebugSnapshot() }) @@ -2833,6 +3575,11 @@ export function registerPtyHandlers( foreground?: unknown background?: unknown } + // Why: hidden-at-spawn declaration (terminal-query-authority.md + // §races) — the renderer knows at spawn time that no visible view + // will consume this PTY's bytes, so main marks it hidden BEFORE the + // first byte and the gate + model responder own spawn-time queries. + initiallyHidden?: boolean // Why: closes the SIGKILL race documented in INVESTIGATION.md by // letting main patch + sync-flush the (worktreeId, tabId, leafId → // ptyId) binding before pty:spawn returns. Only the renderer's @@ -3222,6 +3969,20 @@ export function registerPtyHandlers( return await existingPaneSpawn.promise } const paneSpawnReservation = reservationPaneKey ? reservePaneSpawn(reservationPaneKey) : null + const initiallyHidden = args.initiallyHidden === true + // Why pre-spawn for daemon-host sessions (id minted up front): daemon + // PTYs can emit prompt bytes before spawn() resolves, and the hidden + // mark must beat the first byte so the gate + model responder own + // spawn-time queries (terminal-query-authority.md §races). Other + // providers cannot emit until spawn resolves; the post-spawn mark + // below is byte-zero-safe for them. + const preSpawnHiddenMarkId = + initiallyHidden && isDaemonHostSpawn && effectiveSessionAppId !== undefined + ? effectiveSessionAppId + : null + if (preSpawnHiddenMarkId !== null) { + markHiddenRendererPty(preSpawnHiddenMarkId) + } let result: PtySpawnResult try { try { @@ -3240,6 +4001,11 @@ export function registerPtyHandlers( result = await provider.spawn(spawnOptions) spawnTiming.mark('provider_spawn') } catch (err) { + // Why: a failed spawn must not leave a stale hidden mark on a session + // id a later visible attach may reuse. + if (preSpawnHiddenMarkId !== null) { + unmarkHiddenRendererPty(preSpawnHiddenMarkId) + } const rawMessage = err instanceof Error ? err.message : String(err) const spawnError = normalizeNodePtySpawnError(err) const isIdentityMismatch = @@ -3312,6 +4078,34 @@ export function registerPtyHandlers( reattach: result.isReattach ?? false }) ptyOwnership.set(result.id, args.connectionId ?? null) + if (initiallyHidden) { + // Why marked synchronously before any await below: local/SSH provider + // data events dispatch on later tasks, so this is still ahead of the + // first byte's delivery decision. Idempotent for daemon hosts already + // marked pre-spawn; the renderer's first visibility sync re-marks or + // unmarks (emitting the restore marker) through the Phase-4 path. + markHiddenRendererPty(result.id) + if (preSpawnHiddenMarkId !== null && preSpawnHiddenMarkId !== result.id) { + // Defense: never strand a mark on an id the provider renamed. + unmarkHiddenRendererPty(preSpawnHiddenMarkId) + } + // Why after ptyOwnership.set: the provider lookup routes by + // ownership, and a hidden-spawned agent should be paceable from its + // first flood, not from its first visibility transition. + syncPtyBackgroundedDelivery(result.id, 'spawn') + } + // Why: Phase-5 ConPTY DA1 — record the native-Windows-local-PTY + // determination from the spawn record before the headless seed below, + // so the runtime emulator's DA1 override exists from byte zero. + if ( + isNativeWindowsLocalPtySpawn({ + connectionId: args.connectionId, + cwd: args.cwd, + shellOverride: effectiveShellOverride + }) + ) { + markNativeWindowsConptyPty(result.id) + } if (startupTerminalColorQueryReplyColors) { if (result.isReattach) { if (preSpawnStartupTerminalColorReplyPtyId) { @@ -3420,7 +4214,18 @@ export function registerPtyHandlers( ? { cols: result.snapshotCols, rows: result.snapshotRows } : undefined if (typeof result.snapshot === 'string' && result.snapshot.length > 0) { - runtime.seedHeadlessTerminal(result.id, result.snapshot, seedSize) + // Why kitty flags ride seed metadata: the snapshot string omits + // them by design (renderer kitty reset stays authoritative), but + // the re-seeded emulator must answer hidden `CSI ? u` with the + // flags the still-running app pushed (terminal-query-authority.md). + runtime.seedHeadlessTerminal( + result.id, + result.snapshot, + seedSize, + typeof result.snapshotKittyKeyboardFlags === 'number' + ? { kittyKeyboardFlags: result.snapshotKittyKeyboardFlags } + : {} + ) } else if ( result.coldRestore && typeof result.coldRestore.scrollback === 'string' && @@ -3453,6 +4258,13 @@ export function registerPtyHandlers( : undefined ) } + // Why: arms main's per-PTY Command Code output detector from the launch + // command (renderer startupCommand parity); banner detection covers + // PTYs spawned without one. + runtime?.noteTerminalSpawnCommand?.( + result.id, + typeof args.command === 'string' ? args.command : null + ) if (isClaudeLaunch) { markClaudePtySpawned(result.id) } @@ -3788,23 +4600,110 @@ export function registerPtyHandlers( // Why: renderer ACKs bound main→renderer terminal delivery without stopping // PTY ingestion. Agent/status consumers still see every chunk through the // provider/runtime path while background renderer writes wait their turn. - ipcMain.on('pty:ackData', (_event, args: { id: string; charCount: number }) => { - const charCount = Number.isFinite(args.charCount) ? Math.max(0, args.charCount) : 0 - const current = rendererInFlightCharsByPty.get(args.id) ?? 0 - const acknowledged = Math.min(current, charCount) - const next = Math.max(0, current - charCount) - rendererInFlightTotalChars = Math.max(0, rendererInFlightTotalChars - acknowledged) - if (next === 0) { - rendererInFlightCharsByPty.delete(args.id) - } else { - rendererInFlightCharsByPty.set(args.id, next) + ipcMain.on( + 'pty:ackData', + (_event, args: { id: string; charCount?: number; processedChars?: number }) => { + lastAckReceivedAtMs = Date.now() + // Why: a live ACK channel means a future unanswered probe is a fresh + // diagnostic event, not a continuation of the last silent streak. + deliveryResyncUnansweredWarnLogged = false + let acknowledged = 0 + if (typeof args.processedChars === 'number' && Number.isFinite(args.processedChars)) { + acknowledged = applyCumulativeAck(args.id, Math.max(0, args.processedChars)) + } else { + // Why: tolerate legacy per-chunk delta payloads — dev hot-reload can + // pair an old renderer with a new main. Keyed by field presence. + const accounting = rendererDeliveryAccountingByPty.get(args.id) + const delta = Number.isFinite(args.charCount) ? Math.max(0, args.charCount ?? 0) : 0 + acknowledged = accounting ? applyCumulativeAck(args.id, accounting.ackedChars + delta) : 0 + } + tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) + recordPtyRendererDeliveryPressure() + if (pendingData.size > 0 && !flushTimer) { + schedulePendingDataFlush(0) + } } - tryGetProviderForPty(args.id)?.acknowledgeDataEvent(args.id, acknowledged) - recordPtyRendererDeliveryPressure() - if (pendingData.size > 0 && !flushTimer) { - schedulePendingDataFlush(0) + ) + + ipcMain.on( + 'pty:deliveryResyncResponse', + (_event, args: { requestId: number; processedCharsByPty: Record }) => { + if ( + deliveryResyncOutstandingRequestId === null || + args?.requestId !== deliveryResyncOutstandingRequestId + ) { + return + } + clearDeliveryResyncProbe() + deliveryResyncUnansweredWarnLogged = false + // Why: max-merge — the renderer's cumulative totals are authoritative + // for what it processed; reconciling them drains exactly the in-flight + // debt left by lost ACKs, nothing more. + for (const [id, processedChars] of Object.entries(args.processedCharsByPty ?? {})) { + if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { + continue + } + const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) + if (acknowledged > 0) { + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } + } + recordPtyRendererDeliveryPressure() + if (pendingData.size > 0 && !flushTimer) { + schedulePendingDataFlush(0) + } } - }) + ) + + // Why invoke + renderer-initiated: the field wedge (v1.4.121-rc.0 snapshot, + // 2026-07-06) kills every main→renderer push channel while invoke stays + // alive, so the solicited-resync probe above can never be answered there. + // This is the same reconcile, ridden over the direction proven to work, plus + // a write-off lane for bytes the renderer provably never received. + ipcMain.handle( + 'pty:reportRendererDeliveryState', + (_event, args: PtyRendererDeliveryStateReport): PtyRendererDeliveryHealthReply => { + // Extra repair lane for the lost-ACK variant: identical max-merge to the + // resync response, so a heal is only reached when merging cannot drain. + for (const [id, processedChars] of Object.entries(args?.processedCharsByPty ?? {})) { + if (typeof processedChars !== 'number' || !Number.isFinite(processedChars)) { + continue + } + const acknowledged = applyCumulativeAck(id, Math.max(0, processedChars)) + if (acknowledged > 0) { + tryGetProviderForPty(id)?.acknowledgeDataEvent(id, acknowledged) + } + } + let writtenOff: PtyDeliveryWriteOff[] = [] + // Why the main-side ACK-silence check: the renderer's two silent ticks + // already argue for a wedge; requiring main to have seen no ACK either + // keeps a buggy/foreign caller from writing off live delivery. + if ( + args?.heal === true && + rendererInFlightTotalChars > 0 && + (lastAckReceivedAtMs === null || + Date.now() - lastAckReceivedAtMs >= PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS) + ) { + writtenOff = writeOffLostRendererDelivery(args) + } + recordPtyRendererDeliveryPressure() + if (pendingData.size > 0 && !flushTimer) { + schedulePendingDataFlush(0) + } + let inFlightPtyCount = 0 + for (const accounting of rendererDeliveryAccountingByPty.values()) { + if (accounting.sentChars - accounting.ackedChars > 0) { + inFlightPtyCount++ + } + } + return { + inFlightTotalChars: rendererInFlightTotalChars, + inFlightPtyCount, + msSinceLastAck: lastAckReceivedAtMs === null ? null : Date.now() - lastAckReceivedAtMs, + ...(writtenOff.length > 0 ? { writtenOff } : {}) + } + } + ) // Why: the renderer sends this once its pty:data listener is live (per page // load / reload). Until it arrives, sends are held so boot-window bytes can't @@ -3864,6 +4763,75 @@ export function registerPtyHandlers( } else { visibleRendererPtys.delete(args.id) } + syncPtyBackgroundedDelivery(args.id, 'visibility-report') + }) + + ipcMain.removeAllListeners('pty:setHiddenRendererPty') + ipcMain.on('pty:setHiddenRendererPty', (_event, args: { id: string; hidden: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + mainDeliveryBreadcrumbs.record(args.hidden === true ? 'gate-mark' : 'gate-unmark', { + id: redactPtyIdForDiagnostics(args.id) + }) + if (args.hidden === true) { + markHiddenRendererPty(args.id) + // Why: bytes already queued for a newly hidden PTY are model-owned + // state; drop them now instead of holding them under ACK starvation. + // Reveal restores from the snapshot. + const pending = pendingData.get(args.id) + if (pending && shouldDropHiddenRendererPtyData(args.id, getSettings?.())) { + pendingData.delete(args.id) + updateProducerFlowControl(args.id) + pendingOverflowMarkedPtys.delete(args.id) + const drop = recordHiddenRendererPtyDataDrop(args.id, pending.data.length) + if (drop.shouldEmitRestoreMarker) { + sendModelRestoreNeededMarker( + args.id, + 'hidden-drop', + runtime?.getPtyOutputSequence(args.id) + ) + } + recordPtyRendererDeliveryPressure() + } + syncPtyBackgroundedDelivery(args.id, 'gate-mark') + return + } + const { droppedWhileHidden } = unmarkHiddenRendererPty(args.id) + syncPtyBackgroundedDelivery(args.id, 'gate-unmark') + // Why: a renderer reload or remount can replace the view that latched + // restore-needed from the first-drop marker. Re-emit on unhide so the + // (possibly fresh) visible view still pulls the model snapshot covering + // the dropped bytes. If the original view is still alive this can trigger + // a redundant second restore — accepted: a snapshot replay is cheap and + // idempotent, while a missed restore leaves a corrupt pane. + if (droppedWhileHidden) { + sendModelRestoreNeededMarker(args.id, 'unhide', runtime?.getPtyOutputSequence(args.id)) + } + }) + + ipcMain.removeAllListeners('pty:terminalViewAttributes') + ipcMain.on('pty:terminalViewAttributes', (_event, args: unknown) => { + // Why validate-or-drop: the responder must never store a malformed + // palette — a wrong color reply breaks TUI theme detection worse than + // the documented silent-until-first-push behavior. + const attributes = validateTerminalViewAttributes(args) + if (attributes) { + setTerminalViewAttributes(attributes) + } + }) + + ipcMain.removeAllListeners('pty:setPtyDeliveryInterest') + ipcMain.on('pty:setPtyDeliveryInterest', (_event, args: { id: string; interested: boolean }) => { + if (typeof args.id !== 'string' || !args.id) { + return + } + // Why: explicit delivery-interest signal from renderer byte sidecars — + // any interest suppresses the hidden-delivery gate so + // raw-byte consumers keep receiving while the view is hidden or parked. + // Deliberately NOT synced to the daemon backlog pacer: interest consumers + // tolerate paced data, and interest churn must not un-pace a flood. + setRendererPtyDeliveryInterest(args.id, args.interested === true) }) ipcMain.removeAllListeners('pty:signal') diff --git a/src/main/ipc/settings.test.ts b/src/main/ipc/settings.test.ts index 01697bcc6fe..7af303c72a3 100644 --- a/src/main/ipc/settings.test.ts +++ b/src/main/ipc/settings.test.ts @@ -5,6 +5,7 @@ const { applyElectronProxySettingsMock, browserWindowGetAllWindowsMock, handleMock, + onMock, previewGhosttyImportMock, previewWarpThemeImportMock, prepareLocalWorktreeRootsForReposMock, @@ -14,6 +15,7 @@ const { applyElectronProxySettingsMock: vi.fn(), browserWindowGetAllWindowsMock: vi.fn(), handleMock: vi.fn(), + onMock: vi.fn(), previewGhosttyImportMock: vi.fn(), previewWarpThemeImportMock: vi.fn(), prepareLocalWorktreeRootsForReposMock: vi.fn(), @@ -22,7 +24,7 @@ const { vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: browserWindowGetAllWindowsMock }, - ipcMain: { handle: handleMock }, + ipcMain: { handle: handleMock, on: onMock }, nativeTheme: { themeSource: 'system' } })) @@ -70,6 +72,7 @@ const store = { describe('registerSettingsHandlers', () => { beforeEach(() => { handleMock.mockClear() + onMock.mockClear() applyAppIconMock.mockClear() applyElectronProxySettingsMock.mockClear() applyElectronProxySettingsMock.mockResolvedValue({ source: 'settings' }) @@ -89,6 +92,22 @@ describe('registerSettingsHandlers', () => { expect(channels).toContain('settings:previewGhosttyImport') }) + it('answers the synchronous settings read with the persisted settings', () => { + // Why: panes can bind PTYs before async hydration; the side-effect + // authority kill switch needs the persisted value synchronously. + store.getSettings.mockReturnValue({ terminalMainSideEffectAuthority: false }) + registerSettingsHandlers(store as never) + + const listener = onMock.mock.calls.find( + (call) => call[0] === 'settings:get-sync' + )?.[1] as (event: { returnValue: unknown }) => void + expect(listener).toBeTypeOf('function') + + const event = { returnValue: undefined as unknown } + listener(event) + expect(event.returnValue).toEqual({ terminalMainSideEffectAuthority: false }) + }) + it('registers settings:previewWarpThemeImport handler', () => { registerSettingsHandlers(store as never) const channels = handleMock.mock.calls.map((call) => call[0]) diff --git a/src/main/ipc/settings.ts b/src/main/ipc/settings.ts index 4f3a4698c76..b59bf6add2e 100644 --- a/src/main/ipc/settings.ts +++ b/src/main/ipc/settings.ts @@ -67,6 +67,15 @@ export function registerSettingsHandlers( return store.getSettings() }) + // Why: terminal panes can bind PTYs before async settings hydration + // completes. The side-effect authority kill switch is consulted once at + // transport creation, so the renderer needs the persisted value + // synchronously or pre-hydration bindings would always pick main authority + // (terminal-side-effect-authority.md, migration switch). + ipcMain.on('settings:get-sync', (event) => { + event.returnValue = store.getSettings() + }) + ipcMain.handle('settings:set', async (event, args: Partial) => { const sanitizedArgs = sanitizeRendererSettingsUpdate(args) // Why: Floating Workspace grants are trusted only when written by the diff --git a/src/main/providers/local-pty-provider.test.ts b/src/main/providers/local-pty-provider.test.ts index 3fc96c897bf..1448f59d946 100644 --- a/src/main/providers/local-pty-provider.test.ts +++ b/src/main/providers/local-pty-provider.test.ts @@ -92,6 +92,8 @@ describe('LocalPtyProvider', () => { onExit: ReturnType write: ReturnType resize: ReturnType + pause: ReturnType + resume: ReturnType kill: ReturnType process: string pid: number @@ -138,6 +140,8 @@ describe('LocalPtyProvider', () => { }), write: vi.fn(), resize: vi.fn(), + pause: vi.fn(), + resume: vi.fn(), kill: vi.fn(() => { exitCb?.({ exitCode: -1 }) }), @@ -896,6 +900,39 @@ describe('LocalPtyProvider', () => { }) }) + describe('producer flow control', () => { + it('pauses and resumes the node-pty process directly', async () => { + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + provider.pauseProducer(id) + expect(mockProc.pause).toHaveBeenCalledTimes(1) + provider.resumeProducer(id) + expect(mockProc.resume).toHaveBeenCalledTimes(1) + }) + + it('is a no-op for unknown PTY ids', () => { + expect(() => { + provider.pauseProducer('nonexistent') + provider.resumeProducer('nonexistent') + }).not.toThrow() + expect(mockProc.pause).not.toHaveBeenCalled() + expect(mockProc.resume).not.toHaveBeenCalled() + }) + + it('swallows node-pty throws from a torn-down PTY', async () => { + const { id } = await provider.spawn({ cols: 80, rows: 24 }) + mockProc.pause.mockImplementation(() => { + throw new Error('read EIO') + }) + mockProc.resume.mockImplementation(() => { + throw new Error('read EIO') + }) + expect(() => { + provider.pauseProducer(id) + provider.resumeProducer(id) + }).not.toThrow() + }) + }) + describe('shutdown', () => { it('kills the PTY process', async () => { // Why: capture the spy reference before shutdown triggers onExit → diff --git a/src/main/providers/local-pty-provider.ts b/src/main/providers/local-pty-provider.ts index 661b877abbf..941fe062997 100644 --- a/src/main/providers/local-pty-provider.ts +++ b/src/main/providers/local-pty-provider.ts @@ -842,6 +842,26 @@ export class LocalPtyProvider implements IPtyProvider { ptyProcesses.get(id)?.resize(cols, rows) } + // Why: node-pty pause() stops reading the pty master fd, so the kernel + // buffer fills and a flooding child blocks on write — true producer + // backpressure. Best-effort: a PTY torn down mid-call must never throw + // into the flow-control path. + pauseProducer(id: string): void { + try { + ptyProcesses.get(id)?.pause() + } catch { + /* PTY already destroyed */ + } + } + + resumeProducer(id: string): void { + try { + ptyProcesses.get(id)?.resume() + } catch { + /* PTY already destroyed */ + } + } + // Why: node-pty caches the last winsize it applied on the IPty handle, so its // cols/rows are the authoritative applied size (node-pty clamps invalid dims // and a resize on a dead handle is a no-op, neither of which the requested diff --git a/src/main/providers/provider-dispatch.test.ts b/src/main/providers/provider-dispatch.test.ts index 367179338c2..be45aa04ff3 100644 --- a/src/main/providers/provider-dispatch.test.ts +++ b/src/main/providers/provider-dispatch.test.ts @@ -17,6 +17,9 @@ vi.mock('electron', () => ({ on: onMock, removeHandler: removeHandlerMock, removeAllListeners: removeAllListenersMock + }, + powerMonitor: { + on: vi.fn() } })) diff --git a/src/main/providers/types.ts b/src/main/providers/types.ts index c8e2680366b..24bd305bfbc 100644 --- a/src/main/providers/types.ts +++ b/src/main/providers/types.ts @@ -21,9 +21,38 @@ import type { CommitMessageDraftContext } from '../../shared/commit-message-gene import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types' import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' // ─── PTY Provider ─────────────────────────────────────────────────── +/** Notification-bearing fact a thinning transport detected while it held + * scan authority for a backgrounded PTY (see onBackgroundStreamEvent). */ +export type PtyTransientFact = + | { kind: 'bell' } + | { kind: 'command-finished'; exitCode: number | null } + | { kind: 'pr-link'; link: TerminalGitHubPRLink } + | { kind: '2031-subscribe' } + +export type PtyBackgroundStreamEvent = + | { id: string; kind: 'backgroundMarker'; background: boolean; scanSeedAnsi?: string } + | { id: string; kind: 'dataGap'; droppedChars: number; sequenceChars?: number } + | { id: string; kind: 'transientFact'; fact: PtyTransientFact } + +export type PtyProviderBufferSnapshot = { + data: string + /** Authoritative normal buffer captured beside an alternate-screen frame. */ + scrollbackAnsi?: string + cols: number + rows: number + cwd?: string | null + lastTitle?: string + seq: number + source: 'headless' + oscLinks?: TerminalOscLinkRange[] + alternateScreen?: boolean + pendingEscapeTailAnsi?: string +} + export type PtySpawnOptions = { cols: number rows: number @@ -83,6 +112,11 @@ export type PtySpawnResult = { * writing the snapshot so ANSI cursor positions land correctly. */ snapshotCols?: number snapshotRows?: number + /** Kitty keyboard flags persisted in the daemon snapshot, threaded so the + * re-seeded runtime emulator answers hidden `CSI ? u` with the real flags + * (terminal-query-authority.md §kitty). Never replayed into a renderer + * xterm — POST_REPLAY_REATTACH_RESET's kitty reset stays authoritative. */ + snapshotKittyKeyboardFlags?: number /** True when the spawn reattached to an existing daemon session. */ isReattach?: boolean /** True when the reattached session uses the alternate screen buffer @@ -119,6 +153,36 @@ export type IPtyProvider = { hasPty?: (id: string) => boolean write(id: string, data: string): void resize(id: string, cols: number, rows: number): void + /** + * Producer-side flow control: stop/restart reading the underlying PTY so a + * flooding child blocks on write (kernel backpressure) instead of growing + * main-process buffers. Best-effort and optional — providers that cannot + * pause (SSH relay, legacy daemon protocols) omit these or no-op silently, + * and callers must keep functioning without them (the pending-output cap + * still bounds memory when pause is unavailable). + */ + pauseProducer?: (id: string) => void + resumeProducer?: (id: string) => void + /** + * Hidden-delivery hint: the renderer has no visible view for this PTY, so + * the provider's transport may keep-tail thin this PTY's monitoring stream + * under backlog (bytes nobody is watching must not bury a visible pane's + * echo). Best-effort and optional, like pauseProducer. + */ + setPtyBackgrounded?: (id: string, background: boolean) => void + /** + * Facts a thinning transport interleaves with onData, in byte order: + * scan-authority handoff markers, keep-tail gaps, and the transient facts + * (bell/command-finished/pr-link/2031) it detected in bytes it was allowed + * to drop. Only transports that thin implement it. + */ + onBackgroundStreamEvent?: (callback: (payload: PtyBackgroundStreamEvent) => void) => () => void + /** Authoritative provider-owned model snapshot. Daemon providers expose this + * after their monitoring stream gaps; other providers may omit it. */ + getBufferSnapshot?: ( + id: string, + opts?: { scrollbackRows?: number } + ) => Promise /** * The size the PTY has ACTUALLY applied, not the last size requested. * resize() is fire-and-forget for remote providers (daemon/SSH `notify`), @@ -145,7 +209,9 @@ export type IPtyProvider = { listProcesses(): Promise getDefaultShell(): Promise getProfiles(): Promise<{ name: string; path: string }[]> - onData(callback: (payload: { id: string; data: string }) => void): () => void + onData( + callback: (payload: { id: string; data: string; sequenceChars?: number }) => void + ): () => void onReplay(callback: (payload: { id: string; data: string }) => void): () => void onExit(callback: (payload: { id: string; code: number }) => void): () => void } diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 73330a14fa5..a9f03794db0 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -56,6 +56,7 @@ import { } from './orca-runtime' import { HeadlessEmulator } from '../daemon/headless-emulator' import type { RuntimeMobileSessionTabsResult } from '../../shared/runtime-types' +import type { TerminalSideEffectBatch } from '../../shared/terminal-side-effect-facts' import { TERMINAL_INPUT_CHUNK_MAX_BYTES, TERMINAL_INPUT_MAX_BYTES, @@ -6475,6 +6476,654 @@ describe('OrcaRuntimeService', () => { }) }) + it('resolves tui-idle when a completion title is coalesced with the next working title', async () => { + // Why: node-pty + the main batch window can coalesce "task done" and the + // next task's working title into one chunk. A last-title reader never + // sees the intermediate idle and the waiter hangs (issue #1083 class). + const runtime = createRuntime() + syncSinglePty(runtime) + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + const [terminal] = (await runtime.listTerminals()).terminals + const wait = runtime.waitForTerminal(terminal.handle, { + condition: 'tui-idle', + timeoutMs: 1_000 + }) + + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07\x1b]0;Codex working\x07', 101) + + await expect(wait).resolves.toMatchObject({ + handle: terminal.handle, + condition: 'tui-idle', + status: 'running' + }) + }) + + it('ignores the bare cursor-agent native title so synthesized spinner state survives', async () => { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;⠋ Cursor Agent\x07', 100) + // cursor-agent re-emits its bare native title on internal redraws while + // still working; it must not stomp the synthesized working title. + runtime.onPtyData(ptyId, '\x1b]0;Cursor Agent\x07', 101) + + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: '⠋ Cursor Agent' + }) + }) + + it('clears a stale working title after 3s of title-less output', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyId, 'output without a title\r\n', 101) + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: 'Codex working' + }) + + await vi.advanceTimersByTimeAsync(3_000) + + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: 'Codex' + }) + } finally { + vi.useRealTimers() + } + }) + + it('cancels the stale-title timer when the PTY exits', async () => { + vi.useFakeTimers() + try { + const ptyId = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-bg` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [{ id: ptyId, cwd: '/tmp/worktree-a', title: 'shell' }] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyId, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyId, 'output without a title\r\n', 101) + runtime.onPtyExit(ptyId, 0) + + await vi.advanceTimersByTimeAsync(4_000) + + // The dead session keeps its factual last title — the disposed tracker's + // stale-title rewrite must not fire into the retained record. + expect((await runtime.listTerminals()).terminals[0]).toMatchObject({ + title: 'Codex working' + }) + } finally { + vi.useRealTimers() + } + }) + + it('keeps stale-title timers isolated per PTY', async () => { + vi.useFakeTimers() + try { + const ptyA = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-a` + const ptyB = `${TEST_REPO_ID}::/tmp/worktree-a@@pty-b` + const runtime = createRuntime() + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + listProcesses: async () => [ + { id: ptyA, cwd: '/tmp/worktree-a', title: 'shell' }, + { id: ptyB, cwd: '/tmp/worktree-a', title: 'shell' } + ] + }) + runtime.attachWindow(1) + runtime.markGraphReady(1) + + runtime.onPtyData(ptyA, '\x1b]0;Codex working\x07', 100) + runtime.onPtyData(ptyB, '\x1b]0;Aider working\x07', 100) + // Only A receives title-less output, so only A's stale timer arms. + runtime.onPtyData(ptyA, 'output without a title\r\n', 101) + + await vi.advanceTimersByTimeAsync(3_000) + + const { terminals } = await runtime.listTerminals() + expect(terminals.find((t) => t.tabId === `pty:${ptyA}`)).toMatchObject({ title: 'Codex' }) + expect(terminals.find((t) => t.tabId === `pty:${ptyB}`)).toMatchObject({ + title: 'Aider working' + }) + } finally { + vi.useRealTimers() + } + }) + + // ─── pty:sideEffect channel (terminal-side-effect-authority.md, slice 2) ── + describe('terminal side-effect fact channel', () => { + function createSideEffectRuntime(): { + runtime: OrcaRuntimeService + batches: TerminalSideEffectBatch[] + } { + const batches: TerminalSideEffectBatch[] = [] + const runtime = new OrcaRuntimeService(store, undefined, { + onTerminalSideEffects: (batch) => batches.push(batch) + }) + return { runtime, batches } + } + + it('emits one batched event per chunk with facts in byte order and attribution', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + const chunk = '\x1b]0;Codex working\x07response\x1b]0;Codex done\x07\x07' + runtime.onPtyData('pty-1', chunk, 100) + + expect(batches).toHaveLength(1) + expect(batches[0]).toMatchObject({ + ptyId: 'pty-1', + seq: chunk.length, + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1', + paneKey: 'tab-1:1' + }) + expect(batches[0].replay).toBeUndefined() + expect(batches[0].facts).toEqual([ + { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }, + { kind: 'agent-working' }, + { kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' }, + { kind: 'agent-idle', title: 'Codex done' }, + { kind: 'bell' } + ]) + }) + + it('keeps per-PTY ordering across chunks and accumulates seq', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + + expect(batches.map((batch) => batch.facts[0]?.kind)).toEqual(['title', 'title']) + expect(batches[0].seq).toBeLessThan(batches[1].seq) + }) + + it('emits nothing for chunks without derived facts', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + // Plain output, a BEL-terminated non-title OSC split across chunks, and + // an Orca status payload: none of these is a title/bell/agent fact. + runtime.onPtyData('pty-1', 'plain output\r\n', 100) + runtime.onPtyData('pty-1', '\x1b]7;file://host', 101) + runtime.onPtyData('pty-1', '/tmp\x07', 102) + runtime.onPtyData('pty-1', '\x1b]9999;{"state":"working","agentType":"codex"}\x07', 103) + + expect(batches).toEqual([]) + }) + + it('emits the stale-working-title rewrite as between-chunk fact batches', async () => { + vi.useFakeTimers() + try { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07', 100) + runtime.onPtyData('pty-1', 'output without a title\r\n', 101) + batches.length = 0 + + await vi.advanceTimersByTimeAsync(3_000) + + // Timer facts fire outside a chunk, so each emits immediately — + // still strictly ordered per PTY. They carry staleWorkingTitleClear: + // the renderer must clear state without scheduling a task-complete + // notification main's unthrottled timer did not earn. + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ]) + } finally { + vi.useRealTimers() + } + }) + + it('ingests synthetic title frames without touching the byte pipeline', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07') + + expect(batches).toHaveLength(1) + expect(batches[0].facts).toEqual([ + { kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' }, + // The synthesized spinner classifies as working — agent facts derive + // from synthetic frames the same as from real bytes. + { kind: 'agent-working' } + ]) + // Synthetic frames are fabricated by main: they must not advance the + // metered output sequence the renderer ACK budget is based on. + expect(runtime.getPtyOutputSequence('pty-1')).toBe(0) + }) + + it('carries the synthetic permission BEL as a bell fact', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor needs your input\x07\x07') + + expect(batches[0].facts.at(0)).toMatchObject({ kind: 'title' }) + expect(batches[0].facts.at(-1)).toEqual({ kind: 'bell' }) + }) + + it('emits command-finished facts with best-effort exit codes across chunk splits', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'output\x1b]133;D;13', 100) + expect(batches).toEqual([]) + runtime.onPtyData('pty-1', '0\x07prompt $ ', 101) + runtime.onPtyData('pty-1', '\x1b]133;D\x07', 102) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { kind: 'command-finished', exitCode: 130 }, + { kind: 'command-finished', exitCode: null } + ]) + }) + + it('emits pr-link facts once per URL with batch attribution', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'PR https://github.com/acme/orca/pull/4', 100) + runtime.onPtyData('pty-1', '2\r\nand https://github.com/acme/orca/pull/43 done\r\n', 101) + // Repeated URL: deduped per PTY, like the renderer byte detector. + runtime.onPtyData('pty-1', 'again https://github.com/acme/orca/pull/42\r\n', 102) + + expect(batches).toHaveLength(1) + expect(batches[0]).toMatchObject({ + ptyId: 'pty-1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1' + }) + expect(batches[0].facts).toEqual([ + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + }, + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/43', + slug: { owner: 'acme', repo: 'orca' }, + number: 43 + } + } + ]) + }) + + it('emits 2031-subscribe facts across chunk splits', () => { + // Why: hidden-delivery-gated views never receive the bytes — this fact + // is their only signal to send the DECSET 2031 color-scheme reply. + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b[?20', 100) + expect(batches).toEqual([]) + runtime.onPtyData('pty-1', '31h', 101) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([{ kind: '2031-subscribe' }]) + }) + + it('prefers the tracked title over the renderer snapshot lastTitle', async () => { + const { runtime } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'visible content', + cols: 80, + rows: 24, + // The renderer xterm never saw the synthetic frame (it no longer + // rides pty:data), so its serializer reports a stale title. + lastTitle: 'stale shell title' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07') + + const snapshot = await runtime.serializeTerminalBuffer('pty-1', { scrollbackRows: 10 }) + expect(snapshot?.source).toBe('renderer') + expect(snapshot?.lastTitle).toBe('⠋ Cursor Agent') + }) + + it('prefers the tracked title over the headless emulator lastTitle', async () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07real output\r\n', 100) + // The hook-driven idle frame lands only in main's tracker — the + // emulator never sees fabricated bytes (invariant 5). + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Codex ready\x07') + + const snapshot = await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 }) + expect(snapshot?.source).toBe('headless') + expect(snapshot?.lastTitle).toBe('Codex ready') + }) + + it('returns a title-only replay snapshot and never historical attention', () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;Codex working\x07\x07', 100) + + expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toMatchObject({ + ptyId: 'pty-1', + replay: true, + facts: [{ kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }] + }) + expect(runtime.getTerminalSideEffectSnapshot('pty-unknown')).toBeNull() + }) + + it('drops the cursor-agent literal from record-fallback snapshots', () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'plain output\n', 100) + // Simulate a record title restored by a path that bypassed the tracker + // (the tracker itself refuses to store the bare native title). + const records = ( + runtime as unknown as { + ptysById: Map + } + ).ptysById + records.get('pty-1')!.lastOscTitle = 'Cursor Agent' + + expect(runtime.getTerminalSideEffectSnapshot('pty-1')).toBeNull() + }) + + it('emits the chunk agentStatus events before its side-effect batch', () => { + // Cross-channel contract order per chunk: status → titles → bell. + const order: string[] = [] + const runtime = new OrcaRuntimeService(store, undefined, { + onTerminalAgentStatus: () => order.push('agentStatus:set'), + onTerminalSideEffects: () => order.push('pty:sideEffect') + }) + syncSinglePty(runtime) + + runtime.onPtyData( + 'pty-1', + '\x1b]9999;{"state":"working","agentType":"codex"}\x07\x1b]0;Codex working\x07\x07', + 100 + ) + + expect(order).toEqual(['agentStatus:set', 'pty:sideEffect']) + }) + + it('still emits a throwing chunk’s facts under its own seq, not the next chunk’s', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + vi.spyOn( + runtime as unknown as { applyTrackedPtyTitle: (ptyId: string, title: string) => boolean }, + 'applyTrackedPtyTitle' + ).mockImplementationOnce(() => { + throw new Error('tracker boom') + }) + + const first = '\x1b]0;Codex working\x07' + expect(() => runtime.onPtyData('pty-1', first, 100)).toThrow('tracker boom') + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + + expect(batches).toHaveLength(2) + expect(batches[0].seq).toBe(first.length) + expect(batches[0].facts).toEqual([ + { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' } + ]) + // The next chunk's batch carries only its own facts (the throw aborted + // the first chunk's agent-tracker pass, so no working state was kept). + expect(batches[1].seq).toBeGreaterThan(batches[0].seq) + expect(batches[1].facts).toEqual([ + { kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' } + ]) + }) + + it('parses synthetic frames statelessly so ticks cannot corrupt the bell detector', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '\x1b]0;split ti', 100) + // An 80ms spinner tick lands between the two halves of the real OSC. + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07') + // Continuation: this BEL terminates the real OSC — it is NOT a bell. + runtime.onPtyData('pty-1', 'tle\x07', 101) + // A later standalone BEL is a real bell and must not be swallowed. + runtime.onPtyData('pty-1', 'ready\x07', 102) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' }, + { kind: 'agent-working' }, + { kind: 'title', normalizedTitle: 'split title', rawTitle: 'split title' }, + { kind: 'bell' } + ]) + }) + + it('touches mobile snapshots once for decorative spinner ticks, again on idle', () => { + const { runtime } = createSideEffectRuntime() + syncSinglePty(runtime) + const touchSpy = vi.spyOn( + runtime as unknown as { touchMobileSessionSnapshotsForPty: (ptyId: string) => void }, + 'touchMobileSessionSnapshotsForPty' + ) + + for (const frame of ['⠋', '⠙', '⠹', '⠸', '⠼']) { + runtime.ingestSyntheticTitleFrame('pty-1', `\x1b]0;${frame} Cursor Agent\x07`) + } + // Five ticks with the same de-spinnered title: one snapshot fan-out. + expect(touchSpy).toHaveBeenCalledTimes(1) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor ready\x07') + expect(touchSpy).toHaveBeenCalledTimes(2) + // Raw record titles still track every frame for worktree ps/mobile tabs. + expect( + ( + runtime as unknown as { + ptysById: Map + } + ).ptysById.get('pty-1')?.lastOscTitle + ).toBe('Cursor ready') + }) + + it('seeds the lazily created tracker from the daemon-snapshot title', async () => { + const { runtime, batches } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'restored scrollback\n', + cols: 80, + rows: 24, + lastTitle: 'Codex working' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + // First live chunk creates the tracker cold and kicks off hydration; + // the snapshot seed must land in the already-created tracker. + runtime.onPtyData('pty-1', 'plain output without a title\n', 100) + await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 }) + batches.length = 0 + + runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101) + + // Without the seed the tracker never saw 'working', so this idle title + // could not produce a completion fact. + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'agent-idle', + title: 'Codex done' + }) + }) + + it('arms the stale-title timer for a seeded working title', async () => { + vi.useFakeTimers() + try { + const { runtime, batches } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'restored scrollback\n', + cols: 80, + rows: 24, + lastTitle: 'Codex working' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', 'plain output\n', 100) + // Settle the async daemon-snapshot hydration that seeds the tracker. + await vi.advanceTimersByTimeAsync(0) + runtime.onPtyData('pty-1', 'still no title\n', 101) + batches.length = 0 + + await vi.advanceTimersByTimeAsync(3_000) + + expect(batches.flatMap((batch) => batch.facts)).toEqual([ + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ]) + } finally { + vi.useRealTimers() + } + }) + + it('emits command-code-working facts only after the banner arms the scrape', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + // Generic status words without the Command Code banner must not arm. + runtime.onPtyData('pty-1', '❯ Fix the spinner\r\nThinking...', 100) + expect(batches.flatMap((batch) => batch.facts)).toEqual([]) + + runtime.onPtyData('pty-1', '# Command Code v0.27.3\r\n', 101) + runtime.onPtyData('pty-1', '❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m', 102) + + expect(batches.at(-1)).toMatchObject({ + ptyId: 'pty-1', + worktreeId: TEST_WORKTREE_ID, + tabId: 'tab-1' + }) + expect(batches.at(-1)?.facts).toEqual([ + { kind: 'command-code-working', prompt: 'Fix the spinner' } + ]) + }) + + it('emits a command-code-done fact when the idle composer returns', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + runtime.onPtyData('pty-1', '# Command Code v0.27.3\r\n', 100) + runtime.onPtyData('pty-1', '❯ say hi\r\n✻ Thinking...', 101) + runtime.onPtyData( + 'pty-1', + '\r\n✻ Thought for 1 second\r\n:: Hi!\r\n❯ Ask your question...', + 102 + ) + + expect(batches.at(-1)?.facts).toEqual([{ kind: 'command-code-done', prompt: 'say hi' }]) + }) + + it('arms the Command Code scrape from the noted spawn command', () => { + const { runtime, batches } = createSideEffectRuntime() + syncSinglePty(runtime) + + // Mirrors the renderer detector's startupCommand fast-arm: no banner + // needed when main saw the launch command at spawn time. + runtime.noteTerminalSpawnCommand('pty-1', 'command-code --trust') + runtime.onPtyData('pty-1', '❯ Fix the spinner\r\n✻ Thinking...', 100) + + expect(batches.flatMap((batch) => batch.facts)).toContainEqual({ + kind: 'command-code-working', + prompt: 'Fix the spinner' + }) + }) + + it('prefers the tracked title over a stale renderer lastTitle in the hydration seed', async () => { + const { runtime } = createSideEffectRuntime() + const serializeBuffer = vi.fn().mockResolvedValue({ + data: 'renderer scrollback\n', + cols: 80, + rows: 24, + // The renderer xterm never saw the synthetic hook frame (it no longer + // rides pty:data), so its serializer reports the pre-agent title. + lastTitle: 'stale shell title' + }) + runtime.setPtyController({ + write: () => true, + kill: () => true, + getForegroundProcess: async () => null, + serializeBuffer, + hasRendererSerializer: () => true, + getSize: () => ({ cols: 80, rows: 24 }) + }) + syncSinglePty(runtime) + + runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Claude working\x07') + // First live chunk kicks off renderer hydration; awaiting the snapshot + // below settles the seed write chain. + runtime.onPtyData('pty-1', 'plain output\n', 100) + await runtime.serializeMainTerminalBuffer('pty-1', { scrollbackRows: 10 }) + + const leaves = ( + runtime as unknown as { leaves: Map } + ).leaves + // The seed must not stomp the leaf record (worktree ps status source) + // back to the renderer's stale title. + expect([...leaves.values()][0]?.lastOscTitle).toBe('⠋ Claude working') + }) + }) + it('returns OSC titles from headless main terminal snapshots', async () => { const runtime = createRuntime() syncSinglePty(runtime, 'pty-1') @@ -6733,6 +7382,16 @@ describe('OrcaRuntimeService', () => { expect(serializeBuffer).not.toHaveBeenCalled() }) + it('advances the absolute output sequence across a daemon stream gap', () => { + const runtime = createRuntime() + runtime.onPtyData('pty-gap', 'before', Date.now()) + + runtime.notePtyDataGap('pty-gap', 4096) + runtime.onPtyData('pty-gap', 'after', Date.now()) + + expect(runtime.getPtyOutputSequence('pty-gap')).toBe('before'.length + 4096 + 'after'.length) + }) + it('emits explicit OSC 9999 agent status from runtime PTY data', () => { const statuses: RuntimeTerminalAgentStatusEvent[] = [] const runtime = new OrcaRuntimeService(store, undefined, { diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index b59afc24a32..be8dc19a9ee 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -2,9 +2,9 @@ /* eslint-disable unicorn/no-useless-spread -- Why: waiter sets and handle keys are cloned intentionally before mutation so resolution and rejection can safely remove entries while iterating. */ /* eslint-disable no-control-regex -- Why: terminal normalization must strip ANSI and OSC control sequences from PTY output before returning bounded text to agents. */ import { - extractLastOscTitle, detectAgentStatusFromTitle, isClaudeManagementTitle, + isCursorNativeAgentTitle, isShellProcess, normalizeTerminalTitle } from '../../shared/agent-detection' @@ -13,6 +13,17 @@ import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extra import { parseFileUriPathParts } from '../daemon/osc7-file-uri' import type { AgentStatus } from '../../shared/agent-detection' import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges' +import { + createTerminalTitleTracker, + stripBrailleSpinnerGlyphs, + type TerminalTitleTracker +} from '../../shared/terminal-output-side-effects' +import { createCommandCodeOutputStatusDetector } from '../../shared/command-code-output-status' +import type { + TerminalSideEffectBatch, + TerminalSideEffectFact +} from '../../shared/terminal-side-effect-facts' +import type { TerminalGitHubPRLink } from '../../shared/terminal-github-pr-link-detector' import { AGENT_STATUS_STALE_AFTER_MS, type AgentStatusIpcPayload, @@ -688,14 +699,28 @@ import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' import { invalidateAuthorizedRootsCache } from '../ipc/filesystem-auth' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { closeLocalWatcherForWorktreePath } from '../ipc/filesystem-watcher' -import { HeadlessEmulator, type HeadlessEmulatorOptions } from '../daemon/headless-emulator' +import { HeadlessEmulator } from '../daemon/headless-emulator' +import { + isNativeWindowsConptyPty, + registerConptyDa1OverrideInstaller, + shouldModelAnswerHiddenPtyQueries +} from './terminal-model-query-authority' +import { + getTerminalViewAttributes, + registerTerminalViewAttributesApplier +} from './terminal-view-attribute-store' import { killAllProcessesForWorktree } from './worktree-teardown' import { MOBILE_SUBSCRIBE_SCROLLBACK_ROWS } from './scrollback-limits' import { createMobileSessionTabsNotifyCoalescer, type MobileSessionTabsNotifyCoalescer } from './mobile-session-tabs-notify-coalescer' -import type { IFilesystemProvider, IPtyProvider, PtyProcessInfo } from '../providers/types' +import type { + IFilesystemProvider, + IPtyProvider, + PtyProcessInfo, + PtyTransientFact +} from '../providers/types' import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch' import { assertFolderWorkspacePathUsable, @@ -831,6 +856,11 @@ type RuntimeStore = { mobileEmulatorDefaultDeviceUdid?: string | null voice?: VoiceSettings claudeAgentTeamsMode?: GlobalSettings['claudeAgentTeamsMode'] + // Why: Phase-5 query responder kill switches — read per chunk in + // onPtyData to capture reply ownership at ingestion. + terminalMainSideEffectAuthority?: GlobalSettings['terminalMainSideEffectAuthority'] + terminalHiddenDeliveryGate?: GlobalSettings['terminalHiddenDeliveryGate'] + terminalModelQueryAuthority?: GlobalSettings['terminalModelQueryAuthority'] } // Why: narrow to `unknown` return so test mocks can return void without // a cast. The runtime never reads the return value — the persisted value @@ -1095,6 +1125,28 @@ export type RuntimeTerminalAgentStatusEvent = { payload: ParsedAgentStatusPayload } +type RuntimePtyTitleTrackerEntry = { + tracker: TerminalTitleTracker + // Why: onPtyData batches the mobile session-tab touch to once per chunk; + // the stale-working-title timer fires between chunks and must touch + // immediately. These flags route the tracker callback to the right mode. + applyingChunk: boolean + // Why: synthetic spinner ticks arrive ~12.5x/sec per working pane; the + // synthetic path gates mobile snapshot fan-out on a non-decorative title + // change (spinner glyph + status comparison key kept below). + applyingSyntheticFrame: boolean + lastMobileTitleGateKey: string | null + chunkTouchedSessionTabs: boolean + // Why: facts observed while applying a chunk are batched into one + // pty:sideEffect emission per chunk, preserving byte order (titles in + // sequence, then bell). Timer-fired facts emit immediately between chunks. + pendingFacts: TerminalSideEffectFact[] + // Why: Command Code lacks hooks, so its working/done state is scraped from + // TUI output. Null when no side-effect consumer exists (headless serve) — + // the scrape produces facts only. + commandCodeDetector: { observe: (data: string) => boolean } | null +} + // Why: the full OSC 9999 payload flows through emitTerminalAgentStatusEvents and // is then forwarded to the renderer and dropped. Mobile is served by the main // process and has no renderer store, so we retain the latest payload per pane @@ -1121,6 +1173,10 @@ type RuntimeHeadlessTerminal = { type HeadlessSeedMetadata = { cwd?: string | null oscLinks?: TerminalOscLinkRange[] + /** Persisted kitty flags from the daemon snapshot, re-applied to the fresh + * emulator so hidden `CSI ? u` answers the real flags instead of ?0u + * (terminal-query-authority.md §kitty). */ + kittyKeyboardFlags?: number } type RuntimePtyController = { @@ -2152,12 +2208,39 @@ export class OrcaRuntimeService { // Why: OSC 9999 status can span PTY chunks. Keeping parser state in the // runtime lets hidden/model-owned terminals observe agent state without a // mounted xterm view. + // Why a throttle: the blocked-reason check builds and scans two full wait + // texts (<=256KB each, lowercased) — measured at ~85% of onPtyData's cost + // under a TUI flood (findings log 2026-07-03). PTY chunk boundaries are + // arbitrary, so running the identical computation over coalesced chunks at + // a bounded cadence (plus a trailing-edge timer so burst-final state is + // always evaluated) preserves semantics while removing it from the hot path. + private waitBlockedCheckStateByPtyId = new Map< + string, + { + lastAt: number + lastWaitState: TerminalTailWaitState | null + appended: string + keywordCarry: string + timer: ReturnType | null + } + >() + private agentStatusOscProcessorsByPtyId = new Map< string, ReturnType >() + // Why: per-PTY shared title trackers (all-titles ordering + stale-working + // timer) replace last-title-per-chunk scanning so main observes the same + // intra-chunk working→idle transitions the renderer does (issue #1083). + // Lazily created like agentStatusOscProcessorsByPtyId; disposed on PTY exit. + private ptyTitleTrackersByPtyId = new Map() + // Why: the Command Code output detector arms early from the launch command + // when known (banner detection covers user-typed launches), mirroring the + // renderer detector's startupCommand seed. + private terminalSpawnCommandsByPtyId = new Map() // Why: ordinary OSC 0/1/2 titles can split across PTY chunks, especially over - // SSH/relay buffering. Keep a small raw scan tail so status titles are not lost. + // SSH/relay buffering. Keep a small raw scan tail and feed reconstructed + // chunks into the title tracker instead of falling back to last-title scans. private oscTitleScanTailByPtyId = new Map() // Why: mobile file taps resolve relative paths on the host. OSC 7 is the // terminal-owned cwd signal, and it can arrive in live output between snapshots. @@ -2224,6 +2307,14 @@ export class OrcaRuntimeService { > >() + // Why: Phase-5 query-responder suppression — a terminal-RPC subscribe + // stream feeds a remote xterm view (mobile/web/remote desktop) that answers + // queries with view authority, so main must yield while one is attached + // (terminal-query-authority.md). Ref-counted per PTY because multiple + // streams can attach concurrently; mobileSubscribers is consulted too so + // grace-window mobile records keep suppressing. + private remoteTerminalViewSubscriberCounts = new Map() + // Why: per-PTY driver state. The "driver" is whoever currently owns the // input/resize floor. While `kind === 'mobile'` the desktop renderer drops // xterm.onData/onResize and shows the lock banner; `terminal.send` / @@ -2371,6 +2462,7 @@ export class OrcaRuntimeService { private readonly getLocalProviderFn: (() => IPtyProvider) | null private readonly onPtyStopped: ((ptyId: string) => void) | null private readonly onTerminalAgentStatus: ((event: RuntimeTerminalAgentStatusEvent) => void) | null + private readonly onTerminalSideEffects: ((batch: TerminalSideEffectBatch) => void) | null private readonly getAgentStatusSnapshotFn: (() => AgentStatusIpcPayload[]) | null private readonly buildAgentHookPtyEnv: (() => Record) | null private accountServices: RuntimeAccountServices | null = null @@ -2395,6 +2487,7 @@ export class OrcaRuntimeService { getLocalProvider?: () => IPtyProvider onPtyStopped?: (ptyId: string) => void onTerminalAgentStatus?: (event: RuntimeTerminalAgentStatusEvent) => void + onTerminalSideEffects?: (batch: TerminalSideEffectBatch) => void // Why: agent status mostly arrives via hooks (agent-hooks/server), not OSC // terminal output. worktree.ps reads this at query time so mobile shows the // same inline agent rows the desktop sidebar does — same source, 1:1. @@ -2431,6 +2524,20 @@ export class OrcaRuntimeService { this.onPtyStopped = deps?.onPtyStopped ?? null this.onTerminalAgentStatus = deps?.onTerminalAgentStatus ?? null this.buildAgentHookPtyEnv = deps?.buildAgentHookPtyEnv ?? null + this.onTerminalSideEffects = deps?.onTerminalSideEffects ?? null + // Why: the ConPTY spawn mark can land after daemon stream data already + // created this PTY's emulator; the mark retrofits the DA1 override here + // (terminal-query-authority.md §ConPTY DA1). + registerConptyDa1OverrideInstaller((ptyId) => this.ensureNativeWindowsConptyDa1Override(ptyId)) + // Why: a renderer attribute push must reach already-live emulators too — + // cursor options for DECRQSS/DECRQM parity plus the per-PTY OSC color + // override reset a theme apply implies (terminal-query-authority.md + // §View-attribute bridge). + registerTerminalViewAttributesApplier((attributes) => { + for (const state of this.headlessTerminals.values()) { + state.emulator.applyPushedViewAttributes(attributes) + } + }) } getLocalProvider(): IPtyProvider | null { @@ -5390,12 +5497,22 @@ export class OrcaRuntimeService { } } + /** Record the spawn launch command so the per-PTY Command Code detector can + * arm from it (renderer startupCommand parity). Best-effort: a chunk that + * beats this call falls back to the detector's banner arming. */ + noteTerminalSpawnCommand(ptyId: string, command: string | null | undefined): void { + const trimmed = typeof command === 'string' ? command.trim() : '' + if (trimmed.length > 0) { + this.terminalSpawnCommandsByPtyId.set(ptyId, trimmed) + } + } + /** * Handles incoming data from a PTY process, running agent detection, * updating terminal tail buffers, and triggering foreground agent refreshes. */ - onPtyData(ptyId: string, data: string, at: number): number { - const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + data.length + onPtyData(ptyId: string, data: string, at: number, sequenceChars = data.length): number { + const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + sequenceChars this.ptyOutputSequenceById.set(ptyId, outputSequence) const osc7Metadata = this.recordOsc7MetadataForPty(ptyId, data) const cwd = osc7Metadata.cwd @@ -5410,6 +5527,12 @@ export class OrcaRuntimeService { // panel can surface them in place of the kernel bind address. advertisedUrlWatcher.ingest(ptyId, data, at) serveSimStateWatcher.ingestPtyOutput(ptyId, data) + // Why: reply ownership is captured per chunk, here at ingestion — the + // same module state and tick as the hidden-gate drop sites — and rides + // the writeChain link. A mark/setting/subscriber flip before the queued + // emulator write runs must not change who answers (terminal-query- + // authority.md invariant 1). + const forwardQueryReplies = this.shouldAnswerQueriesForLiveChunk(ptyId) // Ordering invariant (DO NOT REORDER): maybeHydrateHeadlessFromRenderer // MUST run before trackHeadlessTerminalData so the eager-state pattern // (set headlessTerminals + writeChain head = seedPromise) is in place @@ -5418,24 +5541,14 @@ export class OrcaRuntimeService { // that the later seed-resolve would overwrite, dropping the live byte. // See docs/mobile-prefer-renderer-scrollback.md. this.maybeHydrateHeadlessFromRenderer(ptyId) - this.trackHeadlessTerminalData(ptyId, data, outputSequence) - - // Why: extract OSC title from raw PTY data before tail-buffer processing - // strips the escape sequences. Agent CLIs (Claude Code, Gemini, etc.) - // announce status via OSC 0/1/2 title sequences — this is the same - // detection path the renderer uses for notifications and sidebar badges. - const rawOscTitle = this.extractLastOscTitleForPty(ptyId, data) - // Why: collapse high-churn agent titles (Grok/Pi spinner frames, Gemini - // per-keystroke updates) once at the observation boundary so lastOscTitle — - // and the mobile session-tab titles/snapshots derived from it — stays - // stable instead of changing every animation frame. Status is detected - // from the raw title (mirroring the renderer's tracker) so working/idle - // transitions are unaffected by normalization. - const oscTitle = rawOscTitle === null ? null : normalizeTerminalTitle(rawOscTitle) - const agentStatus = rawOscTitle ? detectAgentStatusFromTitle(rawOscTitle) : null + // Our structure wins: OSC title/agent-status extraction runs through the + // shared per-PTY title tracker below (getOrCreatePtyTitleTrackerEntry → + // applyTrackedPtyTitle) in byte order, superseding main's inline + // extractLastOscTitleForPty block (#7880/#7852 title/status semantics are + // preserved via the tracker + detectAgentStatusFromTitle path). + this.trackHeadlessTerminalData(ptyId, data, outputSequence, forwardQueryReplies) const pty = this.getOrCreatePtyWorktreeRecord(ptyId) - let shouldTouchPtyBackedSessionTabs = false const ptyTailBefore = pty ? { lines: pty.tailBuffer, @@ -5453,28 +5566,12 @@ export class OrcaRuntimeService { pty.lastOutputAt = at const normalized = normalizeTerminalChunk(data, pty.tailPendingAnsi) pty.tailPendingAnsi = normalized.pendingAnsi - // Why: the prior chunk's post-append tail is this chunk's pre-append tail, - // so its cached wait scan is exact — reuse it instead of rebuilding and - // rescanning the full tail. Only a tail-derived state is safe to reuse. - const previousWaitState = - pty.tailWaitState?.fromTail === true - ? pty.tailWaitState - : computeTerminalTailWaitState(pty.tailBuffer, pty.tailPartialLine, pty.preview) const nextTail = appendNormalizedToTailBuffer( pty.tailBuffer, pty.tailPartialLine, normalized.text, pty.tailRedrawCursor ) - const nextWaitState = computeTerminalTailWaitState( - nextTail.lines, - nextTail.partialLine, - pty.preview - ) - if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, normalized.text)) { - pty.waitBlockedAt = at - } - pty.tailWaitState = nextWaitState ptyTailAfter = nextTail pty.tailBuffer = nextTail.lines pty.tailPartialLine = nextTail.partialLine @@ -5482,40 +5579,7 @@ export class OrcaRuntimeService { pty.tailTruncated = pty.tailTruncated || nextTail.truncated pty.tailLinesTotal += nextTail.newCompleteLines pty.preview = buildPreview(pty.tailBuffer, pty.tailPartialLine) - if (oscTitle !== null) { - const prevStatus = pty.lastAgentStatus - const prevTitle = pty.lastOscTitle - const observedAt = this.nextTitleObservationSequence() - pty.lastOscTitle = oscTitle - pty.lastOscTitleAt = observedAt - pty.lastAgentStatus = agentStatus - this.setPtyManagementTitleFromObservedTitle(pty, oscTitle, observedAt) - shouldTouchPtyBackedSessionTabs = - prevTitle !== oscTitle || prevStatus !== pty.lastAgentStatus - if (agentStatus === 'idle' && prevStatus !== 'idle') { - this.resolvePtyTuiIdleWaiters(pty, ptyId) - } - const shouldDelayMobileSnapshot = - shouldTouchPtyBackedSessionTabs && - this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, oscTitle) - let foregroundRefresh: Promise | undefined - // Why: gate on an actual status transition — braille spinner frames - // mutate the title every tick, so probing per-title-change would stream - // a foreground query per frame during active work. - if (prevStatus !== pty.lastAgentStatus) { - foregroundRefresh = this.refreshPtyForegroundAgentFromController(ptyId, { - afterTitleObservation: observedAt - }) - } else if (shouldDelayMobileSnapshot) { - // Why: same-status compatible title changes can arrive before the - // foreground owner probe settles; publishing them would flicker. - foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt) - } - if (foregroundRefresh && shouldDelayMobileSnapshot) { - shouldTouchPtyBackedSessionTabs = false - this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) - } - } + this.scheduleWaitBlockedCheck(ptyId, normalized.text, at) } for (const leaf of this.getLeavesForPty(ptyId)) { @@ -5553,7 +5617,10 @@ export class OrcaRuntimeService { leaf.tailLinesTotal = pty.tailLinesTotal leaf.preview = pty.preview leaf.waitBlockedAt = pty.waitBlockedAt - // Why: the leaf mirrors the PTY tail here, so share the cached wait scan. + // Why undefined on this branch: the PTY record's wait scan is throttled + // (scheduleWaitBlockedCheck), so pty.tailWaitState is never populated; + // copying it here intentionally invalidates the leaf cache and the + // mismatch branch below recomputes an exact state on its next chunk. leaf.tailWaitState = pty.tailWaitState } else { const normalized = normalizeTerminalChunk(data, leaf.tailPendingAnsi) @@ -5584,42 +5651,54 @@ export class OrcaRuntimeService { leaf.tailLinesTotal += nextTail.newCompleteLines leaf.preview = buildPreview(leaf.tailBuffer, leaf.tailPartialLine) } - - if (oscTitle !== null) { - // Why: keep the latest OSC title on the leaf so worktree.ps can - // recompute status from the live title each call. Without this, - // daemon-hosted terminals (no renderer pushing pane titles) had no - // way to clear a stale 'working' status after the agent exited and - // the shell took over the title — the stuck-spinner bug in #1437. - leaf.lastOscTitle = oscTitle - leaf.lastOscTitleAt = this.nextTitleObservationSequence() - const prevStatus = leaf.lastAgentStatus - // Why: when a new OSC title doesn't classify as an agent state (e.g. - // bare shell title after the agent exits), clear lastAgentStatus so - // it is no longer sticky. Tui-idle waiters that needed the previous - // 'idle' transition were already resolved at the moment of the - // transition below; only fresh waiters registered after the agent - // exits would observe the cleared value, and they correctly fall - // back to title-based detection / polling. - leaf.lastAgentStatus = agentStatus - // Why: resolve tui-idle on any transition TO idle (not just working→idle). - // Claude Code may skip "working" entirely on fast tasks, going null→idle, - // and the coordinator's tui-idle waiter would hang forever waiting for a - // working→idle transition that never comes. Permission→idle is excluded: - // it means the agent was blocked on user approval and the user said no, - // which isn't a task-completion signal. - if (agentStatus === 'idle' && prevStatus !== 'idle') { - this.resolveTuiIdleWaiters(leaf) - this.deliverPendingMessages(leaf) - } - } } - const retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + // Why: feed the chunk's OSC titles through the shared per-PTY tracker in + // byte order — the same ordering the renderer transport uses — so + // coalesced working→idle transitions reach tui-idle waiters and + // pending-message delivery instead of being masked by the chunk's last + // title (issue #1083). Uses the OSC 9999-stripped cleanData like the + // renderer, so pure status chunks don't perturb the stale-title probe. + const titleTrackerEntry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + const previousTitleScanTail = this.oscTitleScanTailByPtyId.get(ptyId) + const titleInput = previousTitleScanTail + ? `${previousTitleScanTail}${agentStatusChunk.cleanData}` + : agentStatusChunk.cleanData + const nextTitleScanTail = extractOscTitleScanTail(titleInput) + if (nextTitleScanTail.length > 0) { + this.oscTitleScanTailByPtyId.set(ptyId, nextTitleScanTail) + } else { + this.oscTitleScanTailByPtyId.delete(ptyId) + } + titleTrackerEntry.applyingChunk = true + titleTrackerEntry.chunkTouchedSessionTabs = false + let retainedAgentStatusChanged = false + try { + titleTrackerEntry.tracker.handleChunk(agentStatusChunk.cleanData, { + titleScanData: titleInput + }) + // Why: the Command Code scrape rides the same per-chunk batch (its facts + // trail the tracker's). cleanData keeps OSC 9999 payloads out of the + // detector's bounded recent-text window; the detector strips remaining + // control sequences itself, exactly like the renderer byte path. + titleTrackerEntry.commandCodeDetector?.observe(agentStatusChunk.cleanData) + } finally { + titleTrackerEntry.applyingChunk = false + try { + // Why: per-chunk cross-channel contract order is status → titles → + // bell — the chunk's agentStatus:set events must reach the renderer + // before its pty:sideEffect batch. + retainedAgentStatusChanged = this.emitTerminalAgentStatusEvents(ptyId, agentStatusChunk) + } finally { + // Why: flushed in the finally so a throwing tracker callback cannot + // strand this chunk's facts to be emitted under the next chunk's seq. + this.flushPendingTerminalSideEffectFacts(ptyId, titleTrackerEntry) + } + } // Why: hook (OSC 9999) transitions often arrive without a title change, so // headless-serve snapshots would never republish and paired remote clients // kept the stale agent state until the next title change (#7970). - if (shouldTouchPtyBackedSessionTabs || retainedAgentStatusChanged) { + if (titleTrackerEntry.chunkTouchedSessionTabs || retainedAgentStatusChanged) { this.touchMobileSessionSnapshotsForPty(ptyId) } @@ -5637,6 +5716,80 @@ export class OrcaRuntimeService { return outputSequence } + private scheduleWaitBlockedCheck(ptyId: string, appendedText: string, at: number): void { + let state = this.waitBlockedCheckStateByPtyId.get(ptyId) + if (!state) { + state = { lastAt: 0, lastWaitState: null, appended: '', keywordCarry: '', timer: null } + this.waitBlockedCheckStateByPtyId.set(ptyId, state) + } + const appendedLower = appendedText.toLowerCase() + const keywordHit = WAIT_BLOCKED_KEYWORD_PATTERN.test(`${state.keywordCarry}${appendedLower}`) + state.keywordCarry = 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. + state.appended = + state.appended.length + appendedText.length > MAX_TAIL_CHARS + ? `${state.appended}${appendedText}`.slice(-MAX_TAIL_CHARS) + : `${state.appended}${appendedText}` + const elapsed = at - state.lastAt + if (keywordHit || elapsed >= WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS || elapsed < 0) { + this.runWaitBlockedCheck(ptyId, state, at) + return + } + if (!state.timer) { + // Why trailing edge: the final chunks of a burst must still be + // evaluated or a prompt arriving right after a flood would go + // unstamped until the next output. + state.timer = setTimeout(() => { + state.timer = null + this.runWaitBlockedCheck(ptyId, state, Date.now()) + }, WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS - elapsed) + } + } + + private runWaitBlockedCheck( + ptyId: string, + state: { + lastAt: number + lastWaitState: TerminalTailWaitState | null + appended: string + keywordCarry: string + timer: ReturnType | null + }, + at: number + ): void { + const pty = this.ptysById.get(ptyId) + if (!pty) { + state.appended = '' + return + } + const nextWaitState = computeTerminalTailWaitState( + pty.tailBuffer, + pty.tailPartialLine, + pty.preview + ) + const previousWaitState = state.lastWaitState ?? { + waitText: '', + signal: null, + fromTail: false + } + if (tailGainedNewerBlockedReason(previousWaitState, nextWaitState, state.appended)) { + pty.waitBlockedAt = at + } + state.lastAt = at + state.lastWaitState = nextWaitState + state.appended = '' + } + + private clearWaitBlockedCheckState(ptyId: string): void { + const state = this.waitBlockedCheckStateByPtyId.get(ptyId) + if (state?.timer) { + clearTimeout(state.timer) + } + this.waitBlockedCheckStateByPtyId.delete(ptyId) + } + private processAgentStatusOscForPty(ptyId: string, data: string): ProcessedAgentStatusChunk { let processor = this.agentStatusOscProcessorsByPtyId.get(ptyId) if (!processor) { @@ -5646,19 +5799,439 @@ export class OrcaRuntimeService { return processor(data) } - private extractLastOscTitleForPty(ptyId: string, data: string): string | null { - const previousTail = this.oscTitleScanTailByPtyId.get(ptyId) - if (!previousTail && !data.includes('\x1b')) { + /** Emit the facts batched while applying one chunk/frame as a single + * pty:sideEffect batch, preserving byte order. */ + private flushPendingTerminalSideEffectFacts( + ptyId: string, + entry: RuntimePtyTitleTrackerEntry + ): void { + if (entry.pendingFacts.length === 0) { + return + } + const facts = entry.pendingFacts + entry.pendingFacts = [] + this.emitTerminalSideEffectBatch(ptyId, facts) + } + + /** Feed a main-fabricated OSC title/BEL frame (agent hook spinners) through + * the per-PTY tracker — NOT onPtyData, so emulator state, tails, + * transcripts, and stats never see synthetic bytes. Parsed via the + * tracker's stateless synthetic path: the shared chunk bell detector must + * never observe fabricated bytes, or a tick interleaved with a split real + * OSC corrupts its escape state (phantom/swallowed bells). While the + * side-effect kill switch is off the legacy pty:data copy still drives + * renderer parsers; this ingest keeps main's facts and records + * authoritative. */ + ingestSyntheticTitleFrame(ptyId: string, data: string): void { + const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + entry.applyingChunk = true + entry.applyingSyntheticFrame = true + entry.chunkTouchedSessionTabs = false + try { + entry.tracker.applySyntheticTitleFrame(data) + } finally { + entry.applyingChunk = false + entry.applyingSyntheticFrame = false + this.flushPendingTerminalSideEffectFacts(ptyId, entry) + } + if (entry.chunkTouchedSessionTabs) { + this.touchMobileSessionSnapshotsForPty(ptyId) + } + } + + /** Scan-authority handoff for a backgrounded PTY (daemon keep-tail + * thinning): while delegated, the daemon relays bell/133/pr-link/2031 + * facts itself and the delivered bytes may be gapped — feeding them to + * main's transient scanners would mint phantom or duplicate facts. Title + * processing stays main-side either way. */ + setPtyTransientFactDelegation(ptyId: string, delegated: boolean, scanSeedAnsi?: string): void { + const entry = this.getOrCreatePtyTitleTrackerEntry(ptyId) + entry.tracker.setTransientFactScanningSuppressed(delegated) + if (!delegated && scanSeedAnsi) { + // Prime the freshly reset scanner carry with the emulator's dangling + // incomplete escape at the handoff position — a sequence split across + // the un-background toggle must not mint a phantom bell or lose its + // fact. titleScanData:'' keeps titles out (they were never suppressed). + entry.tracker.handleChunk(scanSeedAnsi, { titleScanData: '' }) + } + } + + /** A transient fact the daemon detected while it held scan authority — + * emitted through the same fact channel as byte-scanned facts. Arrives + * between chunks, so recordTerminalSideEffectFact emits it immediately. */ + emitDaemonPtyTransientFact(ptyId: string, fact: PtyTransientFact): void { + switch (fact.kind) { + case 'bell': + this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) + return + case 'command-finished': + this.recordTerminalSideEffectFact(ptyId, { + kind: 'command-finished', + exitCode: fact.exitCode + }) + return + case 'pr-link': + this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link: fact.link }) + return + case '2031-subscribe': + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + } + } + + /** The daemon keep-tail dropped this PTY's oldest undelivered output; the + * next delivered chunk is discontinuous. Reset every cross-chunk parse + * carry so a half-open escape from before the gap cannot corrupt what + * follows, and drop the mobile headless mirror — it rebuilds from the + * delivered tail / snapshot seeds instead of parsing a gapped stream. */ + notePtyDataGap(ptyId: string, droppedChars = 0): void { + if (droppedChars > 0) { + // Why: the daemon snapshot's seq counts bytes its monitoring stream + // dropped. Advancing without parsing preserves that absolute domain so + // post-snapshot live chunks can be reconciled instead of duplicated. + const outputSequence = (this.ptyOutputSequenceById.get(ptyId) ?? 0) + droppedChars + this.ptyOutputSequenceById.set(ptyId, outputSequence) + } + const pty = this.getOrCreatePtyWorktreeRecord(ptyId) + if (pty) { + pty.tailPendingAnsi = '' + } + for (const leaf of this.getLeavesForPty(ptyId)) { + leaf.tailPendingAnsi = '' + } + this.oscTitleScanTailByPtyId.delete(ptyId) + this.osc7ScanTailByPtyId.delete(ptyId) + this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.disposeHeadlessTerminal(ptyId) + } + + /** Record one derived side-effect fact: batched per chunk while applying + * bytes, emitted immediately for between-chunk facts (stale-title timer). */ + private recordTerminalSideEffectFact(ptyId: string, fact: TerminalSideEffectFact): void { + if (!this.onTerminalSideEffects) { + return + } + const entry = this.ptyTitleTrackersByPtyId.get(ptyId) + if (entry?.applyingChunk) { + entry.pendingFacts.push(fact) + return + } + this.emitTerminalSideEffectBatch(ptyId, [fact]) + } + + private emitTerminalSideEffectBatch( + ptyId: string, + facts: TerminalSideEffectFact[], + options: { replay?: boolean } = {} + ): void { + if (!this.onTerminalSideEffects || facts.length === 0) { + return + } + const batch: TerminalSideEffectBatch = { + ptyId, + seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, + facts, + ...(options.replay ? { replay: true } : {}), + ...this.resolveTerminalSideEffectAttribution(ptyId) + } + try { + this.onTerminalSideEffects(batch) + } catch (err) { + console.error('[runtime] terminal side-effect listener threw', { ptyId, err }) + } + } + + /** Same attribution resolution as emitTerminalAgentStatusEvents: prefer the + * first mounted leaf, fall back to the spawn-time PTY record binding. */ + private resolveTerminalSideEffectAttribution(ptyId: string): { + worktreeId?: string + tabId?: string + paneKey?: string + connectionId?: string | null + } { + const pty = this.ptysById.get(ptyId) + const connectionId = pty?.connectionId ?? null + for (const leaf of this.getLeavesForPty(ptyId)) { + return { + worktreeId: leaf.worktreeId, + tabId: leaf.tabId, + paneKey: this.makeRuntimePaneKey(leaf), + connectionId + } + } + if (pty?.paneKey) { + return { + worktreeId: pty.worktreeId, + ...(pty.tabId ? { tabId: pty.tabId } : {}), + paneKey: pty.paneKey, + connectionId + } + } + return {} + } + + /** Title-only replay batch for renderer (re)attach — the no-attention-replay + * rule: snapshots restore title state, never historical bells/completions. */ + getTerminalSideEffectSnapshot(ptyId: string): TerminalSideEffectBatch | null { + const tracker = this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker + const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle + // Why: the cursor-agent literal drop applies to every title surface; a + // record-fallback snapshot must not replay the bare native title the + // tracker would have refused to emit live. + const rawTitle = recordTitle && !isCursorNativeAgentTitle(recordTitle) ? recordTitle : null + const normalizedTitle = tracker?.getLastNormalizedTitle() ?? null + if (normalizedTitle === null && !rawTitle) { return null } - const input = `${previousTail ?? ''}${data}` - const scanTail = extractOscTitleScanTail(input) - if (scanTail.length > 0) { - this.oscTitleScanTailByPtyId.set(ptyId, scanTail) - } else { - this.oscTitleScanTailByPtyId.delete(ptyId) + return { + ptyId, + seq: this.ptyOutputSequenceById.get(ptyId) ?? 0, + replay: true, + facts: [ + { + kind: 'title', + normalizedTitle: normalizedTitle ?? normalizeTerminalTitle(rawTitle!), + rawTitle: rawTitle ?? normalizedTitle! + } + ], + ...this.resolveTerminalSideEffectAttribution(ptyId) } - return extractLastOscTitle(input) + } + + /** Raw last title from main's tracked PTY/leaf records — the title surface + * the tracker (live bytes + synthetic frames) keeps current. */ + private getTrackedRawTitleForPty(ptyId: string): string | null { + const recordTitle = this.ptysById.get(ptyId)?.lastOscTitle + if (recordTitle) { + return recordTitle + } + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastOscTitle) { + return leaf.lastOscTitle + } + } + return null + } + + /** Why: synthetic agent title frames no longer ride pty:data, so neither + * renderer xterm nor the headless emulator observes them. Mobile-parity + * snapshot titles must prefer main's tracker over snapshot lastTitle, or + * hook-driven spinner/idle titles vanish from mobile tabs. */ + private preferTrackedLastTitle(ptyId: string, snapshot: T): T { + const tracked = this.getTrackedRawTitleForPty(ptyId) + if (!tracked) { + return snapshot + } + return { ...snapshot, lastTitle: tracked } + } + + /** Decorative comparison key: spinner frame glyphs stripped, derived agent + * status kept so a working→idle flip with an otherwise-equal label still + * counts as a change. */ + private makeMobileTitleGateKey(rawTitle: string, normalizedTitle: string): string { + return `${detectAgentStatusFromTitle(rawTitle) ?? ''}\u0000${stripBrailleSpinnerGlyphs( + normalizedTitle + )}` + } + + private getOrCreatePtyTitleTrackerEntry(ptyId: string): RuntimePtyTitleTrackerEntry { + const existing = this.ptyTitleTrackersByPtyId.get(ptyId) + if (existing) { + return existing + } + // Why: trackers are created lazily on the first observed chunk. After an + // app relaunch the PTY/leaf records can already hold a persisted title; a + // cold tracker would miss the parked working→idle completion and never + // arm the stale-title timer for a persisted 'working' title. + let initialTitle = this.ptysById.get(ptyId)?.lastOscTitle ?? null + if (initialTitle === null) { + for (const leaf of this.getLeavesForPty(ptyId)) { + if (leaf.lastOscTitle) { + initialTitle = leaf.lastOscTitle + break + } + } + } + const tracker = createTerminalTitleTracker( + { + onTitle: (normalizedTitle, rawTitle, meta) => { + this.recordTerminalSideEffectFact(ptyId, { + kind: 'title', + normalizedTitle, + rawTitle, + ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) + const changed = this.applyTrackedPtyTitle(ptyId, rawTitle, normalizedTitle) + if (!changed) { + return + } + const live = this.ptyTitleTrackersByPtyId.get(ptyId) + const gateKey = this.makeMobileTitleGateKey(rawTitle, normalizedTitle) + const decorativeOnly = live?.lastMobileTitleGateKey === gateKey + if (live) { + live.lastMobileTitleGateKey = gateKey + } + if (live?.applyingChunk) { + // Why: synthetic spinner ticks change only the braille glyph + // ~12.5x/sec; fanning out full mobile session snapshots per frame + // is pure churn. Raw lastOscTitle updates above stay cheap. + if (!(live.applyingSyntheticFrame && decorativeOnly)) { + live.chunkTouchedSessionTabs = true + } + } else { + // Stale-working-title timer path — fires between chunks, so the + // per-chunk batching in onPtyData cannot pick it up. + this.touchMobileSessionSnapshotsForPty(ptyId) + } + }, + // Why: agent transitions and bells become pty:sideEffect facts — + // main is the single byte parser for local/SSH PTYs; the renderer + // store handler decides what the facts mean (notification policy). + onAgentBecameWorking: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-working' }) + }, + onAgentBecameIdle: (title, meta) => { + this.recordTerminalSideEffectFact(ptyId, { + kind: 'agent-idle', + title, + ...(meta?.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : {}) + }) + }, + onAgentExited: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'agent-exited' }) + }, + // Why: bell/command-finished/pr-link/2031 facts exist only for the + // pty:sideEffect channel. Headless serve has no consumer, so skip the + // per-chunk bell walk and 133/URL/2031 scans entirely. + ...(this.onTerminalSideEffects + ? { + onBell: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'bell' }) + }, + onCommandFinished: (exitCode: number | null) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-finished', exitCode }) + }, + onPrLink: (link: TerminalGitHubPRLink) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'pr-link', link }) + }, + // Why: hidden-delivery-gated views never see the bytes, so main + // surfaces DECSET 2031 subscribes as facts; the theme reply is + // still sent by the renderer (query authority stays with the view). + onMode2031Subscribe: () => { + this.recordTerminalSideEffectFact(ptyId, { kind: '2031-subscribe' }) + } + } + : {}) + }, + initialTitle !== null ? { initialTitle } : {} + ) + const entry: RuntimePtyTitleTrackerEntry = { + tracker, + applyingChunk: false, + applyingSyntheticFrame: false, + lastMobileTitleGateKey: null, + chunkTouchedSessionTabs: false, + pendingFacts: [], + // Why: command-code facts exist only for the pty:sideEffect channel — + // headless serve skips the per-chunk scrape entirely. The detector + // self-arms on the Command Code banner; the spawn command (when main + // saw one) mirrors the renderer detector's startupCommand fast-arm. + commandCodeDetector: this.onTerminalSideEffects + ? createCommandCodeOutputStatusDetector({ + startupCommand: this.terminalSpawnCommandsByPtyId.get(ptyId) ?? null, + onWorking: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-working', prompt }) + }, + onDone: (prompt) => { + this.recordTerminalSideEffectFact(ptyId, { kind: 'command-code-done', prompt }) + } + }) + : null + } + this.ptyTitleTrackersByPtyId.set(ptyId, entry) + return entry + } + + /** Apply one observed OSC title (raw form) to the PTY and leaf records. + * Returns true when the PTY record's title or status changed. */ + private applyTrackedPtyTitle(ptyId: string, rawTitle: string, normalizedTitle: string): boolean { + // Why: status is detected from the RAW title (mirrors the renderer tracker), + // so working/idle transitions are unaffected by normalization; the records + // store the NORMALIZED title so rotating Grok/Pi/Gemini frames collapse to + // one stable stored label (#7880) instead of churning `ps`/mobile tabs. + const agentStatus = detectAgentStatusFromTitle(rawTitle) + let ptyRecordChanged = false + const pty = this.ptysById.get(ptyId) + if (pty) { + const prevStatus = pty.lastAgentStatus + const prevTitle = pty.lastOscTitle + const observedAt = this.nextTitleObservationSequence() + pty.lastOscTitle = normalizedTitle + pty.lastOscTitleAt = observedAt + pty.lastAgentStatus = agentStatus + this.setPtyManagementTitleFromObservedTitle(pty, normalizedTitle, observedAt) + ptyRecordChanged = prevTitle !== normalizedTitle || prevStatus !== agentStatus + if (agentStatus === 'idle' && prevStatus !== 'idle') { + this.resolvePtyTuiIdleWaiters(pty, ptyId) + } + const shouldDelayMobileSnapshot = + ptyRecordChanged && + this.shouldDelayPtyBackedMobileSnapshotForForegroundAgent(pty, normalizedTitle) + let foregroundRefresh: Promise | undefined + // Why: gate on an actual status transition — braille spinner frames + // mutate the title every tick, so probing per-title-change would stream + // a foreground query per frame during active work. + if (prevStatus !== agentStatus) { + foregroundRefresh = this.refreshPtyForegroundAgentFromController(ptyId, { + afterTitleObservation: observedAt + }) + } else if (shouldDelayMobileSnapshot) { + // Why: same-status compatible title changes can arrive before the + // foreground owner probe settles; publishing them would flicker. + foregroundRefresh = this.getPendingForegroundAgentRefreshForTitle(ptyId, observedAt) + } + if (foregroundRefresh && shouldDelayMobileSnapshot) { + // Why: report "unchanged" so the per-chunk batch skips the mobile + // snapshot fan-out; the delayed publish fires when the probe settles. + ptyRecordChanged = false + this.delayPtyBackedMobileSnapshotForForegroundAgent(ptyId, observedAt, foregroundRefresh) + } + } + for (const leaf of this.getLeavesForPty(ptyId)) { + // Why: keep the latest OSC title on the leaf so worktree.ps can + // recompute status from the live title each call. Without this, + // daemon-hosted terminals (no renderer pushing pane titles) had no + // way to clear a stale 'working' status after the agent exited and + // the shell took over the title — the stuck-spinner bug in #1437. + leaf.lastOscTitle = normalizedTitle + leaf.lastOscTitleAt = this.nextTitleObservationSequence() + const prevStatus = leaf.lastAgentStatus + // Why: when a new OSC title doesn't classify as an agent state (e.g. + // bare shell title after the agent exits), clear lastAgentStatus so + // it is no longer sticky. Tui-idle waiters that needed the previous + // 'idle' transition were already resolved at the moment of the + // transition below; only fresh waiters registered after the agent + // exits would observe the cleared value, and they correctly fall + // back to title-based detection / polling. + leaf.lastAgentStatus = agentStatus + // Why: resolve tui-idle on any transition TO idle (not just working→idle). + // Claude Code may skip "working" entirely on fast tasks, going null→idle, + // and the coordinator's tui-idle waiter would hang forever waiting for a + // working→idle transition that never comes. Permission→idle is excluded: + // it means the agent was blocked on user approval and the user said no, + // which isn't a task-completion signal. + if (agentStatus === 'idle' && prevStatus !== 'idle') { + this.resolveTuiIdleWaiters(leaf) + this.deliverPendingMessages(leaf) + } + } + return ptyRecordChanged + } + + /** Cancel the per-PTY title tracker (stale-title timer included) on PTY + * teardown so it cannot fire into pruned records. */ + private disposePtyTitleTracker(ptyId: string): void { + this.ptyTitleTrackersByPtyId.get(ptyId)?.tracker.dispose() + this.ptyTitleTrackersByPtyId.delete(ptyId) } private extractLastOsc7CwdForPty( @@ -5845,6 +6418,53 @@ export class OrcaRuntimeService { return addListenerToMap(this.dataListeners, ptyId, listener) } + /** Set by pty IPC: fires when a PTY gains/loses remote view subscribers so + * the daemon background mark (keep-tail stream thinning) can resync — a + * live mobile/web view consumes raw bytes and must never be thinned, even + * while the desktop pane is hidden. */ + onRemoteTerminalViewPresenceChanged: ((ptyId: string) => void) | null = null + + private notifyRemoteTerminalViewPresenceChanged(ptyId: string): void { + try { + this.onRemoteTerminalViewPresenceChanged?.(ptyId) + } catch (err) { + console.error('[runtime] remote view presence listener threw', { ptyId, err }) + } + } + + /** Registered by terminal-RPC subscribe/multiplex streams: while a remote + * view subscriber is attached its xterm answers queries with view + * authority and the model responder must stay silent. Returns an + * idempotent release. */ + registerRemoteTerminalViewSubscriber(ptyId: string): () => void { + this.remoteTerminalViewSubscriberCounts.set( + ptyId, + (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) + 1 + ) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + let released = false + return () => { + if (released) { + return + } + released = true + const next = (this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 1) - 1 + if (next <= 0) { + this.remoteTerminalViewSubscriberCounts.delete(ptyId) + } else { + this.remoteTerminalViewSubscriberCounts.set(ptyId, next) + } + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + } + } + + hasRemoteTerminalViewSubscriber(ptyId: string): boolean { + if ((this.remoteTerminalViewSubscriberCounts.get(ptyId) ?? 0) > 0) { + return true + } + return (this.mobileSubscribers.get(ptyId)?.size ?? 0) > 0 + } + subscribeToFitOverrideChanges( ptyId: string, listener: (event: { mode: 'mobile-fit' | 'desktop-fit'; cols: number; rows: number }) => void @@ -5884,6 +6504,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string } | null> { return this.serializeTerminalBufferFromAvailableState(ptyId, opts) @@ -5902,6 +6523,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + scrollbackAnsi?: string } | null> { return this.serializeHeadlessTerminalBuffer(ptyId, { ...opts, includeEmpty: true }) } @@ -5919,6 +6541,7 @@ export class OrcaRuntimeService { source?: 'headless' | 'renderer' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string } | null> { const headlessSnapshot = await this.serializeHeadlessTerminalBuffer(ptyId, { @@ -5986,17 +6609,22 @@ export class OrcaRuntimeService { return } const dims = size ?? this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state: RuntimeHeadlessTerminal = { - emulator: this.createHeadlessEmulator(ptyId, { cols: dims.cols, rows: dims.rows }), - outputSequence: 0, - writeChain: Promise.resolve() - } + const state = this.createPtyHeadlessTerminalState(ptyId, dims) this.headlessTerminals.set(ptyId, state) this.recordOsc7MetadataForPty(ptyId, data) this.recordRecentPtyOutputForPathProvenance(ptyId, data) state.writeChain = state.writeChain .then(async () => { + // Why: seed writes never set forwardQueryReplies — the main-side + // replay guard. A snapshot containing old queries must answer no one. await state.emulator.write(data) + // Why AFTER the seed write: the snapshot payload cannot carry kitty + // pushes (rehydrateSequences deliberately omits them), but ordering + // behind it keeps the parse deterministic. Unflagged like the seed — + // re-applying flags must answer no one. + if (typeof metadata.kittyKeyboardFlags === 'number') { + await state.emulator.applyKittyKeyboardFlags(metadata.kittyKeyboardFlags) + } if (metadata.cwd !== undefined) { state.emulator.setCwd(metadata.cwd) } @@ -6037,11 +6665,9 @@ export class OrcaRuntimeService { this.headlessHydrationState.set(ptyId, 'pending') const dims = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state: RuntimeHeadlessTerminal = { - emulator: this.createHeadlessEmulator(ptyId, { cols: dims.cols, rows: dims.rows }), - outputSequence: 0, - writeChain: Promise.resolve() - } + // Why: hydration writes below never set forwardQueryReplies (main-side + // replay guard) — renderer-buffer snapshots can embed stale queries. + const state = this.createPtyHeadlessTerminalState(ptyId, dims) this.headlessTerminals.set(ptyId, state) // Why: append the seed work to writeChain so live writes queued by @@ -6071,9 +6697,14 @@ export class OrcaRuntimeService { if (ptyDims && (ptyDims.cols !== rendered.cols || ptyDims.rows !== rendered.rows)) { state.emulator.resize(ptyDims.cols, ptyDims.rows) } - if (rendered.lastTitle) { - state.emulator.setLastTitle(rendered.lastTitle) - this.applySeededAgentStatus(ptyId, rendered.lastTitle) + // Why: the renderer xterm no longer sees synthetic hook title frames + // (they feed main's tracker only), so its serializer lastTitle can be + // stale here. Prefer main's tracked title; the renderer's is only the + // seed when main has observed none (fresh relaunch, cold tracker). + const seedTitle = this.getTrackedRawTitleForPty(ptyId) ?? rendered.lastTitle + if (seedTitle) { + state.emulator.setLastTitle(seedTitle) + this.applySeededAgentStatus(ptyId, seedTitle) } } catch { // Hydration is best-effort. Live writes continue via the same @@ -6094,6 +6725,11 @@ export class OrcaRuntimeService { if (!title) { return } + // Why: a relaunched main starts its per-PTY title tracker cold — without + // this seed it misses the parked working→idle completion and never arms + // the stale-title timer for a persisted 'working' title. Seeding no-ops + // once a live title was observed, so live state always wins. + this.getOrCreatePtyTitleTrackerEntry(ptyId).tracker.seedInitialTitle(title) const status = detectAgentStatusFromTitle(title) // Why: live observations store normalized titles, so seeds must match — // otherwise the first live frame after hydration compares unequal and @@ -6118,11 +6754,28 @@ export class OrcaRuntimeService { } } - private trackHeadlessTerminalData(ptyId: string, data: string, outputSequence: number): void { + /** Per-chunk reply-ownership capture (Phase 5). Evaluated synchronously at + * ingestion only — never re-read at reply time. */ + private shouldAnswerQueriesForLiveChunk(ptyId: string): boolean { + return shouldModelAnswerHiddenPtyQueries({ + ptyId, + settings: this.store?.getSettings(), + hasRemoteViewSubscriber: this.hasRemoteTerminalViewSubscriber(ptyId) + }) + } + + private trackHeadlessTerminalData( + ptyId: string, + data: string, + outputSequence: number, + forwardQueryReplies = false + ): void { const state = this.getOrCreateHeadlessTerminal(ptyId) state.writeChain = state.writeChain .then(async () => { - await state.emulator.write(data) + // Why: the ingestion-time ownership decision is closed over this + // chain link; async scheduling cannot retroactively change it. + await state.emulator.write(data, { forwardQueryReplies }) state.outputSequence = outputSequence }) .catch(() => { @@ -6131,17 +6784,69 @@ export class OrcaRuntimeService { }) } + /** Shared factory for the per-PTY runtime emulators (seed, hydration, and + * lazy live-byte creation): wires the Phase-5 query-reply sink and the + * ConPTY DA1 override. The daemon emulator never goes through here. */ + private createPtyHeadlessTerminalState( + ptyId: string, + dims: { cols: number; rows: number } + ): RuntimeHeadlessTerminal { + let state: RuntimeHeadlessTerminal | null = null + const pathFlavor = this.pathFlavorForPty(this.ptysById.get(ptyId)) + const emulator = new HeadlessEmulator({ + cols: dims.cols, + rows: dims.rows, + pathFlavor, + remotePosixFileUriAuthority: + !!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32', + // Why: replies take the provider input path (same entry as pty:write — + // daemon shell-ready gating and the SSH relay write apply unchanged), + // NOT writePtyInput, so renderer interactive-output metering never + // counts responder traffic as user-input echo. + onQueryReply: (reply) => { + // Why the identity check: queued writeChain links can parse after + // disposeHeadlessTerminal, and daemon respawns reuse session ids — a + // stale link's reply must never reach a successor PTY under this id. + if (state !== null && this.headlessTerminals.get(ptyId) === state) { + // Why this write is safe pre-shell-ready: daemon Session.write + // QUEUES (never drops) input while the POSIX shell-ready gate is + // pending and flushes at the ready marker or the 15s + // SHELL_READY_TIMEOUT_MS bound (session.ts) — a spawn-time query + // reply is delayed at most that bound, not lost. + this.ptyController?.write(ptyId, reply) + } + } + }) + if (isNativeWindowsConptyPty(ptyId)) { + emulator.installConptyPrimaryDeviceAttributesOverride() + } + // Why the lazy getter: replies must use the freshest renderer push at + // parse time, and stay silent (never default) before the first push. + emulator.installViewAttributeResponder(() => getTerminalViewAttributes()) + const viewAttributes = getTerminalViewAttributes() + if (viewAttributes) { + emulator.applyPushedViewAttributes(viewAttributes) + } + state = { emulator, outputSequence: 0, writeChain: Promise.resolve() } + return state + } + + /** Phase-5 ConPTY DA1 retrofit (terminal-query-authority.md): invoked via + * markNativeWindowsConptyPty when the spawn mark lands after daemon stream + * data already created this PTY's emulator. Idempotent emulator-side. */ + private ensureNativeWindowsConptyDa1Override(ptyId: string): void { + if (isNativeWindowsConptyPty(ptyId)) { + this.headlessTerminals.get(ptyId)?.emulator.installConptyPrimaryDeviceAttributesOverride() + } + } + private getOrCreateHeadlessTerminal(ptyId: string): RuntimeHeadlessTerminal { const existing = this.headlessTerminals.get(ptyId) if (existing) { return existing } const size = this.getTerminalSize(ptyId) ?? { cols: 80, rows: 24 } - const state: RuntimeHeadlessTerminal = { - emulator: this.createHeadlessEmulator(ptyId, { cols: size.cols, rows: size.rows }), - outputSequence: 0, - writeChain: Promise.resolve() - } + const state = this.createPtyHeadlessTerminalState(ptyId, size) this.headlessTerminals.set(ptyId, state) return state } @@ -6164,19 +6869,6 @@ export class OrcaRuntimeService { }) } - private createHeadlessEmulator( - ptyId: string, - options: Omit - ): HeadlessEmulator { - const pathFlavor = this.pathFlavorForPty(this.ptysById.get(ptyId)) - return new HeadlessEmulator({ - ...options, - pathFlavor, - remotePosixFileUriAuthority: - !!this.ptysById.get(ptyId)?.connectionId && pathFlavor !== 'win32' - }) - } - // Public: desktop-initiated clears (ipc/pty.ts) must also drop this mobile // mirror or a resubscribing mobile client resurrects the cleared scrollback. async clearHeadlessTerminalBuffer(ptyId: string): Promise { @@ -6248,11 +6940,11 @@ export class OrcaRuntimeService { // their existing null fallback paths. } return rendererSnapshot - ? { + ? this.preferTrackedLastTitle(ptyId, { ...rendererSnapshot, cwd: rendererSnapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), - source: 'renderer' - } + source: 'renderer' as const + }) : null } @@ -6322,9 +7014,10 @@ export class OrcaRuntimeService { source?: 'headless' oscLinks?: TerminalOscLinkRange[] alternateScreen?: boolean + scrollbackAnsi?: string // Why: dangling mid-escape tail the restorer must write LAST, after any // reset, so the next live chunk completes it instead of rendering it - // literally (#7329). + // literally (Bug E / #7329). pendingEscapeTailAnsi?: string } | null> { const state = this.headlessTerminals.get(ptyId) @@ -6332,36 +7025,35 @@ export class OrcaRuntimeService { return null } await state.writeChain - // Why: when an alternate-screen TUI (Claude Code, vim, etc.) is currently - // active, the visible content is the alt-screen snapshot — replaying any - // normal-buffer scrollback before it can duplicate shell prompts and - // flatten SGR attributes when the mobile xterm replays the data. Force - // scrollbackRows=0 in that case. When the buffer is in normal mode the - // caller can request scrollback so the user can scroll up to see prior - // agent output. - const requested = opts.scrollbackRows ?? 0 + // Why: normal history is separated from an active alternate frame, so the + // caller's scrollback policy can be honored without painting it into alt. const isAlternateScreen = state.emulator.isAlternateScreen - const scrollbackRows = isAlternateScreen ? 0 : requested + const scrollbackRows = opts.scrollbackRows ?? 0 const snapshot = state.emulator.getSnapshot({ scrollbackRows }) const data = snapshot.rehydrateSequences + snapshot.snapshotAnsi return data.length > 0 || opts.includeEmpty === true - ? { + ? this.preferTrackedLastTitle(ptyId, { data, cols: snapshot.cols, rows: snapshot.rows, cwd: snapshot.cwd ?? this.terminalCwdByPtyId.get(ptyId), lastTitle: snapshot.lastTitle, seq: state.outputSequence, - source: 'headless', + source: 'headless' as const, oscLinks: snapshot.oscLinks, + scrollbackAnsi: snapshot.scrollbackAnsi, ...(snapshot.pendingEscapeTailAnsi ? { pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi } : {}), // Why: lets the renderer skip the destructive scrollback clear when // restoring an alt-screen snapshot — clearing wipes xterm's own // history that the TUI relies on for scroll-up after a tab return. - alternateScreen: isAlternateScreen - } + alternateScreen: isAlternateScreen, + // Why NOT folded into data: the renderer writes its post-replay + // reset after data, and any ESC after a dangling partial aborts it. + // The restorer writes this last (Bug E fix). + pendingEscapeTailAnsi: snapshot.pendingEscapeTailAnsi + }) : null } @@ -6372,6 +7064,10 @@ export class OrcaRuntimeService { return } this.headlessTerminals.delete(ptyId) + // Why: queued chain links still parse below before the emulator disposes; + // sever the reply sink now so they cannot write to a respawned PTY that + // reused this id (belt to the sink's state-identity check). + state.emulator.disableQueryReplyForwarding() state.writeChain.finally(() => state.emulator.dispose()).catch(() => state.emulator.dispose()) } @@ -7172,6 +7868,7 @@ export class OrcaRuntimeService { ? { cols: subscriber.previousCols, rows: subscriber.previousRows } : null inner.delete(clientId) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) if (inner.size > 0) { ptysWithSurvivingPeers.push(ptyId) } else { @@ -7256,13 +7953,17 @@ export class OrcaRuntimeService { serveSimStateWatcher.unbindPty(ptyId) // Clean up new mobile state for this PTY this.mobileSubscribers.delete(ptyId) + this.remoteTerminalViewSubscriberCounts.delete(ptyId) this.mobileDisplayModes.delete(ptyId) this.resizeListeners.delete(ptyId) this.lastRendererSizes.delete(ptyId) this.recentPtyOutputById.delete(ptyId) + this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.terminalSpawnCommandsByPtyId.delete(ptyId) + this.disposePtyTitleTracker(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) @@ -7985,6 +8686,20 @@ export class OrcaRuntimeService { ptyId: string, clientId: string, viewport?: { cols: number; rows: number } + ): Promise { + try { + return await this.handleMobileSubscribeInternal(ptyId, clientId, viewport) + } finally { + // Every subscribe path mutates mobileSubscribers — resync the daemon + // background mark once, whatever branch returned. + this.notifyRemoteTerminalViewPresenceChanged(ptyId) + } + } + + private async handleMobileSubscribeInternal( + ptyId: string, + clientId: string, + viewport?: { cols: number; rows: number } ): Promise { const mode = this.getMobileDisplayMode(ptyId) @@ -8157,6 +8872,7 @@ export class OrcaRuntimeService { const wasResizedToPhone = subscriber.wasResizedToPhone inner.delete(clientId) + this.notifyRemoteTerminalViewPresenceChanged(ptyId) if (inner.size > 0) { // Why: if the leaving client was the only one with a non-null restore @@ -18861,9 +19577,12 @@ export class OrcaRuntimeService { serveSimStateWatcher.unbindPty(ptyId) this.ptysById.delete(ptyId) this.recentPtyOutputById.delete(ptyId) + this.clearWaitBlockedCheckState(ptyId) this.recentPtyPathCandidatesById.delete(ptyId) this.ptyOutputSequenceById.delete(ptyId) this.agentStatusOscProcessorsByPtyId.delete(ptyId) + this.terminalSpawnCommandsByPtyId.delete(ptyId) + this.disposePtyTitleTracker(ptyId) this.oscTitleScanTailByPtyId.delete(ptyId) this.osc7ScanTailByPtyId.delete(ptyId) this.terminalCwdByPtyId.delete(ptyId) @@ -23355,6 +24074,14 @@ export class OrcaRuntimeService { } } +const WAIT_BLOCKED_CHECK_MIN_INTERVAL_MS = 50 +// Why: chunks that can complete an actionable prompt bypass the throttle so +// blocked stamps stay per-chunk-immediate; the pattern heads mirror +// findTerminalWaitBlockedSignal. Scanned over the new chunk plus a short +// carry only — never the accumulated window. +const WAIT_BLOCKED_KEYWORD_PATTERN = + /press enter|press t to trust|do you trust|trust this|trusted workspace|update available|choose working directory|codex just got an upgrade|hooks need review/ +const WAIT_BLOCKED_KEYWORD_CARRY_CHARS = 31 const MAX_TAIL_LINES = 2000 const MAX_TAIL_CHARS = 256 * 1024 const MAX_TAIL_PARTIAL_CHARS = 4000 @@ -23918,6 +24645,30 @@ function trimTerminalLineRight(line: string): string { return end === line.length ? line : line.slice(0, end) } +// Why a window: the unwindowed implementation below materializes a row object +// per retained tail line and finalize re-allocates + regex-trims every row — +// O(tail) per chunk (~0.9ms at the 2,000-line cap), measured at ~93% of the +// main-process event loop under an agent-TUI flood (findings log 2026-07-03). +// A redraw can only touch rows the cursor can reach, so run the algorithm on +// a suffix window sized by the chunk's maximum upward cursor excursion and +// share the untouched prefix by reference. Equality with the unwindowed +// implementation is fuzz-verified in +// retained-tail-redraw-window.equivalence.test.ts. +const REDRAW_WINDOW_SAFETY_ROWS = 8 + +function maxUpwardCursorReach( + normalizedChunk: string, + previousRedrawCursor: RetainedTailRedrawCursor | null +): number { + let reach = previousRedrawCursor ? previousRedrawCursor.rowFromEnd : 0 + const cursorUpPattern = /\x1b\[(\d*)(?:;[\d;]*)?A/g + let match: RegExpExecArray | null + while ((match = cursorUpPattern.exec(normalizedChunk)) !== null) { + reach += match[1] ? Number.parseInt(match[1], 10) : 1 + } + return reach +} + function appendNormalizedToMultilineTailBuffer( previousLines: string[], boundedPreviousPartialLine: string, @@ -23930,6 +24681,79 @@ function appendNormalizedToMultilineTailBuffer( redrawCursor: RetainedTailRedrawCursor | null truncated: boolean newCompleteLines: number +} { + const windowRows = + maxUpwardCursorReach(normalizedChunk, previousRedrawCursor) + REDRAW_WINDOW_SAFETY_ROWS + if (windowRows >= previousLines.length) { + return appendNormalizedToMultilineTailBufferUnwindowed( + previousLines, + boundedPreviousPartialLine, + normalizedChunk, + previousPartialWasCapped, + previousRedrawCursor + ) + } + const prefixLength = previousLines.length - windowRows + const suffix = previousLines.slice(prefixLength) + const windowed = appendNormalizedToMultilineTailBufferUnwindowed( + suffix, + boundedPreviousPartialLine, + normalizedChunk, + previousPartialWasCapped, + previousRedrawCursor + ) + let lines = previousLines.slice(0, prefixLength) + // Why: the unwindowed finalize trims trailing spaces/tabs on every row; the + // shared prefix must match without paying a regex per untouched row. + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]! + const lastChar = line.charCodeAt(line.length - 1) + if (lastChar === 32 || lastChar === 9) { + lines[index] = line.replace(/[ \t]+$/g, '') + } + } + for (const line of windowed.lines) { + lines.push(line) + } + let truncated = windowed.truncated + if (lines.length > MAX_TAIL_LINES) { + lines = lines.slice(lines.length - MAX_TAIL_LINES) + truncated = true + } + let totalChars = windowed.partialLine.length + for (const line of lines) { + totalChars += line.length + } + let dropCount = 0 + while (dropCount < lines.length && totalChars > MAX_TAIL_CHARS) { + totalChars -= lines[dropCount]!.length + dropCount += 1 + } + if (dropCount > 0) { + lines = lines.slice(dropCount) + truncated = true + } + return { + lines, + partialLine: windowed.partialLine, + redrawCursor: windowed.redrawCursor, + truncated, + newCompleteLines: windowed.newCompleteLines + } +} + +export function appendNormalizedToMultilineTailBufferUnwindowed( + previousLines: string[], + boundedPreviousPartialLine: string, + normalizedChunk: string, + previousPartialWasCapped: boolean, + previousRedrawCursor: RetainedTailRedrawCursor | null +): { + lines: string[] + partialLine: string + redrawCursor: RetainedTailRedrawCursor | null + truncated: boolean + newCompleteLines: number } { const rows: RetainedTerminalRow[] = [ ...previousLines.map((line) => ({ text: line, completed: true })), diff --git a/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts new file mode 100644 index 00000000000..32685f93b53 --- /dev/null +++ b/src/main/runtime/retained-tail-redraw-window.equivalence.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest' +import { + appendNormalizedToTailBuffer, + appendNormalizedToMultilineTailBufferUnwindowed +} from './orca-runtime' + +// Differential guard for the windowed redraw tail path: the public +// appendNormalizedToTailBuffer routes vertical-control chunks through a +// suffix-windowed wrapper (findings log 2026-07-03 — the unwindowed path was +// O(tail) per chunk and dominated main's event loop under agent-TUI floods). +// This fuzz asserts the windowed result is byte-identical to the reference +// implementation across randomized tails and redraw chunks. + +function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +function randomTail(rng: () => number, maxLines: number): string[] { + const count = Math.floor(rng() * maxLines) + return Array.from({ length: count }, (_, i) => { + const base = `line ${i} ${'x'.repeat(Math.floor(rng() * 40))}` + // Trailing whitespace included deliberately: the reference implementation + // trims every row on each call, so the windowed prefix must match. + return rng() < 0.3 ? `${base} ` : base + }) +} + +function randomRedrawChunk(rng: () => number): string { + const parts: string[] = [] + const ops = 1 + Math.floor(rng() * 12) + for (let i = 0; i < ops; i++) { + const roll = rng() + if (roll < 0.2) { + parts.push(`\x1b[${1 + Math.floor(rng() * 12)}A`) + } else if (roll < 0.3) { + parts.push(`\x1b[${Math.floor(rng() * 3)}J`) + } else if (roll < 0.4) { + parts.push(`\x1b[${Math.floor(rng() * 3)}K`) + } else if (roll < 0.5) { + parts.push('\r') + } else if (roll < 0.6) { + parts.push(`\x1b[${1 + Math.floor(rng() * 30)}G`) + } else if (roll < 0.7) { + parts.push('\n') + } else if (roll < 0.75) { + parts.push('') + } else { + parts.push(`text${Math.floor(rng() * 100)} ${'y'.repeat(Math.floor(rng() * 20))}`) + } + } + return parts.join('') +} + +describe('windowed redraw tail equivalence', () => { + it('matches the unwindowed reference across 500 randomized cases', () => { + const rng = mulberry32(42) + for (let round = 0; round < 500; round++) { + const tail = randomTail(rng, round % 5 === 0 ? 2100 : 300) + const partial = rng() < 0.5 ? `partial ${'z'.repeat(Math.floor(rng() * 30))}` : '' + const redrawCursor = + rng() < 0.3 ? { rowFromEnd: Math.floor(rng() * 20), column: Math.floor(rng() * 40) } : null + // Why the guaranteed cursor-up: the public function routes to the + // multiline (windowed) path only for vertical-control chunks; chunks + // without one take the single-line fast path, which is out of scope. + const chunk = `\x1b[${1 + Math.floor(rng() * 4)}A${randomRedrawChunk(rng)}` + + const actual = appendNormalizedToTailBuffer(tail, partial, chunk, redrawCursor) + // Reference path over the full tail. + const expected = appendNormalizedToMultilineTailBufferUnwindowed( + tail, + partial.slice(-4000), + chunk, + partial.length > 4000, + redrawCursor + ) + + expect(actual.lines, `round ${round} lines`).toEqual(expected.lines) + expect(actual.partialLine, `round ${round} partial`).toBe(expected.partialLine) + expect(actual.redrawCursor, `round ${round} cursor`).toEqual(expected.redrawCursor) + expect(actual.truncated, `round ${round} truncated`).toBe(expected.truncated) + expect(actual.newCompleteLines, `round ${round} newLines`).toBe(expected.newCompleteLines) + } + }) +}) diff --git a/src/main/runtime/rpc/methods/terminal.ts b/src/main/runtime/rpc/methods/terminal.ts index ff553f812a7..73c77701c97 100644 --- a/src/main/runtime/rpc/methods/terminal.ts +++ b/src/main/runtime/rpc/methods/terminal.ts @@ -36,6 +36,9 @@ const TERMINAL_STREAM_CHUNK_BYTES = 48 * 1024 const TERMINAL_OUTPUT_FLUSH_MS = 5 // Why: output batches become binary stream payloads; byte size is the transport cost. const TERMINAL_OUTPUT_BATCH_MAX_BYTES = 64 * 1024 +// Why: remote clients can apply output pressure without pausing runtime PTY ingestion. +const TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES = 512 * 1024 +const TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES = 2 * 1024 * 1024 // Why: pending output is held for later binary frames, so cap the encoded // payload bytes rather than UTF-16 code units. const TERMINAL_MULTIPLEX_PENDING_MAX_BYTES = 256 * 1024 @@ -60,6 +63,7 @@ type SnapshotFrameOptions = { type SerializedSnapshot = { data: string + scrollbackAnsi?: string cols: number rows: number seq?: number @@ -82,7 +86,13 @@ type TerminalMultiplexStream = { ptyId: string client: TerminalViewportClient | undefined isMobile: boolean + ackOutput: boolean + ackInFlightBytes: number buffering: boolean + ackPendingOutput: TerminalOutputFrameChunk[] + ackPendingOutputBytes: number + ackPendingOutputOverflowed: boolean + ackRecoverySnapshotInFlight: boolean pendingOutput: TerminalOutputChunk[] pendingOutputBytes: number pendingOutputOverflowed: boolean @@ -362,6 +372,26 @@ function getOutputAfterSnapshotSeq( return chunk.data.slice(snapshotSeq - chunkStartSeq) } +function appendAckPendingOutput( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk +): void { + stream.ackPendingOutput.push(chunk) + stream.ackPendingOutputBytes += chunk.bytes.byteLength + let omittedChunkCount = 0 + while ( + stream.ackPendingOutputBytes > TERMINAL_MULTIPLEX_PENDING_MAX_BYTES && + omittedChunkCount < stream.ackPendingOutput.length + ) { + stream.ackPendingOutputBytes -= stream.ackPendingOutput[omittedChunkCount]!.bytes.byteLength + omittedChunkCount += 1 + } + if (omittedChunkCount > 0) { + stream.ackPendingOutput.splice(0, omittedChunkCount) + stream.ackPendingOutputOverflowed = true + } +} + function trimPendingOutputToBudget( pendingOutput: TerminalOutputChunk[], pendingOutputBytes: number @@ -388,6 +418,43 @@ function measureTerminalStreamByteLength( return measureClipboardTextByteLength(data, options) } +function trimPendingOutputCoveredBySnapshot( + pendingOutput: TerminalOutputChunk[], + snapshotSeq: number | undefined +): { chunks: TerminalOutputChunk[]; bytes: number } { + if (typeof snapshotSeq !== 'number') { + return { + chunks: pendingOutput, + bytes: pendingOutput.reduce((sum, chunk) => sum + chunk.bytes, 0) + } + } + const chunks: TerminalOutputChunk[] = [] + let bytes = 0 + for (const chunk of pendingOutput) { + const chunkSeq = chunk.meta?.seq + const rawLength = chunk.meta?.rawLength ?? chunk.data.length + if (typeof chunkSeq !== 'number' || rawLength !== chunk.data.length) { + chunks.push(chunk) + bytes += chunk.bytes + continue + } + const startSeq = chunkSeq - rawLength + if (snapshotSeq >= chunkSeq) { + continue + } + if (snapshotSeq <= startSeq) { + chunks.push(chunk) + bytes += chunk.bytes + continue + } + const data = chunk.data.slice(snapshotSeq - startSeq) + const slicedBytes = terminalStreamByteLength(data) + chunks.push({ data, bytes: slicedBytes, meta: undefined }) + bytes += slicedBytes + } + return { chunks, bytes } +} + function terminalStreamByteLength(data: string): number { return measureTerminalStreamByteLength(data).byteLength } @@ -436,13 +503,12 @@ async function serializeBudgetedRequestedSnapshot( if (!serialized) { return null } - const overByteBudget = terminalStreamByteLengthExceeds( - serialized.data, - REQUESTED_SNAPSHOT_BYTE_BUDGET - ) + const data = (serialized.scrollbackAnsi ?? '') + serialized.data + const overByteBudget = terminalStreamByteLengthExceeds(data, REQUESTED_SNAPSHOT_BYTE_BUDGET) if (!overByteBudget || rows === 0) { return { ...serialized, + data, scrollbackRows: rows, truncatedByByteBudget: rows < requestedRows || overByteBudget } @@ -491,7 +557,14 @@ async function serializeBudgetedMobileSnapshot( ): Promise { if (!isMobile) { const serialized = await runtime.serializeTerminalBuffer(ptyId, { scrollbackRows: 0 }) - return serialized ? { ...serialized, scrollbackRows: 0, truncatedByByteBudget: false } : null + return serialized + ? { + ...serialized, + data: (serialized.scrollbackAnsi ?? '') + serialized.data, + scrollbackRows: 0, + truncatedByByteBudget: false + } + : null } const candidates = [MOBILE_SUBSCRIBE_SCROLLBACK_ROWS, 500, 250, 100, 25, 0] for (const rows of candidates) { @@ -499,13 +572,12 @@ async function serializeBudgetedMobileSnapshot( if (!serialized) { return null } - const overByteBudget = terminalStreamByteLengthExceeds( - serialized.data, - MOBILE_SNAPSHOT_BYTE_BUDGET - ) + const data = (serialized.scrollbackAnsi ?? '') + serialized.data + const overByteBudget = terminalStreamByteLengthExceeds(data, MOBILE_SNAPSHOT_BYTE_BUDGET) if (!overByteBudget || rows === 0) { return { ...serialized, + data, scrollbackRows: rows, truncatedByByteBudget: rows < MOBILE_SUBSCRIBE_SCROLLBACK_ROWS || overByteBudget } @@ -749,7 +821,16 @@ const TerminalMultiplexSubscribeFrame = TerminalHandle.extend({ type: z.enum(['mobile', 'desktop']).default('desktop') }) .optional(), - viewport: TerminalViewport.optional() + viewport: TerminalViewport.optional(), + capabilities: z + .object({ + ackOutput: z.literal(1).optional() + }) + .optional() +}) + +const TerminalMultiplexAckFrame = z.object({ + bytes: z.number().int().nonnegative() }) const TerminalMultiplexSnapshotRequestFrame = z.object({ @@ -1224,6 +1305,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ let closed = false let cursor = 0 const streams = new Map() + let ackTotalInFlightBytes = 0 let resolveMultiplex = (): void => {} const multiplexClosed = new Promise((resolve) => { resolveMultiplex = resolve @@ -1269,6 +1351,136 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ }) ) } + const canSendAckGatedOutput = (stream: TerminalMultiplexStream, bytes: number): boolean => { + if (!stream.ackOutput) { + return true + } + return ( + stream.ackInFlightBytes + bytes <= TERMINAL_MULTIPLEX_ACK_STREAM_HIGH_WATER_BYTES && + ackTotalInFlightBytes + bytes <= TERMINAL_MULTIPLEX_ACK_TOTAL_HIGH_WATER_BYTES + ) + } + const sendAckGatedOutput = ( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk + ): void => { + sendFrame(stream.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) + if (stream.ackOutput) { + stream.ackInFlightBytes += chunk.bytes.byteLength + ackTotalInFlightBytes += chunk.bytes.byteLength + } + } + const queueOrSendOutput = ( + stream: TerminalMultiplexStream, + chunk: TerminalOutputFrameChunk + ): void => { + if (closed || streams.get(stream.streamId) !== stream) { + return + } + if ( + stream.ackPendingOutputOverflowed || + stream.ackPendingOutput.length > 0 || + !canSendAckGatedOutput(stream, chunk.bytes.byteLength) + ) { + appendAckPendingOutput(stream, chunk) + return + } + sendAckGatedOutput(stream, chunk) + } + const sendAckRecoverySnapshot = async (stream: TerminalMultiplexStream): Promise => { + if ( + closed || + streams.get(stream.streamId) !== stream || + stream.ackRecoverySnapshotInFlight + ) { + return + } + stream.ackRecoverySnapshotInFlight = true + try { + const serialized = await serializeBudgetedRequestedSnapshot(runtime, stream.ptyId, 0) + if (closed || streams.get(stream.streamId) !== stream) { + return + } + const size = runtime.getTerminalSize(stream.ptyId) + const displayMode = runtime.getMobileDisplayMode(stream.ptyId) + // Why: dropped ACK-pending output means live frames are no longer a + // complete replay. Send a fresh model snapshot before resuming output. + // Why: truncated marks an unusable snapshot, and clients discard + // those. The recovery snapshot must be applied to cover dropped + // output, so it is only truncated when serialization failed. + sendSnapshotFrames((opcode, payload) => sendFrame(stream.streamId, opcode, payload), { + kind: 'scrollback', + cols: serialized?.cols ?? size?.cols ?? 80, + rows: serialized?.rows ?? size?.rows ?? 24, + displayMode, + reason: 'ack-pending-overflow', + seq: serialized?.seq, + source: serialized?.source, + truncated: !serialized, + truncatedByByteBudget: serialized?.truncatedByByteBudget, + data: serialized?.data ?? '' + }) + if (serialized && typeof serialized.seq === 'number') { + // Why: retained chunks queued before the snapshot serialized are + // already contained in it; replaying them would duplicate output. + const snapshotSeq = serialized.seq + const retained = stream.ackPendingOutput.filter( + (chunk) => !(typeof chunk.seq === 'number' && chunk.seq <= snapshotSeq) + ) + stream.ackPendingOutput = retained + stream.ackPendingOutputBytes = retained.reduce( + (total, chunk) => total + chunk.bytes.byteLength, + 0 + ) + } + stream.ackPendingOutputOverflowed = false + } catch (error) { + sendStreamError( + stream.streamId, + error instanceof Error ? error.message : 'Remote terminal recovery snapshot failed.' + ) + } finally { + if (streams.get(stream.streamId) === stream) { + stream.ackRecoverySnapshotInFlight = false + flushAckPendingOutput(stream) + } + } + } + const flushAckPendingOutput = (stream: TerminalMultiplexStream): void => { + if (stream.ackPendingOutputOverflowed) { + void sendAckRecoverySnapshot(stream) + return + } + let flushed = 0 + while ( + flushed < stream.ackPendingOutput.length && + canSendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!.bytes.byteLength) + ) { + sendAckGatedOutput(stream, stream.ackPendingOutput[flushed]!) + flushed += 1 + } + if (flushed > 0) { + stream.ackPendingOutput.splice(0, flushed) + stream.ackPendingOutputBytes = stream.ackPendingOutput.reduce( + (total, pending) => total + pending.bytes.byteLength, + 0 + ) + } + } + const flushAllAckPendingOutput = (): void => { + for (const stream of streams.values()) { + flushAckPendingOutput(stream) + } + } + const acknowledgeOutput = (stream: TerminalMultiplexStream, bytes: number): void => { + if (!stream.ackOutput || bytes <= 0) { + return + } + const acknowledged = Math.min(stream.ackInFlightBytes, bytes) + stream.ackInFlightBytes -= acknowledged + ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - acknowledged) + flushAllAckPendingOutput() + } const detachStream = (streamId: number, emitEnd: boolean): void => { const stream = streams.get(streamId) if (!stream) { @@ -1276,12 +1488,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } stream.outputBatcher.flush() stream.outputBatcher.dispose() + ackTotalInFlightBytes = Math.max(0, ackTotalInFlightBytes - stream.ackInFlightBytes) + stream.ackInFlightBytes = 0 + stream.ackPendingOutput = [] + stream.ackPendingOutputBytes = 0 + stream.ackPendingOutputOverflowed = false + stream.ackRecoverySnapshotInFlight = false stream.unsubscribeData() stream.unsubscribeResize() stream.unsubscribeFit() stream.unsubscribeDriver() stream.unregisterBinaryHandler() streams.delete(streamId) + flushAllAckPendingOutput() // Why: release the runtime exit-waiter for this slot (see the field's // note). The .catch below no-ops because the stream is already deleted. stream.exitWaiterAbort.abort() @@ -1314,6 +1533,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ detachStream(stream.streamId, false) return } + if (frame.opcode === TerminalStreamOpcode.Ack) { + const parsed = TerminalMultiplexAckFrame.safeParse( + decodeTerminalStreamJson(frame.payload) ?? {} + ) + if (parsed.success) { + acknowledgeOutput(stream, parsed.data.bytes) + } + return + } if (frame.opcode === TerminalStreamOpcode.Input) { const text = decodeTerminalStreamText(frame.payload) if (!text) { @@ -1482,6 +1710,16 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ emit({ type: 'end', streamId: request.streamId }) return } + if (closed) { + return + } + // Why: a competing subscribe for the same streamId can fully register + // while this one awaited the PTY id above. Overwriting it in + // `streams` would orphan its data/view-subscriber registrations — a + // leaked view subscriber permanently silences the model query + // responder (terminal-query-authority.md). Detach it so every + // registration stays release-balanced. + detachStream(request.streamId, false) const ptyId = leaf.ptyId const stream: TerminalMultiplexStream = { @@ -1490,7 +1728,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ptyId, client: request.client, isMobile, + ackOutput: request.capabilities?.ackOutput === 1, + ackInFlightBytes: 0, buffering: true, + ackPendingOutput: [], + ackPendingOutputBytes: 0, + ackPendingOutputOverflowed: false, + ackRecoverySnapshotInFlight: false, pendingOutput: [], pendingOutputBytes: 0, pendingOutputOverflowed: false, @@ -1506,7 +1750,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) } for (const chunk of iterateTerminalOutputFrameChunks(data, meta)) { - sendFrame(request.streamId, TerminalStreamOpcode.Output, chunk.bytes, chunk.seq) + queueOrSendOutput(stream, chunk) } }), unsubscribeData: () => {}, @@ -1522,7 +1766,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ ) try { - stream.unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { if (closed || streams.get(request.streamId) !== stream) { return } @@ -1532,6 +1776,15 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } stream.outputBatcher.push(data, meta) }) + // Why: a multiplexed stream feeds a remote xterm view that answers + // terminal queries with view authority; the main model responder + // yields while it is attached (terminal-query-authority.md). + // Wrapped into unsubscribeData so every detach path releases it. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + stream.unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } if (isMobile && request.client?.id) { await runtime.handleMobileSubscribe(ptyId, request.client.id, request.viewport) @@ -1718,6 +1971,13 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } }) } catch (error) { + // Why the ownership check: a newer subscribe may own this streamId + // now (it detached and released this stream on arrival). Detaching + // or erroring the slot here would tear down the successor's live + // registrations instead of this stream's. + if (streams.get(request.streamId) !== stream) { + return + } detachStream(request.streamId, false) sendStreamError(request.streamId, error instanceof Error ? error.message : String(error)) emit({ type: 'end', streamId: request.streamId }) @@ -1819,9 +2079,19 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ const outputBatcher = createTerminalOutputBatcher((chunk) => { emit({ type: 'data', chunk }) }) - const unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data) => { + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data) => { outputBatcher.push(data) }) + // Why: this legacy JSON stream can feed a live xterm view too + // (older web/desktop subscribers), so it conservatively registers + // as a remote view subscriber. For read-only watchers the cost is + // a withheld model reply — the pre-Phase-5 status quo — which is + // strictly safer than a double reply under a view consumer. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + const unsubscribeData = (): void => { + releaseViewSubscriber() + unsubscribeStreamData() + } const unsubscribeFit = runtime.subscribeToFitOverrideChanges(ptyId, (event) => { outputBatcher.flush() emit({ @@ -1861,7 +2131,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // resize re-stream so it only fires on an actual width change. let lastResizeCols: number | undefined let resizeGeneration = 0 - const pendingOutput: TerminalOutputChunk[] = [] + let pendingOutput: TerminalOutputChunk[] = [] let pendingOutputBytes = 0 let pendingOutputOverflowed = false let unsubscribeData = (): void => {} @@ -1975,7 +2245,7 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ return } - unsubscribeData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { + const unsubscribeStreamData = runtime.subscribeToTerminalData(ptyId, (data, meta) => { if (closed) { return } @@ -1996,6 +2266,14 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ } outputBatcher?.push(data, meta) }) + // Why: binary subscribe streams feed remote xterm views (mobile and + // binary-capable desktop clients) that answer queries with view + // authority; the main model responder yields while attached. + const releaseViewSubscriber = runtime.registerRemoteTerminalViewSubscriber(ptyId) + unsubscribeData = () => { + releaseViewSubscriber() + unsubscribeStreamData() + } let read = await runtime.readTerminal(params.terminal) let serialized = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) @@ -2066,6 +2344,55 @@ export const TERMINAL_METHODS: RpcAnyMethod[] = [ // Why: baseline for resize re-stream gating; the client already // rewrapped to these cols via the initial snapshot replay. lastResizeCols = serialized?.cols ?? size?.cols + let recoveryAttempts = 0 + // Why: if the bounded pre-subscribe tail overflowed, only a fresh + // model snapshot can cover the dropped middle without replay gaps. + while (pendingOutputOverflowed && recoveryAttempts < 2) { + pendingOutputOverflowed = false + recoveryAttempts += 1 + const recovery = await serializeBudgetedMobileSnapshot(runtime, ptyId, isMobile) + if (closed) { + return + } + if (!recovery) { + break + } + // Why: without an output seq (renderer-source fallback) covered + // chunks cannot be trimmed exactly, and the renderer view may lag + // the queued chunks under backpressure. Keep the bounded replay + // instead of applying an unverifiable snapshot. + if (typeof recovery.seq !== 'number') { + break + } + // Why: shipped mobile clients drop a second scrollback snapshot for + // an initialized handle but apply a resized snapshot inline by + // re-initializing xterm with fresh scrollback. Omit seq on the wire + // so the client's layout-seq staleness filter is not polluted with + // output-byte sequences. + const recoveryStats = sendSnapshotFrames(sendFrame, { + kind: 'resized', + cols: recovery.cols, + rows: recovery.rows, + displayMode, + reason: 'pending-output-overflow', + source: recovery.source, + truncated: false, + truncatedByByteBudget: recovery.truncatedByByteBudget, + data: recovery.data + }) + console.log('[mobile-terminal-stream] recovery snapshot', { + terminal: params.terminal, + streamId, + reason: 'pending-output-overflow', + bytes: recoveryStats.bytes, + chunks: recoveryStats.chunks, + scrollbackRows: recovery.scrollbackRows, + truncatedByByteBudget: recovery.truncatedByByteBudget === true + }) + const trimmed = trimPendingOutputCoveredBySnapshot(pendingOutput, recovery.seq) + pendingOutput = trimmed.chunks + pendingOutputBytes = trimmed.bytes + } buffering = false const bufferedOutput = pendingOutput.splice(0) if (!initialOutputOverflowed) { diff --git a/src/main/runtime/rpc/streaming.test.ts b/src/main/runtime/rpc/streaming.test.ts index abc9ab20372..9cdc88a021b 100644 --- a/src/main/runtime/rpc/streaming.test.ts +++ b/src/main/runtime/rpc/streaming.test.ts @@ -9,6 +9,9 @@ import type { RuntimeTerminalWait } from '../../../shared/runtime-types' function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: subscribe streams register as remote view subscribers for Phase-5 + // query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts index 24367102745..88125c840b3 100644 --- a/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex-escape-tail.test.ts @@ -53,6 +53,7 @@ describe('terminal.multiplex pending-escape-tail threading (#7329)', () => { getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), getMobileDisplayMode: vi.fn().mockReturnValue('auto'), getLayout: vi.fn().mockReturnValue({ seq: 1 }), + registerRemoteTerminalViewSubscriber: vi.fn(() => () => {}), subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), diff --git a/src/main/runtime/rpc/terminal-multiplex.test.ts b/src/main/runtime/rpc/terminal-multiplex.test.ts index edb5afc030a..62dcfddc5ab 100644 --- a/src/main/runtime/rpc/terminal-multiplex.test.ts +++ b/src/main/runtime/rpc/terminal-multiplex.test.ts @@ -18,6 +18,14 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: every multiplex stream registers as a remote view subscriber for + // Phase-5 query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, + // Why: the multiplex subscribe path resolves handles via + // resolveLiveLeafForHandle (#7718). Default to a live pty so tests that + // only stub the legacy resolveLeafForHandle still bind; tests that need a + // null/stale leaf override this explicitly. + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), ...overrides } as OrcaRuntimeService } @@ -498,6 +506,708 @@ describe('terminal multiplex RPC', () => { } }) + it('holds ACK-capable multiplex output over budget until the client acknowledges bytes', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ + data: 'snapshot', + cols: 120, + rows: 40 + }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + _: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListenerRef.current = listener + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-gated', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 16, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + binaryFrames.splice(0) + + const output = 'x'.repeat(700 * 1024) + dataListenerRef.current?.(output, { seq: output.length, rawLength: output.length }) + + const initialOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + const initialBytes = initialOutputFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(initialBytes).toBeLessThanOrEqual(512 * 1024) + expect(initialOutputFrames.length).toBeGreaterThan(0) + const initialOutput = initialOutputFrames + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(initialOutput.length).toBeLessThan(output.length) + + handlers.get(16)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 16, + seq: 2, + payload: encodeTerminalStreamText('still interactive\r') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'still interactive\r', + enter: false, + interrupt: false + }) + ) + + handlers.get(16)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 16, + seq: 3, + payload: encodeTerminalStreamJson({ bytes: initialBytes }) + }) + )! + ) + + const flushedOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + expect(flushedOutputFrames.length).toBeGreaterThan(initialOutputFrames.length) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-gated') + await dispatchPromise + }) + + it('releases shared ACK budget to other stalled multiplex streams', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListeners = new Map< + string, + (data: string, meta?: { seq?: number; rawLength?: number }) => void + >() + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn((terminal: string) => ({ + ptyId: terminal.replace('terminal-', 'pty-') + })), + resolveLiveLeafForHandle: vi.fn((terminal: string) => ({ + ptyId: terminal.replace('terminal-', 'pty-') + })), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn(async (ptyId: string) => ({ + data: `snapshot-${ptyId}`, + cols: 120, + rows: 40 + })), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + ptyId: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListeners.set(ptyId, listener) + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-shared-budget', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + + const streamIds = [21, 22, 23, 24, 25, 26] + for (const streamId of streamIds) { + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: streamId, + payload: encodeTerminalStreamJson({ + streamId, + terminal: `terminal-${streamId - 20}`, + client: { id: `desktop-${streamId}`, type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + } + + await vi.waitFor(() => + expect( + messages + .map((msg) => JSON.parse(msg).result) + .filter((result) => result?.type === 'subscribed') + ).toHaveLength(streamIds.length) + ) + await vi.waitFor(() => expect(dataListeners.size).toBe(streamIds.length)) + binaryFrames.splice(0) + + const fillerOutput = 'f'.repeat(480 * 1024) + for (let index = 1; index <= 4; index += 1) { + dataListeners.get(`pty-${index}`)?.(fillerOutput, { + seq: fillerOutput.length, + rawLength: fillerOutput.length + }) + } + const stalledOutput = 's'.repeat(700 * 1024) + dataListeners.get('pty-5')?.(stalledOutput, { + seq: stalledOutput.length, + rawLength: stalledOutput.length + }) + dataListeners.get('pty-6')?.(stalledOutput, { + seq: stalledOutput.length, + rawLength: stalledOutput.length + }) + + const initialOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + const initialBytesByStream = new Map() + for (const frame of initialOutputFrames) { + if (!frame) { + continue + } + initialBytesByStream.set( + frame.streamId, + (initialBytesByStream.get(frame.streamId) ?? 0) + frame.payload.byteLength + ) + } + const initialBytes = initialOutputFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(initialBytes).toBeLessThanOrEqual(2 * 1024 * 1024) + expect(initialBytesByStream.get(21)).toBe(480 * 1024) + expect(initialBytesByStream.get(22)).toBe(480 * 1024) + expect(initialBytesByStream.get(23)).toBe(480 * 1024) + expect(initialBytesByStream.get(24)).toBe(480 * 1024) + expect(initialBytesByStream.get(25)).toBeGreaterThan(0) + expect(initialBytesByStream.get(26) ?? 0).toBe(0) + + handlers.get(26)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 26, + seq: 200, + payload: encodeTerminalStreamText('remote-still-interactive\r') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-6', { + text: 'remote-still-interactive\r', + enter: false, + interrupt: false + }) + ) + + const frameCountBeforeAck = binaryFrames.length + handlers.get(21)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 21, + seq: 201, + payload: encodeTerminalStreamJson({ bytes: initialBytesByStream.get(21) ?? 0 }) + }) + )! + ) + + await vi.waitFor(() => + expect( + binaryFrames + .slice(frameCountBeforeAck) + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => { + if (frame?.streamId !== 25 || frame.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + ).toBe(true) + ) + const framesAfterAck = binaryFrames + .slice(frameCountBeforeAck) + .map((frame) => decodeTerminalStreamFrame(frame)) + const snapshotStartIndex = framesAfterAck.findIndex((frame) => { + if (frame?.streamId !== 25 || frame.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + const outputFramesAfterAck = framesAfterAck.filter( + (frame) => frame?.opcode === TerminalStreamOpcode.Output + ) + const bytesAfterAckByStream = new Map() + for (const frame of outputFramesAfterAck) { + if (!frame) { + continue + } + bytesAfterAckByStream.set( + frame.streamId, + (bytesAfterAckByStream.get(frame.streamId) ?? 0) + frame.payload.byteLength + ) + } + expect(snapshotStartIndex).toBeGreaterThanOrEqual(0) + expect( + framesAfterAck + .filter((frame) => frame?.streamId === 25 && frame.opcode === TerminalStreamOpcode.Output) + .every((frame) => framesAfterAck.indexOf(frame) > snapshotStartIndex) + ).toBe(true) + expect(bytesAfterAckByStream.get(25) ?? 0).toBeGreaterThan(0) + expect(bytesAfterAckByStream.get(21) ?? 0).toBe(0) + expect( + outputFramesAfterAck.reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + ).toBeLessThanOrEqual(initialBytesByStream.get(21) ?? 0) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-shared-budget') + await dispatchPromise + }) + + it('caps stalled ACK output and snapshots before resuming retained tail frames', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 }) + .mockResolvedValue({ data: 'recovered snapshot', cols: 120, rows: 40, seq: 99 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + _: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListenerRef.current = listener + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-overflow', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 17, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + binaryFrames.splice(0) + + const output = 'x'.repeat(3 * 1024 * 1024) + dataListenerRef.current?.(output, { seq: output.length, rawLength: output.length }) + + const initialOutputFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + const initialBytes = initialOutputFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(initialBytes).toBeLessThanOrEqual(512 * 1024) + + handlers.get(17)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Input, + streamId: 17, + seq: 2, + payload: encodeTerminalStreamText('still interactive\r') + }) + )! + ) + await vi.waitFor(() => + expect(runtime.sendTerminal).toHaveBeenCalledWith('terminal-1', { + text: 'still interactive\r', + enter: false, + interrupt: false + }) + ) + + binaryFrames.splice(0) + handlers.get(17)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 17, + seq: 3, + payload: encodeTerminalStreamJson({ bytes: initialBytes }) + }) + )! + ) + + await vi.waitFor(() => + expect( + binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + ).toBe(true) + ) + const drainFrames = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame)) + const recoveryStartIndex = drainFrames.findIndex((frame) => { + if (frame?.opcode !== TerminalStreamOpcode.SnapshotStart) { + return false + } + const payload = decodeTerminalStreamJson<{ reason?: string }>(frame.payload) + return payload?.reason === 'ack-pending-overflow' + }) + const firstOutputAfterAckIndex = drainFrames.findIndex( + (frame) => frame?.opcode === TerminalStreamOpcode.Output + ) + expect(recoveryStartIndex).toBeGreaterThanOrEqual(0) + // Why: clients discard truncated snapshots; a usable recovery snapshot + // must not be marked truncated or the dropped output gap is permanent. + expect( + decodeTerminalStreamJson<{ truncated?: boolean }>(drainFrames[recoveryStartIndex]!.payload) + ?.truncated + ).toBe(false) + expect(firstOutputAfterAckIndex).toBeGreaterThan(recoveryStartIndex) + expect( + drainFrames + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + ).toBe('recovered snapshot') + + const outputBytesAfterRecovery = drainFrames + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + expect(outputBytesAfterRecovery).toBeLessThanOrEqual(256 * 1024) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-overflow') + await dispatchPromise + }) + + it('trims recovery-covered ACK pending output instead of replaying it', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const floodedChars = 3 * 1024 * 1024 + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi + .fn() + .mockResolvedValueOnce({ data: 'initial snapshot', cols: 120, rows: 40 }) + // Why: the recovery snapshot seq covers the entire flood, so every + // retained pending chunk is already contained in the snapshot. + .mockResolvedValue({ data: 'recovered snapshot', cols: 120, rows: 40, seq: floodedChars }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn( + ( + _: string, + listener: (data: string, meta?: { seq?: number; rawLength?: number }) => void + ) => { + dataListenerRef.current = listener + return vi.fn() + } + ), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + subscribeToDriverChanges: vi.fn().mockReturnValue(vi.fn()), + getTerminalFitOverride: vi.fn().mockReturnValue(null), + getDriver: vi.fn().mockReturnValue({ kind: 'idle' }), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateDesktopViewport: vi.fn().mockResolvedValue(true) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-ack-trim', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 31, + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + viewport: { cols: 120, rows: 40 }, + capabilities: { ackOutput: 1 } + }) + }) + )! + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + binaryFrames.splice(0) + + const output = 'x'.repeat(floodedChars) + dataListenerRef.current?.(output, { seq: floodedChars, rawLength: floodedChars }) + const initialBytes = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + expect(initialBytes).toBeLessThanOrEqual(512 * 1024) + + binaryFrames.splice(0) + handlers.get(31)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 31, + seq: 2, + payload: encodeTerminalStreamJson({ bytes: initialBytes }) + }) + )! + ) + await vi.waitFor(() => + expect( + binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .some((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotEnd) + ).toBe(true) + ) + + const framesAfterRecovery = binaryFrames.map((frame) => decodeTerminalStreamFrame(frame)) + expect( + framesAfterRecovery + .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + ).toBe('recovered snapshot') + // Why: every retained chunk is covered by the recovery snapshot seq; + // replaying any of them would duplicate snapshot content. + expect( + framesAfterRecovery.filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + ).toEqual([]) + + binaryFrames.splice(0) + const fresh = 'fresh-after-recovery\r\n' + dataListenerRef.current?.(fresh, { + seq: floodedChars + fresh.length, + rawLength: fresh.length + }) + await vi.waitFor(() => { + const freshOutput = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(freshOutput).toBe(fresh) + }) + + runtime.cleanupSubscription('terminal-multiplex:conn-ack-trim') + await dispatchPromise + }) + it('marks multiplex fallback snapshots truncated when the uncursored read is limited', async () => { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] @@ -1472,6 +2182,238 @@ describe('terminal multiplex RPC', () => { await dispatchPromise }) + it('keeps view-subscriber releases balanced when a same-streamId subscribe overwrites a blocked one', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + // Why: a leaked registration permanently suppresses the model query + // responder (terminal-query-authority.md) — the count must return to 0. + let viewSubscriberCount = 0 + let leafResolved = false + let resolveFirstWait: (ptyId: string) => void = () => {} + // Why: the multiplex subscribe path resolves via resolveLiveLeafForHandle + // (#7718); null makes subscribe A block in waitForLeafPtyId until B resolves. + const resolveLeaf = (): { ptyId: string | null } => + leafResolved ? { ptyId: 'pty-1' } : { ptyId: null } + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn(resolveLeaf), + resolveLiveLeafForHandle: vi.fn(resolveLeaf), + waitForLeafPtyId: vi.fn( + () => + new Promise((resolve) => { + resolveFirstWait = resolve + }) + ), + registerRemoteTerminalViewSubscriber: vi.fn(() => { + viewSubscriberCount += 1 + let released = false + return () => { + if (!released) { + released = true + viewSubscriberCount -= 1 + } + } + }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snap', cols: 80, rows: 24 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + handleMobileSubscribe: vi.fn().mockResolvedValue(undefined), + handleMobileUnsubscribe: vi.fn(), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-overwrite', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + const sendSubscribe = (): void => { + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 7, + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' } + }) + }) + )! + ) + } + + // Subscribe A blocks in waitForLeafPtyId; subscribe B (same streamId) + // then resolves the leaf directly and fully registers. + sendSubscribe() + await vi.waitFor(() => expect(runtime.waitForLeafPtyId).toHaveBeenCalled()) + leafResolved = true + sendSubscribe() + await vi.waitFor(() => + expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength( + 1 + ) + ) + + // A resumes and takes the slot; B's registration must be released, not + // orphaned by the overwrite. + resolveFirstWait('pty-1') + await vi.waitFor(() => + expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength( + 2 + ) + ) + + handlers.get(7)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Unsubscribe, + streamId: 7, + seq: 2, + payload: new Uint8Array() + }) + )! + ) + expect(viewSubscriberCount).toBe(0) + + cleanups.get('terminal-multiplex:conn-overwrite')?.() + await dispatchPromise + }) + + it('keeps an evicted subscribe error from detaching the successor stream', async () => { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const handlers = new Map< + number, + (frame: NonNullable>) => void + >() + const cleanups = new Map void>() + let viewSubscriberCount = 0 + const mobileSubscribeWaiters: { + resolve: () => void + reject: (error: Error) => void + }[] = [] + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + // Why: the multiplex subscribe path resolves the leaf via + // resolveLiveLeafForHandle (#7718), so it must return a live pty here. + resolveLiveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + registerRemoteTerminalViewSubscriber: vi.fn(() => { + viewSubscriberCount += 1 + let released = false + return () => { + if (!released) { + released = true + viewSubscriberCount -= 1 + } + } + }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn().mockResolvedValue({ data: 'snap', cols: 80, rows: 24 }), + getTerminalSize: vi.fn().mockReturnValue({ cols: 80, rows: 24 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn().mockReturnValue(vi.fn()), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + handleMobileSubscribe: vi.fn( + () => + new Promise((resolve, reject) => { + mobileSubscribeWaiters.push({ resolve: () => resolve(true), reject }) + }) + ), + handleMobileUnsubscribe: vi.fn(), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + waitForTerminal: vi.fn(() => new Promise(() => {})) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.multiplex', {}), + (msg) => messages.push(msg), + { + connectionId: 'conn-evicted-error', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + }, + registerBinaryStreamHandler: (streamId, handler) => { + handlers.set(streamId, handler) + return () => handlers.delete(streamId) + } + } + ) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'ready')).toBe(true) + ) + const sendSubscribe = (): void => { + handlers.get(0)?.( + decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + seq: 1, + payload: encodeTerminalStreamJson({ + streamId: 9, + terminal: 'terminal-1', + client: { id: 'phone-1', type: 'mobile' } + }) + }) + )! + ) + } + + // A registers, then blocks in handleMobileSubscribe. B (same streamId) + // evicts A on arrival and completes its own registration. + sendSubscribe() + await vi.waitFor(() => expect(mobileSubscribeWaiters).toHaveLength(1)) + sendSubscribe() + await vi.waitFor(() => expect(mobileSubscribeWaiters).toHaveLength(2)) + mobileSubscribeWaiters[1]!.resolve() + await vi.waitFor(() => + expect(messages.filter((msg) => JSON.parse(msg).result?.type === 'subscribed')).toHaveLength( + 1 + ) + ) + expect(viewSubscriberCount).toBe(1) + + // A's pending await now rejects. The evicted stream must not detach the + // successor that owns the slot. + mobileSubscribeWaiters[0]!.reject(new Error('mobile_subscribe_failed')) + await Promise.resolve() + await Promise.resolve() + expect(viewSubscriberCount).toBe(1) + + cleanups.get('terminal-multiplex:conn-evicted-error')?.() + await dispatchPromise + expect(viewSubscriberCount).toBe(0) + }) + it('rejects a stale terminal handle with terminal_handle_stale instead of binding the wrong PTY', async () => { // Why: after a reconnect a client can resubscribe with a handle whose // pane now hosts a different PTY. Binding the stream anyway would mirror diff --git a/src/main/runtime/rpc/terminal-output-batching.test.ts b/src/main/runtime/rpc/terminal-output-batching.test.ts index df81e05e057..d10456c8fa6 100644 --- a/src/main/runtime/rpc/terminal-output-batching.test.ts +++ b/src/main/runtime/rpc/terminal-output-batching.test.ts @@ -15,6 +15,9 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: subscribe streams register as remote view subscribers for Phase-5 + // query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, ...overrides } as OrcaRuntimeService } diff --git a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts index f4a70cbeebd..ba921c6b14e 100644 --- a/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts +++ b/src/main/runtime/rpc/terminal-subscribe-buffer.test.ts @@ -14,6 +14,9 @@ import { function stubRuntime(overrides: Partial = {}): OrcaRuntimeService { return { getRuntimeId: () => 'test-runtime', + // Why: subscribe streams register as remote view subscribers for Phase-5 + // query-authority suppression (terminal-query-authority.md). + registerRemoteTerminalViewSubscriber: () => () => {}, ...overrides } as OrcaRuntimeService } @@ -346,13 +349,6 @@ describe('terminal subscribe buffering', () => { await vi.waitFor(() => expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) ) - const subscribed = messages - .map((msg) => JSON.parse(msg).result) - .find((result) => result?.type === 'subscribed') - expect(subscribed).toMatchObject({ - type: 'subscribed', - truncated: false - }) const snapshotStart = binaryFrames .map((frame) => decodeTerminalStreamFrame(frame)) .find((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotStart) @@ -367,21 +363,35 @@ describe('terminal subscribe buffering', () => { await dispatchPromise }) - it('bounds legacy binary output queued while the initial snapshot is serializing', async () => { + it('recovers binary output overflow queued while the initial snapshot is serializing', async () => { vi.useFakeTimers() try { const messages: string[] = [] const binaryFrames: Uint8Array[] = [] const cleanups = new Map void>() - const dataListenerRef: { current?: (data: string) => void } = {} - const snapshotResolves: ((value: { data: string; cols: number; rows: number }) => void)[] = [] + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const snapshotResolvers: ((value: { + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }) => void)[] = [] const runtime = stubRuntime({ resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), serializeTerminalBuffer: vi.fn( () => - new Promise<{ data: string; cols: number; rows: number }>((resolve) => { - snapshotResolves.push(resolve) + new Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }>((resolve) => { + snapshotResolvers.push(resolve) }) ), getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), @@ -427,34 +437,54 @@ describe('terminal subscribe buffering', () => { await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) const shiftSpy = vi.spyOn(Array.prototype, 'shift') + let seq = 0 for (let index = 0; index < 400; index += 1) { - dataListenerRef.current?.(`${String(index).padStart(3, '0')}${'x'.repeat(1021)}`) + const data = `${String(index).padStart(3, '0')}${'x'.repeat(1021)}` + seq += data.length + dataListenerRef.current?.(data, { seq, rawLength: data.length }) } const shiftCallCount = shiftSpy.mock.calls.length shiftSpy.mockRestore() await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) - snapshotResolves.shift()?.({ data: '', cols: 120, rows: 40 }) + snapshotResolvers[0]?.({ data: '', cols: 120, rows: 40, seq: 0, source: 'headless' }) await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2)) - snapshotResolves.shift()?.({ data: '399', cols: 120, rows: 40 }) + snapshotResolvers[1]?.({ + data: 'recovered after overflow\r\n', + cols: 120, + rows: 40, + seq, + source: 'headless' + }) await vi.waitFor(() => expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) ) await vi.runOnlyPendingTimersAsync() - const output = binaryFrames + const decodedFrames = binaryFrames .map((frame) => decodeTerminalStreamFrame(frame)) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output) - .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .filter((frame): frame is NonNullable => frame !== null) + const snapshotStarts = decodedFrames.filter( + (frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart + ) + const decodedStarts = snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload)) + // Why one snapshot: overflow during the initial serialize is recovered + // INLINE (drop pending, re-read, re-serialize) before anything is sent, + // so the client's first scrollback snapshot is already current. The + // 'resized'/pending-output-overflow follow-up path remains only for + // overflow that begins after the initial snapshot went out. + expect(decodedStarts).toEqual([expect.objectContaining({ kind: 'scrollback', seq })]) + const snapshotText = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame.payload)) + .join('') + const output = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame.payload)) .join('') expect(output.length).toBeLessThanOrEqual(256 * 1024) - expect(output).toBe('') - const snapshotPayload = binaryFrames - .map((frame) => decodeTerminalStreamFrame(frame)) - .filter((frame) => frame?.opcode === TerminalStreamOpcode.SnapshotChunk) - .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) - .join('') - expect(snapshotPayload).toBe('399') expect(output).not.toContain('000') + expect(output).not.toContain('399') + expect(snapshotText).toContain('recovered after overflow') expect(shiftCallCount).toBe(0) runtime.cleanupSubscription('terminal-1:desktop-1') @@ -612,4 +642,129 @@ describe('terminal subscribe buffering', () => { runtime.cleanupSubscription('terminal-1:phone-1') await dispatchPromise }) + + it('applies inline overflow recovery when the snapshot has no output seq', async () => { + vi.useFakeTimers() + try { + const messages: string[] = [] + const binaryFrames: Uint8Array[] = [] + const cleanups = new Map void>() + const dataListenerRef: { + current?: (data: string, meta?: { seq?: number; rawLength?: number }) => void + } = {} + const snapshotResolvers: ((value: { + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }) => void)[] = [] + const runtime = stubRuntime({ + resolveLeafForHandle: vi.fn().mockReturnValue({ ptyId: 'pty-1' }), + readTerminal: vi.fn().mockResolvedValue({ tail: [], truncated: false }), + serializeTerminalBuffer: vi.fn( + () => + new Promise<{ + data: string + cols: number + rows: number + seq?: number + source?: 'headless' | 'renderer' + }>((resolve) => { + snapshotResolvers.push(resolve) + }) + ), + getTerminalSize: vi.fn().mockReturnValue({ cols: 120, rows: 40 }), + getMobileDisplayMode: vi.fn().mockReturnValue('auto'), + getLayout: vi.fn().mockReturnValue({ seq: 1 }), + subscribeToTerminalData: vi.fn((_: string, listener: (data: string) => void) => { + dataListenerRef.current = listener + return vi.fn() + }), + subscribeToTerminalResize: vi.fn().mockReturnValue(vi.fn()), + subscribeToFitOverrideChanges: vi.fn().mockReturnValue(vi.fn()), + registerSubscriptionCleanup: vi.fn((id: string, cleanup: () => void) => { + cleanups.set(id, cleanup) + }), + cleanupSubscription: vi.fn((id: string) => { + const cleanup = cleanups.get(id) + cleanups.delete(id) + cleanup?.() + }), + waitForTerminal: vi.fn(() => new Promise(() => {})), + sendTerminal: vi.fn().mockResolvedValue({ accepted: true }), + updateMobileViewport: vi.fn().mockResolvedValue(false) + }) + const dispatcher = new RpcDispatcher({ runtime, methods: TERMINAL_METHODS }) + + const dispatchPromise = dispatcher.dispatchStreaming( + makeRequest('terminal.subscribe', { + terminal: 'terminal-1', + client: { id: 'desktop-1', type: 'desktop' }, + capabilities: { terminalBinaryStream: 1 } + }), + (msg) => messages.push(msg), + { + connectionId: 'conn-buffered-no-seq', + sendBinary: (bytes) => { + binaryFrames.push(bytes) + } + } + ) + + await vi.waitFor(() => expect(dataListenerRef.current).toBeDefined()) + let seq = 0 + for (let index = 0; index < 400; index += 1) { + const data = `${String(index).padStart(3, '0')}${'x'.repeat(1021)}` + seq += data.length + dataListenerRef.current?.(data, { seq, rawLength: data.length }) + } + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalled()) + snapshotResolvers[0]?.({ data: '', cols: 120, rows: 40, seq: 0, source: 'headless' }) + await vi.waitFor(() => expect(runtime.serializeTerminalBuffer).toHaveBeenCalledTimes(2)) + // Why applying is safe without a seq here: inline recovery serialized + // AFTER dropping the overflowed pending queue, so the snapshot covers + // those chunks by construction — no seq-based trimming is needed. + snapshotResolvers[1]?.({ + data: 'renderer fallback snapshot\r\n', + cols: 120, + rows: 40, + source: 'renderer' + }) + await vi.waitFor(() => + expect(messages.some((msg) => JSON.parse(msg).result?.type === 'subscribed')).toBe(true) + ) + await vi.runOnlyPendingTimersAsync() + + const decodedFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame): frame is NonNullable => frame !== null) + const snapshotStarts = decodedFrames.filter( + (frame) => frame.opcode === TerminalStreamOpcode.SnapshotStart + ) + // Why seq 1 (layout seq): a no-output-seq snapshot falls back to the + // layout seq on the wire; the recovered data still ships as the first + // and only scrollback snapshot. + expect(snapshotStarts.map((frame) => decodeTerminalStreamJson(frame.payload))).toEqual([ + expect.objectContaining({ kind: 'scrollback', seq: 1 }) + ]) + const snapshotText = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.SnapshotChunk) + .map((frame) => decodeTerminalStreamText(frame.payload)) + .join('') + expect(snapshotText).toContain('renderer fallback snapshot') + const output = decodedFrames + .filter((frame) => frame.opcode === TerminalStreamOpcode.Output) + .map((frame) => decodeTerminalStreamText(frame.payload)) + .join('') + // Why empty: the overflowed pending queue was dropped before the + // covering snapshot was serialized; nothing needs replay. + expect(output).toBe('') + + runtime.cleanupSubscription('terminal-1:desktop-1') + await dispatchPromise + } finally { + vi.useRealTimers() + } + }) }) diff --git a/src/main/runtime/runtime-rpc.test.ts b/src/main/runtime/runtime-rpc.test.ts index 5f50c32845e..2d7579cce48 100644 --- a/src/main/runtime/runtime-rpc.test.ts +++ b/src/main/runtime/runtime-rpc.test.ts @@ -13,6 +13,15 @@ import * as runtimeMetadataModule from './runtime-metadata' import { readRuntimeMetadata } from './runtime-metadata' import { createRuntimeTransportMetadata, OrcaRuntimeRpcServer } from './runtime-rpc' import { parsePairingCode } from '../../shared/pairing' +import { subscribeRemoteRuntimeRequest } from '../../shared/remote-runtime-client' +import { + TerminalStreamOpcode, + decodeTerminalStreamFrame, + decodeTerminalStreamText, + encodeTerminalStreamFrame, + encodeTerminalStreamJson, + encodeTerminalStreamText +} from '../../shared/terminal-stream-protocol' import { decrypt, deriveSharedKey, encrypt, generateKeyPair } from './rpc/e2ee-crypto' import { DeviceRegistry } from './device-registry' @@ -2760,6 +2769,206 @@ describe('OrcaRuntimeRpcServer', () => { } }) + it('keeps active runtime multiplex streams responsive while a background stream is ACK-limited over WebSocket', async () => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) + const writes: { terminal: string; text: string }[] = [] + const runtime = new OrcaRuntimeService(makeStore() as never) + const spawn = vi + .fn() + .mockResolvedValueOnce({ id: 'multiplex-background-pty' }) + .mockResolvedValueOnce({ id: 'multiplex-active-pty' }) + runtime.setPtyController({ + spawn, + write: (ptyId, data) => { + writes.push({ terminal: ptyId, text: data }) + return true + }, + kill: () => true, + getForegroundProcess: async () => null + }) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath, + enableWebSocket: true, + wsPort: 0 + }) + + await server.start() + + const phoneOffer = server.createPairingOffer({ + address: '127.0.0.1', + name: 'phone', + scope: 'mobile' + }) + expect(phoneOffer.available).toBe(true) + if (!phoneOffer.available) { + throw new Error('WebSocket pairing unavailable') + } + const pairing = parsePairingCode(phoneOffer.pairingUrl) + expect(pairing).toBeTruthy() + if (!pairing) { + throw new Error('Pairing URL did not parse') + } + + const metadata = readRuntimeMetadata(userDataPath) + const laptopEndpoint = metadata!.transports[0]!.endpoint + const laptopAuthToken = metadata!.authToken + const worktree = 'id:repo-1::/tmp/worktree-a' + const backgroundLeafId = '11111111-1111-4111-8111-111111111111' + const activeLeafId = '22222222-2222-4222-8222-222222222222' + const backgroundCreateResponse = await sendRequest(laptopEndpoint, { + id: 'laptop_create_background', + authToken: laptopAuthToken, + method: 'terminal.create', + params: { + worktree, + command: 'background', + tabId: 'multiplex-background-tab', + leafId: backgroundLeafId + } + }) + const activeCreateResponse = await sendRequest(laptopEndpoint, { + id: 'laptop_create_active', + authToken: laptopAuthToken, + method: 'terminal.create', + params: { + worktree, + command: 'active', + tabId: 'multiplex-active-tab', + leafId: activeLeafId, + activate: true + } + }) + const backgroundTerminal = (backgroundCreateResponse.result as { terminal: { handle: string } }) + .terminal + const activeTerminal = (activeCreateResponse.result as { terminal: { handle: string } }) + .terminal + + const responses: Record[] = [] + const binaryFrames: Uint8Array[] = [] + const onError = vi.fn() + const subscription = await subscribeRemoteRuntimeRequest( + pairing, + 'terminal.multiplex', + {}, + 15_000, + { + onResponse: (response) => responses.push(response as Record), + onBinary: (bytes) => binaryFrames.push(bytes), + onError + } + ) + + try { + await vi.waitFor(() => + expect( + responses.some( + (response) => (response.result as { type?: string } | undefined)?.type === 'ready' + ) + ).toBe(true) + ) + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 1, + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + payload: encodeTerminalStreamJson({ + streamId: 21, + terminal: backgroundTerminal.handle, + client: { id: 'desktop-background', type: 'desktop' }, + capabilities: { ackOutput: 1 } + }) + }) + ) + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 2, + opcode: TerminalStreamOpcode.Subscribe, + streamId: 0, + payload: encodeTerminalStreamJson({ + streamId: 22, + terminal: activeTerminal.handle, + client: { id: 'desktop-active', type: 'desktop' }, + capabilities: { ackOutput: 1 } + }) + }) + ) + await vi.waitFor(() => { + const subscribedStreamIds = responses + .map((response) => response.result as { type?: string; streamId?: number } | undefined) + .filter((result) => result?.type === 'subscribed') + .map((result) => result?.streamId) + expect(subscribedStreamIds).toEqual(expect.arrayContaining([21, 22])) + }) + binaryFrames.splice(0) + + const backgroundOutput = 'B'.repeat(700 * 1024) + runtime.onPtyData('multiplex-background-pty', backgroundOutput, 1) + await vi.waitFor(() => { + const backgroundFrames = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21) + const backgroundBytes = backgroundFrames.reduce( + (total, frame) => total + (frame?.payload.byteLength ?? 0), + 0 + ) + expect(backgroundBytes).toBeGreaterThan(0) + expect(backgroundBytes).toBeLessThan(backgroundOutput.length) + }) + + const frameCountBeforeActive = binaryFrames.length + runtime.onPtyData('multiplex-active-pty', 'ACTIVE_MULTIPLEX_READY\r\n', 2) + await vi.waitFor(() => { + const activeOutput = binaryFrames + .slice(frameCountBeforeActive) + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 22) + .map((frame) => (frame ? decodeTerminalStreamText(frame.payload) : '')) + .join('') + expect(activeOutput).toContain('ACTIVE_MULTIPLEX_READY') + }) + + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 3, + opcode: TerminalStreamOpcode.Input, + streamId: 22, + payload: encodeTerminalStreamText('still interactive\r') + }) + ) + await vi.waitFor(() => + expect(writes).toContainEqual({ + terminal: 'multiplex-active-pty', + text: 'still interactive\r' + }) + ) + + const backgroundBytesBeforeAck = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + subscription.sendBinary( + encodeTerminalStreamFrame({ + seq: 4, + opcode: TerminalStreamOpcode.Ack, + streamId: 21, + payload: encodeTerminalStreamJson({ bytes: backgroundBytesBeforeAck }) + }) + ) + await vi.waitFor(() => { + const backgroundBytesAfterAck = binaryFrames + .map((frame) => decodeTerminalStreamFrame(frame)) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Output && frame.streamId === 21) + .reduce((total, frame) => total + (frame?.payload.byteLength ?? 0), 0) + expect(backgroundBytesAfterAck).toBeGreaterThan(backgroundBytesBeforeAck) + }) + expect(onError).not.toHaveBeenCalled() + } finally { + subscription.close() + await server.stop() + } + }) + it('serves worktree.ps from the runtime summary builder', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-')) const runtime = new OrcaRuntimeService(makeStore({ isUnread: true }) as never) diff --git a/src/main/runtime/terminal-model-query-authority.test.ts b/src/main/runtime/terminal-model-query-authority.test.ts new file mode 100644 index 00000000000..3217a98022e --- /dev/null +++ b/src/main/runtime/terminal-model-query-authority.test.ts @@ -0,0 +1,140 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + _resetTerminalModelQueryAuthorityForTest, + clearNativeWindowsConptyPty, + isNativeWindowsConptyPty, + isNativeWindowsLocalPtySpawn, + isTerminalModelQueryAuthorityEnabled, + markNativeWindowsConptyPty, + shouldModelAnswerHiddenPtyQueries +} from './terminal-model-query-authority' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + markHiddenRendererPty, + setRendererPtyDeliveryInterest +} from '../ipc/pty-hidden-delivery-gate' + +const ALL_ON = { + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true +} + +afterEach(() => { + _resetTerminalModelQueryAuthorityForTest() + _resetHiddenRendererPtyDeliveryGateForTest() +}) + +describe('isTerminalModelQueryAuthorityEnabled', () => { + it('defaults on, including for absent settings', () => { + expect(isTerminalModelQueryAuthorityEnabled(ALL_ON)).toBe(true) + expect(isTerminalModelQueryAuthorityEnabled({})).toBe(true) + expect(isTerminalModelQueryAuthorityEnabled(null)).toBe(true) + expect(isTerminalModelQueryAuthorityEnabled(undefined)).toBe(true) + }) + + it('is an independent off switch for the responder alone', () => { + expect( + isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalModelQueryAuthority: false }) + ).toBe(false) + }) + + it('requires both Phase-4 gate switches — no marks exist without them', () => { + expect( + isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalHiddenDeliveryGate: false }) + ).toBe(false) + expect( + isTerminalModelQueryAuthorityEnabled({ ...ALL_ON, terminalMainSideEffectAuthority: false }) + ).toBe(false) + }) +}) + +describe('shouldModelAnswerHiddenPtyQueries', () => { + const answer = (ptyId: string, overrides: Record = {}): boolean => + shouldModelAnswerHiddenPtyQueries({ + ptyId, + settings: { ...ALL_ON, ...overrides }, + hasRemoteViewSubscriber: false + }) + + it('answers only for hidden-marked PTYs (the delivery decision is the reply decision)', () => { + expect(answer('pty-1')).toBe(false) + markHiddenRendererPty('pty-1') + expect(answer('pty-1')).toBe(true) + expect(answer('pty-other')).toBe(false) + }) + + it('yields to registered renderer delivery interest (chunk is delivered to a sidecar)', () => { + markHiddenRendererPty('pty-1') + setRendererPtyDeliveryInterest('pty-1', true) + expect(answer('pty-1')).toBe(false) + setRendererPtyDeliveryInterest('pty-1', false) + expect(answer('pty-1')).toBe(true) + }) + + it('yields while a remote view subscriber is attached', () => { + markHiddenRendererPty('pty-1') + expect( + shouldModelAnswerHiddenPtyQueries({ + ptyId: 'pty-1', + settings: ALL_ON, + hasRemoteViewSubscriber: true + }) + ).toBe(false) + }) + + it('stays silent under any kill switch', () => { + markHiddenRendererPty('pty-1') + expect(answer('pty-1', { terminalModelQueryAuthority: false })).toBe(false) + expect(answer('pty-1', { terminalHiddenDeliveryGate: false })).toBe(false) + expect(answer('pty-1', { terminalMainSideEffectAuthority: false })).toBe(false) + }) +}) + +describe('isNativeWindowsLocalPtySpawn (main-side mirror of isLocalNativeWindowsPty)', () => { + const base = { + connectionId: null, + cwd: 'C:\\repo', + shellOverride: undefined, + platform: 'win32' as NodeJS.Platform + } + + it('matches local native Windows spawns', () => { + expect(isNativeWindowsLocalPtySpawn(base)).toBe(true) + expect(isNativeWindowsLocalPtySpawn({ ...base, connectionId: undefined })).toBe(true) + expect( + isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'C:\\Tools\\powershell.exe' }) + ).toBe(true) + }) + + it('rejects non-Windows hosts', () => { + expect(isNativeWindowsLocalPtySpawn({ ...base, platform: 'darwin' })).toBe(false) + expect(isNativeWindowsLocalPtySpawn({ ...base, platform: 'linux' })).toBe(false) + }) + + it('rejects SSH-backed spawns', () => { + expect(isNativeWindowsLocalPtySpawn({ ...base, connectionId: 'ssh-1' })).toBe(false) + }) + + it('rejects WSL cwds and WSL shell overrides', () => { + expect( + isNativeWindowsLocalPtySpawn({ ...base, cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me' }) + ).toBe(false) + expect(isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'wsl.exe' })).toBe(false) + expect( + isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'C:\\Windows\\System32\\wsl.exe' }) + ).toBe(false) + expect(isNativeWindowsLocalPtySpawn({ ...base, shellOverride: 'wsl' })).toBe(false) + }) +}) + +describe('native-Windows ConPTY spawn record', () => { + it('marks, reads, and clears per PTY', () => { + expect(isNativeWindowsConptyPty('pty-1')).toBe(false) + markNativeWindowsConptyPty('pty-1') + expect(isNativeWindowsConptyPty('pty-1')).toBe(true) + expect(isNativeWindowsConptyPty('pty-2')).toBe(false) + clearNativeWindowsConptyPty('pty-1') + expect(isNativeWindowsConptyPty('pty-1')).toBe(false) + }) +}) diff --git a/src/main/runtime/terminal-model-query-authority.ts b/src/main/runtime/terminal-model-query-authority.ts new file mode 100644 index 00000000000..e1e0f9c28ef --- /dev/null +++ b/src/main/runtime/terminal-model-query-authority.ts @@ -0,0 +1,111 @@ +/** + * Phase 5 of the terminal model/view architecture: main-side terminal query + * authority (docs/reference/terminal-query-authority.md). + * + * The delivery decision is the reply decision: main answers a query iff the + * hidden-delivery gate dropped the chunk that carried it. This module owns + * the responder kill-switch predicate and the main-side mirror of the + * renderer's native-Windows-ConPTY determination, recorded per PTY at spawn + * so the runtime emulator can register the DA1 override before byte zero. + */ +import type { GlobalSettings } from '../../shared/types' +import { isWslUncPath } from '../../shared/wsl-paths' +import { + isHiddenPtyDeliveryGateEnabled, + shouldDropHiddenRendererPtyData +} from '../ipc/pty-hidden-delivery-gate' + +export type TerminalModelQueryAuthoritySettings = Pick< + GlobalSettings, + 'terminalMainSideEffectAuthority' | 'terminalHiddenDeliveryGate' | 'terminalModelQueryAuthority' +> + +/** Responder kill switch: requires BOTH Phase-4 gate switches (no marks/drops + * exist without them) plus the Phase-5-specific independent off switch. */ +export function isTerminalModelQueryAuthorityEnabled( + settings: TerminalModelQueryAuthoritySettings | null | undefined +): boolean { + return isHiddenPtyDeliveryGateEnabled(settings) && settings?.terminalModelQueryAuthority !== false +} + +/** Per-chunk reply-ownership predicate, evaluated once at ingestion in + * OrcaRuntimeService.onPtyData — the same module state and tick as the + * hidden-gate drop sites, so "chunk dropped" and "main answers" cannot + * diverge for live chunks. Remote view subscribers (mobile/web/remote + * desktop xterms on the multiplexed stream) keep view authority, so main + * yields while one is attached. */ +export function shouldModelAnswerHiddenPtyQueries(opts: { + ptyId: string + settings: TerminalModelQueryAuthoritySettings | null | undefined + hasRemoteViewSubscriber: boolean +}): boolean { + return ( + isTerminalModelQueryAuthorityEnabled(opts.settings) && + !opts.hasRemoteViewSubscriber && + shouldDropHiddenRendererPtyData(opts.ptyId, opts.settings) + ) +} + +/** Main-side mirror of the renderer's isLocalNativeWindowsPty + * (windows-pty-compatibility.ts), computed from spawn-time facts: local or + * daemon provider (no SSH connection), win32 host, and not a WSL shell. */ +export function isNativeWindowsLocalPtySpawn(opts: { + connectionId: string | null | undefined + cwd: string | null | undefined + shellOverride: string | null | undefined + platform?: NodeJS.Platform +}): boolean { + if ((opts.platform ?? process.platform) !== 'win32') { + return false + } + if (opts.connectionId) { + return false + } + if (isWslUncPath(opts.cwd ?? '')) { + return false + } + if (/(?:^|[/\\])wsl(?:\.exe)?$/i.test(opts.shellOverride ?? '')) { + return false + } + return true +} + +// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts records +// the determination at spawn, the runtime consults it at emulator creation. +// Daemon-adopted PTYs from a previous app run carry no mark — acceptable: +// ConPTY's blocking DA1 only fires at spawn, which happened in a prior life. +const nativeWindowsConptyPtys = new Set() + +// Why installers: the mark lands after the awaited spawn response, but daemon +// stream data (warm-reattach flush) can lazy-create the runtime emulator +// first. The runtime registers an installer so marking retrofits the DA1 +// override onto an existing emulator; installation is idempotent emulator-side. +type ConptyDa1OverrideInstaller = (ptyId: string) => void +const conptyDa1OverrideInstallers = new Set() + +export function registerConptyDa1OverrideInstaller(installer: ConptyDa1OverrideInstaller): void { + conptyDa1OverrideInstallers.add(installer) +} + +export function markNativeWindowsConptyPty(id: string): void { + nativeWindowsConptyPtys.add(id) + for (const installer of conptyDa1OverrideInstallers) { + installer(id) + } +} + +export function isNativeWindowsConptyPty(id: string): boolean { + return nativeWindowsConptyPtys.has(id) +} + +/** Wired into clearProviderPtyState so every PTY teardown path releases the + * spawn record. */ +export function clearNativeWindowsConptyPty(id: string): void { + nativeWindowsConptyPtys.delete(id) +} + +/** Test seam: reset module state between tests. */ +export function _resetTerminalModelQueryAuthorityForTest(): void { + nativeWindowsConptyPtys.clear() + conptyDa1OverrideInstallers.clear() +} diff --git a/src/main/runtime/terminal-query-responder.test.ts b/src/main/runtime/terminal-query-responder.test.ts new file mode 100644 index 00000000000..b6988e06731 --- /dev/null +++ b/src/main/runtime/terminal-query-responder.test.ts @@ -0,0 +1,827 @@ +/** + * Phase 5 model query responder (docs/reference/terminal-query-authority.md): + * reply parity through the runtime emulator, the per-chunk ownership matrix, + * the main-side replay guard, and the ingestion-time capture race. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { OrcaRuntimeService } from './orca-runtime' +import { HeadlessEmulator } from '../daemon/headless-emulator' +import { + _resetHiddenRendererPtyDeliveryGateForTest, + markHiddenRendererPty, + setRendererPtyDeliveryInterest, + unmarkHiddenRendererPty +} from '../ipc/pty-hidden-delivery-gate' +import { + _resetTerminalModelQueryAuthorityForTest, + markNativeWindowsConptyPty +} from './terminal-model-query-authority' +import { + _resetTerminalViewAttributesForTest, + setTerminalViewAttributes +} from './terminal-view-attribute-store' +import type { TerminalViewAttributes, TerminalViewRgb } from '../../shared/terminal-view-attributes' + +const settingsState = { + terminalMainSideEffectAuthority: true as boolean, + terminalHiddenDeliveryGate: true as boolean, + terminalModelQueryAuthority: true as boolean +} + +const store = { + getRepo: () => undefined, + getRepos: () => [], + addRepo: () => {}, + updateRepo: () => undefined as never, + getAllWorktreeMeta: () => ({}), + getWorktreeMeta: () => undefined, + setWorktreeMeta: () => undefined as never, + removeWorktreeMeta: () => {}, + getGitHubCache: () => ({ pr: {}, issue: {} }) as never, + getSettings: () => ({ + workspaceDir: '/tmp/workspaces', + nestWorkspaces: false, + refreshLocalBaseRefOnWorktreeCreate: false, + branchPrefix: 'none', + branchPrefixCustom: '', + terminalMainSideEffectAuthority: settingsState.terminalMainSideEffectAuthority, + terminalHiddenDeliveryGate: settingsState.terminalHiddenDeliveryGate, + terminalModelQueryAuthority: settingsState.terminalModelQueryAuthority + }) +} + +type RendererBufferStub = { data: string; cols: number; rows: number } + +function createResponderRuntime(opts: { rendererBuffer?: RendererBufferStub } = {}) { + const runtime = new OrcaRuntimeService(store) + const replies: { ptyId: string; data: string }[] = [] + runtime.setPtyController({ + write: (ptyId, data) => { + replies.push({ ptyId, data }) + return true + }, + kill: () => true, + getForegroundProcess: async () => null, + getSize: () => ({ cols: 80, rows: 24 }), + resize: () => true, + ...(opts.rendererBuffer + ? { + hasRendererSerializer: () => true, + serializeBuffer: async () => opts.rendererBuffer ?? null + } + : {}) + }) + return { runtime, replies } +} + +/** Awaits the per-PTY emulator writeChain so queued chunk links (and the + * replies they forward) have settled. */ +async function settle(runtime: OrcaRuntimeService, ptyId: string): Promise { + await runtime.serializeMainTerminalBuffer(ptyId) +} + +/** Renderer-pushed attribute snapshot with distinct, pinned slot values so + * reply fixtures cannot pass by coincidence. */ +function viewAttributes(overrides: Partial = {}): TerminalViewAttributes { + const ansi = Array.from( + { length: 256 }, + (_, i) => [i, (i * 2) % 256, (i * 3) % 256] as TerminalViewRgb + ) + ansi[1] = [0xcc, 0x00, 0x00] + return { + foreground: [0xd0, 0xd0, 0xd0], + background: [0x1e, 0x1e, 0x2e], + cursor: [0xff, 0x99, 0x00], + ansi, + colorSchemeMode: 'dark', + cursorStyle: 'bar', + cursorBlink: true, + ...overrides + } +} + +afterEach(() => { + _resetHiddenRendererPtyDeliveryGateForTest() + _resetTerminalModelQueryAuthorityForTest() + _resetTerminalViewAttributesForTest() + settingsState.terminalMainSideEffectAuthority = true + settingsState.terminalHiddenDeliveryGate = true + settingsState.terminalModelQueryAuthority = true +}) + +describe('reply parity for hidden-dropped chunks', () => { + // Expected replies pinned from the design doc and verified against the + // bundled @xterm/headless build — the same core the renderer runs, so + // parity is structural for static and model-state classes. + it.each([ + ['DA1 CSI c', '\x1b[c', ['\x1b[?1;2c']], + ['DA1 CSI 0 c variant', '\x1b[0c', ['\x1b[?1;2c']], + ['DA2', '\x1b[>c', ['\x1b[>0;276;0c']], + ['DSR 5n operating status', '\x1b[5n', ['\x1b[0n']], + ['CPR 6n at origin', '\x1b[6n', ['\x1b[1;1R']], + ['CPR 6n reports the model cursor position', 'hello\r\nworld\x1b[6n', ['\x1b[2;6R']], + ['DECXCPR ?6n', '\x1b[?6n', ['\x1b[?1;1R']], + ['DECRPM ?1 DECCKM default', '\x1b[?1$p', ['\x1b[?1;2$y']], + ['DECRPM ?6 DECOM default', '\x1b[?6$p', ['\x1b[?6;2$y']], + ['DECRPM ?7 DECAWM default', '\x1b[?7$p', ['\x1b[?7;1$y']], + ['DECRPM ?25 DECTCEM default', '\x1b[?25$p', ['\x1b[?25;1$y']], + ['DECRPM ?1004 focus events default', '\x1b[?1004$p', ['\x1b[?1004;2$y']], + ['DECRPM ?1006 SGR mouse default', '\x1b[?1006$p', ['\x1b[?1006;2$y']], + ['DECRPM ?1016 SGR pixels default', '\x1b[?1016$p', ['\x1b[?1016;2$y']], + ['DECRPM ?1049 alt screen default', '\x1b[?1049$p', ['\x1b[?1049;2$y']], + ['DECRPM ?2004 bracketed paste default', '\x1b[?2004$p', ['\x1b[?2004;2$y']], + ['DECRPM ?2026 synchronized output default', '\x1b[?2026$p', ['\x1b[?2026;2$y']], + ['DECRPM reports a set mode as enabled', '\x1b[?2004h\x1b[?2004$p', ['\x1b[?2004;1$y']], + ['DECRPM unknown mode reports 0', '\x1b[?12345$p', ['\x1b[?12345;0$y']], + ['DECRQM ANSI insert mode', '\x1b[4$p', ['\x1b[4;2$y']], + ['DECRQSS DECSTBM default margins', '\x1bP$qr\x1b\\', ['\x1bP1$r1;24r\x1b\\']], + ['DECRQSS DECSTBM after margin set', '\x1b[5;20r\x1bP$qr\x1b\\', ['\x1bP1$r5;20r\x1b\\']], + ['DECRQSS DECSCUSR default cursor', '\x1bP$q q\x1b\\', ['\x1bP1$r2 q\x1b\\']], + ['DECRQSS DECSCA', '\x1bP$q"q\x1b\\', ['\x1bP1$r0"q\x1b\\']], + ['DECRQSS SGR', '\x1bP$qm\x1b\\', ['\x1bP1$r0m\x1b\\']], + ['XTVERSION', '\x1b[>0q', ['\x1bP>|xterm.js(6.0.0)\x1b\\']], + ['kitty CSI ? u default flags', '\x1b[?u', ['\x1b[?0u']], + ['kitty CSI ? u reports pushed flags', '\x1b[=5;1u\x1b[?u', ['\x1b[?5u']] + ])('%s', async (_label, chunk, expectedReplies) => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-q') + + runtime.onPtyData('pty-q', chunk, Date.now()) + await settle(runtime, 'pty-q') + + expect(replies.map((reply) => reply.data)).toEqual(expectedReplies) + expect(replies.every((reply) => reply.ptyId === 'pty-q')).toBe(true) + }) + + it.each([ + ['XTWINOPS', '\x1b[14t'], + ['XTGETTCAP', '\x1bP+q544e\x1b\\'], + ['DSR ?15n printer status', '\x1b[?15n'], + ['DSR ?25n UDK status', '\x1b[?25n'], + ['DSR ?26n keyboard status', '\x1b[?26n'], + ['DSR ?53n locator status', '\x1b[?53n'], + // View-attribute class: silent until the slice-2 renderer attribute push + // — a fabricated default would resurrect the default-black OSC-11 bug. + ['OSC 10 foreground query', '\x1b]10;?\x07'], + ['OSC 11 background query', '\x1b]11;?\x07'], + ['OSC 12 cursor-color query', '\x1b]12;?\x1b\\'], + ['OSC 4 palette query', '\x1b]4;1;?\x07'], + ['DSR ?996n color-scheme query', '\x1b[?996n'] + ])('stays silent for %s', async (_label, chunk) => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-q') + + runtime.onPtyData('pty-q', chunk, Date.now()) + await settle(runtime, 'pty-q') + + expect(replies).toEqual([]) + }) +}) + +describe('reply ownership matrix', () => { + const DA1 = '\x1b[c' + + it('never answers delivered (unmarked) chunks — the visible xterm owns them', async () => { + const { runtime, replies } = createResponderRuntime() + + runtime.onPtyData('pty-v', DA1, Date.now()) + await settle(runtime, 'pty-v') + + expect(replies).toEqual([]) + }) + + it('never answers while renderer delivery interest holds the chunk delivered', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-i') + setRendererPtyDeliveryInterest('pty-i', true) + + runtime.onPtyData('pty-i', DA1, Date.now()) + await settle(runtime, 'pty-i') + + expect(replies).toEqual([]) + }) + + it.each([ + ['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)], + ['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)], + [ + 'terminalMainSideEffectAuthority', + () => (settingsState.terminalMainSideEffectAuthority = false) + ] + ])('never answers with kill switch %s off', async (_label, flip) => { + flip() + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-k') + + runtime.onPtyData('pty-k', DA1, Date.now()) + await settle(runtime, 'pty-k') + + expect(replies).toEqual([]) + }) + + it('yields while a remote view subscriber is attached and resumes on release', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-r') + const release = runtime.registerRemoteTerminalViewSubscriber('pty-r') + + runtime.onPtyData('pty-r', DA1, Date.now()) + await settle(runtime, 'pty-r') + expect(replies).toEqual([]) + + release() + // Releases are idempotent: a double release must not unbalance the count. + release() + runtime.onPtyData('pty-r', DA1, Date.now()) + await settle(runtime, 'pty-r') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c']) + }) + + it('counts overlapping remote view subscribers', () => { + const { runtime } = createResponderRuntime() + const releaseA = runtime.registerRemoteTerminalViewSubscriber('pty-m') + const releaseB = runtime.registerRemoteTerminalViewSubscriber('pty-m') + expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(true) + releaseA() + expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(true) + releaseB() + expect(runtime.hasRemoteTerminalViewSubscriber('pty-m')).toBe(false) + }) + + it('treats mobile subscriber records as remote view subscribers', async () => { + const { runtime } = createResponderRuntime() + await runtime.handleMobileSubscribe('pty-mob', 'client-1', { cols: 40, rows: 20 }) + expect(runtime.hasRemoteTerminalViewSubscriber('pty-mob')).toBe(true) + }) + + it('answers a dropped-chunk query exactly once', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-once') + + runtime.onPtyData('pty-once', DA1, Date.now()) + await settle(runtime, 'pty-once') + + expect(replies).toHaveLength(1) + }) +}) + +describe('main-side replay guard', () => { + const DA1 = '\x1b[c' + + it('never answers queries embedded in a seeded snapshot, then answers live bytes', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-seed') + + runtime.seedHeadlessTerminal('pty-seed', `restored prompt${DA1}`) + await settle(runtime, 'pty-seed') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-seed', DA1, Date.now()) + await settle(runtime, 'pty-seed') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c']) + }) + + it('never answers queries replayed by renderer-buffer hydration', async () => { + const { runtime, replies } = createResponderRuntime({ + rendererBuffer: { data: `restored screen${DA1}`, cols: 80, rows: 24 } + }) + markHiddenRendererPty('pty-hyd') + + // First live byte triggers maybeHydrateHeadlessFromRenderer; the hydration + // seed parses the embedded DA1 but must not forward its reply. + runtime.onPtyData('pty-hyd', 'live output', Date.now()) + await settle(runtime, 'pty-hyd') + + expect(replies).toEqual([]) + }) +}) + +describe('kitty flag re-seed parity (terminal-query-authority.md §kitty)', () => { + it('answers ?u with the persisted snapshot flags after a re-seed, silently applied', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-kitty') + + // Daemon warm-reattach threads modes.kittyKeyboardFlags through the + // spawn result into the seed; applying them is a seed-side write and + // must answer no one (main-side replay guard). + runtime.seedHeadlessTerminal('pty-kitty', 'restored prompt', undefined, { + kittyKeyboardFlags: 5 + }) + await settle(runtime, 'pty-kitty') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-kitty', '\x1b[?u', Date.now()) + await settle(runtime, 'pty-kitty') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?5u']) + }) + + it('answers ?0u when the snapshot carried no flags (fresh-shell paths)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-kitty0') + + runtime.seedHeadlessTerminal('pty-kitty0', 'restored prompt') + runtime.onPtyData('pty-kitty0', '\x1b[?u', Date.now()) + await settle(runtime, 'pty-kitty0') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?0u']) + }) +}) + +describe('ingestion-time ownership capture', () => { + const DA1 = '\x1b[c' + + it('still answers when the hidden mark flips off between ingestion and the async write', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-race') + + runtime.onPtyData('pty-race', DA1, Date.now()) + // Flip before the queued writeChain link runs: the captured decision wins. + unmarkHiddenRendererPty('pty-race') + await settle(runtime, 'pty-race') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?1;2c']) + }) + + it('stays silent when the hidden mark lands after ingestion', async () => { + const { runtime, replies } = createResponderRuntime() + + runtime.onPtyData('pty-race2', DA1, Date.now()) + markHiddenRendererPty('pty-race2') + await settle(runtime, 'pty-race2') + + expect(replies).toEqual([]) + }) +}) + +describe('stale writeChain links after dispose', () => { + const DA1 = '\x1b[c' + + it('never forwards a queued reply once the PTY state is disposed', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-stale') + + // Queue a forward-flagged chain link, then dispose before it runs. + runtime.onPtyData('pty-stale', DA1, Date.now()) + runtime.onPtyExit('pty-stale', 0) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(replies).toEqual([]) + }) + + it('never injects a stale reply into a successor PTY reusing the session id', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-reuse') + + // Daemon respawns reuse session ids: dispose with the flagged link still + // queued, then re-create the same id before the link runs. + runtime.onPtyData('pty-reuse', DA1, Date.now()) + runtime.onPtyExit('pty-reuse', 0) + runtime.onPtyData('pty-reuse', 'fresh shell banner', Date.now()) + await settle(runtime, 'pty-reuse') + + expect(replies).toEqual([]) + }) +}) + +describe('ConPTY DA1 override', () => { + it('retrofits the override when the spawn mark lands after data created the emulator', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-win-late') + + // Daemon warm-reattach flush: stream data creates the emulator before + // the awaited spawn response marks the PTY native-Windows. + runtime.onPtyData('pty-win-late', 'warm reattach flush', Date.now()) + markNativeWindowsConptyPty('pty-win-late') + + runtime.onPtyData('pty-win-late', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win-late') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c']) + }) + + it('keeps the override single-reply when installed at creation and marked again', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win-idem') + markHiddenRendererPty('pty-win-idem') + + runtime.onPtyData('pty-win-idem', 'boot output', Date.now()) + // A duplicate mark (e.g. respawn against a live emulator) must not stack + // a second handler that double-replies. + markNativeWindowsConptyPty('pty-win-idem') + + runtime.onPtyData('pty-win-idem', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win-idem') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c']) + }) + + it('answers CSI ?61;4c for marked native-Windows PTYs, suppressing the core ?1;2c', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win') + markHiddenRendererPty('pty-win') + + runtime.onPtyData('pty-win', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?61;4c']) + }) + + it('lets non-primary device-attribute queries fall through to the core', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win2') + markHiddenRendererPty('pty-win2') + + runtime.onPtyData('pty-win2', '\x1b[>c', Date.now()) + await settle(runtime, 'pty-win2') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[>0;276;0c']) + }) + + it('keeps the override silent for delivered chunks', async () => { + const { runtime, replies } = createResponderRuntime() + markNativeWindowsConptyPty('pty-win3') + + runtime.onPtyData('pty-win3', '\x1b[c', Date.now()) + await settle(runtime, 'pty-win3') + + expect(replies).toEqual([]) + }) +}) + +describe('HeadlessEmulator forwarding window', () => { + it('forwards replies only for writes flagged forwardQueryReplies', async () => { + const onQueryReply = vi.fn() + const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply }) + try { + await emulator.write('\x1b[c') + expect(onQueryReply).not.toHaveBeenCalled() + + await emulator.write('\x1b[c', { forwardQueryReplies: true }) + expect(onQueryReply).toHaveBeenCalledTimes(1) + expect(onQueryReply).toHaveBeenCalledWith('\x1b[?1;2c') + } finally { + emulator.dispose() + } + }) + + it('scopes the async-fallback forwarding window to the flagged chunk parse', async () => { + const onQueryReply = vi.fn() + const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply }) + // Force the async write path (xterm deprecates writeSync; the fallback + // must stay structurally safe without writeChain serialization). + const internals = emulator as unknown as { terminal: { _core: { writeSync?: unknown } } } + internals.terminal._core.writeSync = undefined + try { + // Enqueue an unflagged seed carrying a query, then a flagged live + // chunk, WITHOUT awaiting between them: both sit in xterm's write + // queue together. The seed parse must not see an open window. + const seed = emulator.write('seeded\x1b[c') + const live = emulator.write('\x1b[5n', { forwardQueryReplies: true }) + await Promise.all([seed, live]) + + expect(onQueryReply.mock.calls.map((call) => call[0])).toEqual(['\x1b[0n']) + } finally { + emulator.dispose() + } + }) + + it('keeps the ConPTY override inside the forwarding window', async () => { + const onQueryReply = vi.fn() + const emulator = new HeadlessEmulator({ cols: 80, rows: 24, onQueryReply }) + emulator.installConptyPrimaryDeviceAttributesOverride() + try { + // Unflagged (replayed/seeded) DA1 must answer no one even with the + // override installed. + await emulator.write('\x1b[c') + expect(onQueryReply).not.toHaveBeenCalled() + + await emulator.write('\x1b[c', { forwardQueryReplies: true }) + expect(onQueryReply).toHaveBeenCalledTimes(1) + expect(onQueryReply).toHaveBeenCalledWith('\x1b[?61;4c') + } finally { + emulator.dispose() + } + }) +}) + +describe('view-attribute bridge replies (after renderer push)', () => { + // Reply bytes pinned to the renderer xterm's format: OSC replies use the + // queried ident, 16-bit doubled-byte channels, and ST termination + // (CoreBrowserTerminal._handleColorEvent + toRgbString); ?996n answers with + // the contour 997 report, same bytes as mode2031SequenceFor. + it.each([ + ['OSC 10 foreground', '\x1b]10;?\x07', ['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\']], + ['OSC 11 background', '\x1b]11;?\x07', ['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']], + ['OSC 12 cursor color', '\x1b]12;?\x1b\\', ['\x1b]12;rgb:ffff/9999/0000\x1b\\']], + ['OSC 4 named palette slot', '\x1b]4;1;?\x07', ['\x1b]4;1;rgb:cccc/0000/0000\x1b\\']], + ['OSC 4 extended palette slot', '\x1b]4;196;?\x07', ['\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\']], + [ + 'OSC 4 multiple slots in one sequence', + '\x1b]4;1;?;196;?\x07', + ['\x1b]4;1;rgb:cccc/0000/0000\x1b\\', '\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\'] + ], + [ + 'OSC 10 stacked params report foreground then background', + '\x1b]10;?;?\x07', + ['\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\', '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\'] + ], + ['DSR ?996n dark', '\x1b[?996n', ['\x1b[?997;1n']], + ['DECRQSS DECSCUSR from pushed cursor options', '\x1bP$q q\x1b\\', ['\x1bP1$r5 q\x1b\\']], + ['DECRQM ?12 from pushed cursorBlink', '\x1b[?12$p', ['\x1b[?12;1$y']] + ])('%s', async (_label, chunk, expectedReplies) => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-view') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-view', chunk, Date.now()) + await settle(runtime, 'pty-view') + + expect(replies.map((reply) => reply.data)).toEqual(expectedReplies) + }) + + it('answers ?996n from palette luminance, not the pushed app mode (dark palette, light app mode)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-dark') + // Supported divergence: light app mode with terminalUseSeparateLightTheme + // off renders a dark terminal theme. A visible xterm answers ?996n from + // bg/fg relative luminance (CoreBrowserTerminal._reportColorScheme), so + // the hidden reply must say dark here too. + setTerminalViewAttributes(viewAttributes({ colorSchemeMode: 'light' })) + + runtime.onPtyData('pty-lum-dark', '\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-dark') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;1n']) + }) + + it('answers ?996n light for a light palette regardless of the pushed app mode', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-light') + setTerminalViewAttributes( + viewAttributes({ + foreground: [0x33, 0x33, 0x33], + background: [0xfa, 0xfa, 0xfa], + colorSchemeMode: 'dark' + }) + ) + + runtime.onPtyData('pty-lum-light', '\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-light') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n']) + }) + + it('answers ?996n from OSC-SET-mutated colors like a visible xterm', async () => { + // _reportColorScheme reads the CURRENT theme-service colors, which include + // OSC 10/11 SET mutations — the per-PTY overlays layer the same way. + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-lum-set') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-lum-set', '\x1b]11;#ffffff\x07\x1b]10;#101010\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-lum-set') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b[?997;2n']) + }) + + it('stays silent before the first push, then answers the same query after it', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-first') + + runtime.onPtyData('pty-first', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-first') + // No fabricated defaults: silence is the documented hidden status quo. + expect(replies).toEqual([]) + + setTerminalViewAttributes(viewAttributes()) + runtime.onPtyData('pty-first', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-first') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it('retrofits cursor options onto already-live emulators when the push lands late', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-late') + + // Emulator exists before any push: core default DECSCUSR is steady block. + runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now()) + await settle(runtime, 'pty-late') + expect(replies.map((reply) => reply.data)).toEqual(['\x1bP1$r2 q\x1b\\']) + + setTerminalViewAttributes(viewAttributes({ cursorStyle: 'underline', cursorBlink: false })) + runtime.onPtyData('pty-late', '\x1bP$q q\x1b\\', Date.now()) + await settle(runtime, 'pty-late') + expect(replies.map((reply) => reply.data).at(-1)).toBe('\x1bP1$r4 q\x1b\\') + }) +}) + +describe('per-PTY OSC color SET layering', () => { + it('layers an OSC 4 SET over the pushed base, isolated per PTY', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-a') + markHiddenRendererPty('pty-b') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-a', '\x1b]4;1;rgb:00/ff/00\x07\x1b]4;1;?\x07', Date.now()) + runtime.onPtyData('pty-b', '\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-a') + await settle(runtime, 'pty-b') + + expect(replies).toEqual([ + { ptyId: 'pty-a', data: '\x1b]4;1;rgb:0000/ffff/0000\x1b\\' }, + { ptyId: 'pty-b', data: '\x1b]4;1;rgb:cccc/0000/0000\x1b\\' } + ]) + }) + + it('restores a single indexed color via OSC 104;', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-104') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-104', '\x1b]4;1;#00ff00\x07\x1b]104;1\x07\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-104') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:cccc/0000/0000\x1b\\']) + }) + + it('restores the whole indexed table via bare OSC 104', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-104all') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData( + 'pty-104all', + '\x1b]4;1;#00ff00;196;#0000ff\x07\x1b]104\x07\x1b]4;1;?;196;?\x07', + Date.now() + ) + await settle(runtime, 'pty-104all') + + expect(replies.map((reply) => reply.data)).toEqual([ + '\x1b]4;1;rgb:cccc/0000/0000\x1b\\', + '\x1b]4;196;rgb:c4c4/8888/4c4c\x1b\\' + ]) + }) + + it('layers OSC 10/11/12 SETs and restores them via 110/111/112', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-special') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData( + 'pty-special', + '\x1b]10;#010203\x07\x1b]11;rgb:ff/ff/ff\x07\x1b]12;#0a0b0c\x07' + + '\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07' + + '\x1b]110\x07\x1b]111\x07\x1b]112\x07' + + '\x1b]10;?\x07\x1b]11;?\x07\x1b]12;?\x07', + Date.now() + ) + await settle(runtime, 'pty-special') + + expect(replies.map((reply) => reply.data)).toEqual([ + '\x1b]10;rgb:0101/0202/0303\x1b\\', + '\x1b]11;rgb:ffff/ffff/ffff\x1b\\', + '\x1b]12;rgb:0a0a/0b0b/0c0c\x1b\\', + '\x1b]10;rgb:d0d0/d0d0/d0d0\x1b\\', + '\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\', + '\x1b]12;rgb:ffff/9999/0000\x1b\\' + ]) + }) + + it('tracks SET mutations parsed from a seed without replying, like renderer replay', async () => { + // Cold-restore scrollback replayed into a visible renderer xterm re-applies + // OSC SETs to its theme service; the model mirrors that state — but the + // replay guard still keeps the seed from ANSWERING anything. + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-seedset') + setTerminalViewAttributes(viewAttributes()) + + runtime.seedHeadlessTerminal('pty-seedset', 'restored\x1b]4;1;#00ff00\x07\x1b]4;1;?\x07') + await settle(runtime, 'pty-seedset') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-seedset', '\x1b]4;1;?\x07', Date.now()) + await settle(runtime, 'pty-seedset') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]4;1;rgb:0000/ffff/0000\x1b\\']) + }) + + it('preserves per-PTY overrides on an identical re-push (fresh renderer process)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-idem') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-idem', '\x1b]11;#ffffff\x07', Date.now()) + await settle(runtime, 'pty-idem') + + // A second window / renderer reload / macOS re-activation re-pushes + // byte-identical attributes (its publisher dedupe is per-process). That is + // not a theme apply, so the OSC SET overlay must survive. + setTerminalViewAttributes(viewAttributes()) + runtime.onPtyData('pty-idem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-idem') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:ffff/ffff/ffff\x1b\\']) + }) + + it('clears per-PTY overrides when a new push lands (theme apply parity)', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-clear') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-clear', '\x1b]11;#ffffff\x07', Date.now()) + await settle(runtime, 'pty-clear') + + // A theme apply overwrites OSC-SET-mutated colors on visible panes too + // (ThemeService._setTheme), so the model mirrors that on every CHANGED + // push (identical re-pushes are filtered — see the test above). + setTerminalViewAttributes(viewAttributes({ background: [0x10, 0x20, 0x30] })) + runtime.onPtyData('pty-clear', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-clear') + + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1010/2020/3030\x1b\\']) + }) +}) + +describe('view-attribute replay guard and suppression', () => { + it('never answers view-attribute queries embedded in a seeded snapshot', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vseed') + setTerminalViewAttributes(viewAttributes()) + + runtime.seedHeadlessTerminal('pty-vseed', 'prompt\x1b]11;?\x07\x1b[?996n') + await settle(runtime, 'pty-vseed') + expect(replies).toEqual([]) + + runtime.onPtyData('pty-vseed', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vseed') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it('never answers view-attribute queries replayed by renderer-buffer hydration', async () => { + const { runtime, replies } = createResponderRuntime({ + rendererBuffer: { data: 'restored\x1b]11;?\x07\x1b[?996n', cols: 80, rows: 24 } + }) + markHiddenRendererPty('pty-vhyd') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vhyd', 'live output', Date.now()) + await settle(runtime, 'pty-vhyd') + + expect(replies).toEqual([]) + }) + + it('never answers a delivered (unmarked) view-attribute query — the visible xterm owns it', async () => { + const { runtime, replies } = createResponderRuntime() + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vvis', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-vvis') + + expect(replies).toEqual([]) + }) + + it('never answers while renderer delivery interest holds the chunk delivered', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vint') + setRendererPtyDeliveryInterest('pty-vint', true) + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vint', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vint') + + expect(replies).toEqual([]) + }) + + it('yields view-attribute replies while a remote view subscriber is attached', async () => { + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vrem') + setTerminalViewAttributes(viewAttributes()) + const release = runtime.registerRemoteTerminalViewSubscriber('pty-vrem') + + runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vrem') + expect(replies).toEqual([]) + + release() + runtime.onPtyData('pty-vrem', '\x1b]11;?\x07', Date.now()) + await settle(runtime, 'pty-vrem') + expect(replies.map((reply) => reply.data)).toEqual(['\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\']) + }) + + it.each([ + ['terminalModelQueryAuthority', () => (settingsState.terminalModelQueryAuthority = false)], + ['terminalHiddenDeliveryGate', () => (settingsState.terminalHiddenDeliveryGate = false)], + [ + 'terminalMainSideEffectAuthority', + () => (settingsState.terminalMainSideEffectAuthority = false) + ] + ])('never answers view-attribute queries with kill switch %s off', async (_label, flip) => { + flip() + const { runtime, replies } = createResponderRuntime() + markHiddenRendererPty('pty-vkill') + setTerminalViewAttributes(viewAttributes()) + + runtime.onPtyData('pty-vkill', '\x1b]11;?\x07\x1b[?996n', Date.now()) + await settle(runtime, 'pty-vkill') + + expect(replies).toEqual([]) + }) +}) diff --git a/src/main/runtime/terminal-view-attribute-store.ts b/src/main/runtime/terminal-view-attribute-store.ts new file mode 100644 index 00000000000..ee55581bfe9 --- /dev/null +++ b/src/main/runtime/terminal-view-attribute-store.ts @@ -0,0 +1,57 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): main-side cache of the renderer's `pty:terminalViewAttributes` + * push. One app-global snapshot, not per-PTY — per-pane font zoom never + * affects these attributes and the color/cursor settings are global. + * + * Null until the first push, and the responder answers NO view-attribute + * query while null (silent-until-first-push): a fabricated default would + * resurrect the default-black OSC-11 bug. Staleness is bounded by one IPC + * hop; subscribed TUIs are corrected by the renderer-owned 2031/997 flip. + */ +import { + terminalViewAttributesEqual, + type TerminalViewAttributes +} from '../../shared/terminal-view-attributes' + +// Why module state (pattern of pty-hidden-delivery-gate.ts): pty.ts receives +// the push, the runtime emulators consult it at reply time via the getter. +let currentAttributes: TerminalViewAttributes | null = null + +// Why appliers (pattern of registerConptyDa1OverrideInstaller): each push +// must also reach already-live emulators — cursor options under the replay +// guard, plus the per-PTY override reset a theme apply implies. +type TerminalViewAttributesApplier = (attributes: TerminalViewAttributes) => void +const pushAppliers = new Set() + +export function registerTerminalViewAttributesApplier( + applier: TerminalViewAttributesApplier +): void { + pushAppliers.add(applier) +} + +/** Called from the pty:terminalViewAttributes IPC handler with a validated + * payload. Last push wins (replies always use the freshest snapshot). */ +export function setTerminalViewAttributes(attributes: TerminalViewAttributes): void { + // Why idempotent: the renderer publisher's dedupe is per-process, so a + // fresh renderer (second window, reload, macOS re-activation) re-pushes + // identical attributes. That is not a theme apply — fanning out would wipe + // every PTY's OSC SET overlay while visible panes keep theirs. + if (currentAttributes && terminalViewAttributesEqual(currentAttributes, attributes)) { + return + } + currentAttributes = attributes + for (const applier of pushAppliers) { + applier(attributes) + } +} + +export function getTerminalViewAttributes(): TerminalViewAttributes | null { + return currentAttributes +} + +/** Test seam: reset module state between tests. */ +export function _resetTerminalViewAttributesForTest(): void { + currentAttributes = null + pushAppliers.clear() +} diff --git a/src/main/ssh/ssh-relay-session-test-fixtures.ts b/src/main/ssh/ssh-relay-session-test-fixtures.ts new file mode 100644 index 00000000000..0b4e8188c07 --- /dev/null +++ b/src/main/ssh/ssh-relay-session-test-fixtures.ts @@ -0,0 +1,48 @@ +import { vi, type Mock } from 'vitest' +import type { BrowserWindow } from 'electron' +import type { SshConnection } from './ssh-connection' +import type { Store } from '../persistence' +import type { SshPortForwardManager } from './ssh-port-forward' +import { deployAndLaunchRelay } from './ssh-relay-deploy' + +type SshRelaySessionTestDeps = { + mockConn: SshConnection + mockStore: Store + mockPortForward: SshPortForwardManager + getMainWindow: Mock<() => BrowserWindow | null> + mockWindow: BrowserWindow +} + +export function createMockDeps(): SshRelaySessionTestDeps { + const mockConn = {} as SshConnection + const mockStore = { + getRepos: vi.fn().mockReturnValue([]), + getSshRemotePtyLeases: vi.fn().mockReturnValue([]), + markSshRemotePtyLease: vi.fn(), + markSshRemotePtyLeases: vi.fn() + } as unknown as Store + const mockPortForward = { + removeAllForwards: vi.fn() + } as unknown as SshPortForwardManager + const mockWindow = { + isDestroyed: () => false, + // Why: the port scanner visibility-gates its ticks; a visible mock window + // keeps establish-path tests exercising the scan-on-ready behavior. + isVisible: () => true, + isMinimized: () => false, + webContents: { send: vi.fn() } + } as unknown as BrowserWindow + const getMainWindow = vi.fn().mockReturnValue(mockWindow) + return { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } +} + +export function mockDeploySuccess(): void { + vi.mocked(deployAndLaunchRelay).mockResolvedValue({ + transport: { + write: vi.fn(), + onData: vi.fn(), + onClose: vi.fn() + }, + platform: 'linux-x64' + }) +} diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index aebfd234b5c..82cbfaee42c 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -1,10 +1,9 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' import { SshRelaySession } from './ssh-relay-session' import type { SshConnection } from './ssh-connection' -import type { Store } from '../persistence' -import type { SshPortForwardManager } from './ssh-port-forward' import { AGENT_HOOK_INSTALL_PLUGINS_METHOD } from '../../shared/agent-hook-relay' import { SSH_RELAY_CONFIGURE_GRACE_TIME_METHOD } from '../../shared/ssh-types' +import { createMockDeps, mockDeploySuccess } from './ssh-relay-session-test-fixtures' const { muxRequestMock, installRemoteManagedAgentHooksMock } = vi.hoisted(() => ({ muxRequestMock: vi.fn(), @@ -90,6 +89,12 @@ vi.mock('../providers/ssh-git-dispatch', () => ({ })) const { deployAndLaunchRelay } = await import('./ssh-relay-deploy') +// Why: the hidden-delivery gate module is intentionally real (pure state, no +// electron deps) so the SSH parity tests exercise the same gate main uses. +const { markHiddenRendererPty, setRendererPtyDeliveryInterest } = + await import('../ipc/pty-hidden-delivery-gate') +const { _resetHiddenRendererPtyDeliveryGateForTest } = + await import('../ipc/pty-hidden-delivery-gate') const { execCommand } = await import('./ssh-relay-deploy-helpers') const { getRemoteHostPlatform } = await import('./ssh-remote-platform') const { @@ -105,41 +110,6 @@ const { registerSshFilesystemProvider, unregisterSshFilesystemProvider } = const { registerSshGitProvider, unregisterSshGitProvider } = await import('../providers/ssh-git-dispatch') -function createMockDeps() { - const mockConn = {} as SshConnection - const mockStore = { - getRepos: vi.fn().mockReturnValue([]), - getSshRemotePtyLeases: vi.fn().mockReturnValue([]), - markSshRemotePtyLease: vi.fn(), - markSshRemotePtyLeases: vi.fn() - } as unknown as Store - const mockPortForward = { - removeAllForwards: vi.fn() - } as unknown as SshPortForwardManager - const mockWindow = { - isDestroyed: () => false, - // Why: the port scanner visibility-gates its ticks; a visible mock window - // keeps establish-path tests exercising the scan-on-ready behavior. - isVisible: () => true, - isMinimized: () => false, - webContents: { send: vi.fn() } - } - const getMainWindow = vi.fn().mockReturnValue(mockWindow) - return { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } -} - -function mockDeploySuccess() { - const mockTransport = { - write: vi.fn(), - onData: vi.fn(), - onClose: vi.fn() - } - vi.mocked(deployAndLaunchRelay).mockResolvedValue({ - transport: mockTransport, - platform: 'linux-x64' - }) -} - describe('SshRelaySession', () => { beforeEach(() => { vi.clearAllMocks() @@ -150,6 +120,95 @@ describe('SshRelaySession', () => { installRemoteManagedAgentHooksMock.mockResolvedValue([]) mockDeploySuccess() vi.mocked(getPtyIdsForConnection).mockReturnValue([]) + _resetHiddenRendererPtyDeliveryGateForTest() + }) + + it('drops hidden-gated PTY data after runtime ingestion with one restore marker', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + const runtime = { + onPtyData: vi.fn(() => 99), + onPtyExit: vi.fn() + } + const session = new SshRelaySession( + 'target-1', + getMainWindow, + mockStore, + mockPortForward, + runtime as never + ) + await session.establish(mockConn) + const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { + onData: ReturnType + } + const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { + id: string + data: string + }) => void + + markHiddenRendererPty('ssh-pty-1') + onData({ id: 'ssh-pty-1', data: 'hidden ssh output' }) + + // Runtime ingestion still ran; renderer delivery shrank to one marker. + expect(runtime.onPtyData).toHaveBeenCalledWith( + 'ssh-pty-1', + 'hidden ssh output', + expect.any(Number) + ) + expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1) + // Why out-of-band: an in-band empty pty:data sentinel is ambiguous with + // chunks fully consumed by renderer OSC-9999 stripping. + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:modelRestoreNeeded', { + id: 'ssh-pty-1', + reason: 'hidden-drop', + markerSeq: 99 + }) + + onData({ id: 'ssh-pty-1', data: 'more hidden ssh output' }) + expect(mockWindow.webContents.send).toHaveBeenCalledTimes(1) + + // Delivery interest (renderer sidecars) suppresses the gate — parity with + // the local path in ipc/pty.ts. + setRendererPtyDeliveryInterest('ssh-pty-1', true) + onData({ id: 'ssh-pty-1', data: 'sidecar ssh bytes' }) + expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: 'ssh-pty-1', + data: 'sidecar ssh bytes', + seq: 99, + rawLength: 'sidecar ssh bytes'.length + }) + + // Non-hidden PTYs are unaffected. + onData({ id: 'ssh-pty-2', data: 'visible ssh output' }) + expect(mockWindow.webContents.send).toHaveBeenLastCalledWith('pty:data', { + id: 'ssh-pty-2', + data: 'visible ssh output', + seq: 99, + rawLength: 'visible ssh output'.length + }) + }) + + it('keeps hidden SSH delivery when the gate kill switch is off', async () => { + const { mockConn, mockStore, mockPortForward, getMainWindow, mockWindow } = createMockDeps() + ;(mockStore as unknown as { getSettings: () => unknown }).getSettings = vi.fn(() => ({ + terminalHiddenDeliveryGate: false + })) + const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward) + await session.establish(mockConn) + const ptyProvider = vi.mocked(registerSshPtyProvider).mock.calls[0]?.[1] as unknown as { + onData: ReturnType + } + const onData = ptyProvider.onData.mock.calls[0]?.[0] as (payload: { + id: string + data: string + }) => void + + markHiddenRendererPty('ssh-pty-1') + onData({ id: 'ssh-pty-1', data: 'still delivered' }) + + expect(mockWindow.webContents.send).toHaveBeenCalledWith('pty:data', { + id: 'ssh-pty-1', + data: 'still delivered' + }) }) it('starts in idle state', () => { diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index b2564259ffd..5176d06411f 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -44,6 +44,11 @@ import { setPtyOwnership, answerStartupTerminalColorQueriesForPty } from '../ipc/pty' +import { + recordHiddenRendererPtyDataDrop, + shouldDropHiddenRendererPtyData +} from '../ipc/pty-hidden-delivery-gate' +import type { PtyModelRestoreNeededEvent } from '../../shared/pty-model-restore-marker' import { registerSshFilesystemProvider, unregisterSshFilesystemProvider, @@ -1080,7 +1085,30 @@ export class SshRelaySession { const seq = this.runtime?.onPtyData(payload.id, payload.data, Date.now()) const rendererData = answerStartupTerminalColorQueriesForPty(payload.id, payload.data) const win = this.getMainWindow() - if (win && !win.isDestroyed() && rendererData.length > 0) { + if (!win || win.isDestroyed()) { + return + } + // Why: hidden-delivery gate parity with ipc/pty.ts — runtime ingestion + // above already consumed the chunk; gated renderer delivery is dropped + // and one out-of-band pty:modelRestoreNeeded signal latches + // model-restore-needed for reveal. Never an in-band pty:data sentinel: + // OSC-9999-only chunks legitimately strip to empty in the renderer. + const store = this.store as { getSettings?: Store['getSettings'] } + if (shouldDropHiddenRendererPtyData(payload.id, store.getSettings?.())) { + const drop = recordHiddenRendererPtyDataDrop(payload.id, payload.data.length) + if (drop.shouldEmitRestoreMarker) { + win.webContents.send('pty:modelRestoreNeeded', { + id: payload.id, + reason: 'hidden-drop', + ...(typeof seq === 'number' ? { markerSeq: seq } : {}) + } satisfies PtyModelRestoreNeededEvent) + } + return + } + // Why: startup color-query answering can strip query-only chunks to + // empty; skip empty sends and only attach seq metadata when the chunk + // reaches the renderer unmodified (seq tracks raw stream offsets). + if (rendererData.length > 0) { win.webContents.send('pty:data', { ...payload, data: rendererData, diff --git a/src/main/synthetic-title-frame-routing.test.ts b/src/main/synthetic-title-frame-routing.test.ts new file mode 100644 index 00000000000..b4bbf35ecd2 --- /dev/null +++ b/src/main/synthetic-title-frame-routing.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { shouldCopySyntheticTitleFrameToPtyData } from './synthetic-title-frame-routing' + +describe('shouldCopySyntheticTitleFrameToPtyData', () => { + it('keeps the legacy pty:data copy only while the kill switch is off', () => { + // Authority off: renderer byte parsers are the sole synthetic-frame + // consumer, so the legacy copy must keep flowing. + expect(shouldCopySyntheticTitleFrameToPtyData({ terminalMainSideEffectAuthority: false })).toBe( + true + ) + }) + + it('skips the copy under main authority — tracker ingest is the only consumer', () => { + // Why: under authority the copy would only mint phantom renderer ACKs + // for fabricated bytes main never metered. + expect(shouldCopySyntheticTitleFrameToPtyData({ terminalMainSideEffectAuthority: true })).toBe( + false + ) + // Default-on: an unset switch means main authority. + expect(shouldCopySyntheticTitleFrameToPtyData({})).toBe(false) + expect(shouldCopySyntheticTitleFrameToPtyData(null)).toBe(false) + expect(shouldCopySyntheticTitleFrameToPtyData(undefined)).toBe(false) + }) +}) diff --git a/src/main/synthetic-title-frame-routing.ts b/src/main/synthetic-title-frame-routing.ts new file mode 100644 index 00000000000..3bb10a030fa --- /dev/null +++ b/src/main/synthetic-title-frame-routing.ts @@ -0,0 +1,14 @@ +import type { GlobalSettings } from '../shared/types' + +/** + * Why: with the side-effect kill switch off, renderer byte parsers are the + * ONLY consumer of main-fabricated OSC title frames, so they must still ride + * `pty:data`. With main authority on (the default), the tracker ingest is the + * sole consumer and the legacy copy would only mint phantom renderer ACKs for + * bytes main never metered. See terminal-side-effect-authority.md (slice 3). + */ +export function shouldCopySyntheticTitleFrameToPtyData( + settings: Pick | null | undefined +): boolean { + return settings?.terminalMainSideEffectAuthority === false +} diff --git a/src/main/window/createMainWindow.test.ts b/src/main/window/createMainWindow.test.ts index 453487a18aa..dc278cb1ffa 100644 --- a/src/main/window/createMainWindow.test.ts +++ b/src/main/window/createMainWindow.test.ts @@ -9,6 +9,8 @@ const { menuPopupMock, notificationMock, notificationShowMock, + powerMonitorOnMock, + powerMonitorRemoveListenerMock, isMock } = vi.hoisted(() => { const menuPopupMock = vi.fn() @@ -23,6 +25,8 @@ const { return { show: notificationShowMock } }), notificationShowMock, + powerMonitorOnMock: vi.fn(), + powerMonitorRemoveListenerMock: vi.fn(), isMock: { dev: false } } }) @@ -34,6 +38,7 @@ vi.mock('electron', () => ({ Menu: { buildFromTemplate: buildFromTemplateMock }, Notification: notificationMock, nativeTheme: { shouldUseDarkColors: false }, + powerMonitor: { on: powerMonitorOnMock, removeListener: powerMonitorRemoveListenerMock }, screen: { getPrimaryDisplay: () => ({ workAreaSize: { width: 1440, height: 900 } }) }, @@ -77,6 +82,8 @@ describe('createMainWindow', () => { menuPopupMock.mockClear() notificationMock.mockClear() notificationShowMock.mockClear() + powerMonitorOnMock.mockReset() + powerMonitorRemoveListenerMock.mockReset() isMock.dev = false vi.mocked(ipcMain.on).mockReset() vi.mocked(ipcMain.removeListener).mockReset() @@ -2853,6 +2860,103 @@ describe('createMainWindow', () => { }) }) + describe('system resume relay', () => { + function setupResumeWindow() { + const windowHandlers: Record void> = {} + const webContents = { + on: vi.fn(), + setZoomLevel: vi.fn(), + setBackgroundThrottling: vi.fn(), + invalidate: vi.fn(), + setWindowOpenHandler: vi.fn(), + send: vi.fn(), + isDestroyed: vi.fn(() => false), + id: 1 + } + const instance = { + webContents, + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + windowHandlers[event] = handler + }), + isDestroyed: vi.fn(() => false), + // Why: maximized keeps forceRepaint from scheduling its size-nudge timer. + isMaximized: vi.fn(() => true), + isFullScreen: vi.fn(() => false), + getSize: vi.fn(() => [1200, 800]), + setSize: vi.fn(), + maximize: vi.fn(), + show: vi.fn(), + loadFile: vi.fn(), + loadURL: vi.fn() + } + browserWindowMock.mockImplementation(function () { + return instance + }) + return { windowHandlers, webContents, instance } + } + + function getPowerResumeListener(): () => void { + const resumeCall = powerMonitorOnMock.mock.calls.find( + (call: unknown[]) => call[0] === 'resume' + ) + if (!resumeCall) { + throw new Error('missing powerMonitor resume listener') + } + return resumeCall[1] as () => void + } + + it('relays powerMonitor resume to the live window and forces a repaint', () => { + const { webContents } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + webContents.send.mockClear() + webContents.invalidate.mockClear() + + onResume() + + expect(webContents.send).toHaveBeenCalledWith('system:resumed') + expect(webContents.invalidate).toHaveBeenCalledTimes(1) + }) + + it('does not send the resume event once the window is destroyed', () => { + const { webContents, instance } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + instance.isDestroyed.mockReturnValue(true) + webContents.send.mockClear() + webContents.invalidate.mockClear() + + onResume() + + expect(webContents.send).not.toHaveBeenCalled() + expect(webContents.invalidate).not.toHaveBeenCalled() + }) + + it('does not send the resume event once webContents is destroyed', () => { + const { webContents } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + webContents.isDestroyed.mockReturnValue(true) + webContents.send.mockClear() + webContents.invalidate.mockClear() + + onResume() + + expect(webContents.send).not.toHaveBeenCalled() + expect(webContents.invalidate).not.toHaveBeenCalled() + }) + + it('removes the powerMonitor resume listener when the window closes', () => { + const { windowHandlers } = setupResumeWindow() + createMainWindow(null) + const onResume = getPowerResumeListener() + + windowHandlers.closed() + + expect(powerMonitorRemoveListenerMock).toHaveBeenCalledWith('resume', onResume) + }) + }) + describe('minimize to tray on close (win32)', () => { const originalPlatform = process.platform diff --git a/src/main/window/createMainWindow.ts b/src/main/window/createMainWindow.ts index 44b246e8980..1a30178a4e2 100644 --- a/src/main/window/createMainWindow.ts +++ b/src/main/window/createMainWindow.ts @@ -6,6 +6,7 @@ import { Menu, nativeTheme, Notification, + powerMonitor, screen, shell } from 'electron' @@ -313,6 +314,19 @@ export function createMainWindow( }) } + // Why: a focus-preserving system/display wake fires no window focus or + // visibility events in the renderer, so terminal wake recovery would never + // run. Relay powerMonitor resume explicitly (supported on mac/win/linux) + // and force a repaint so stale compositor surfaces recover too. + const onSystemResume = (): void => { + if (mainWindow.isDestroyed() || mainWindow.webContents.isDestroyed?.() === true) { + return + } + forceRepaint(mainWindow) + mainWindow.webContents.send('system:resumed') + } + powerMonitor.on('resume', onSystemResume) + mainWindow.webContents.on('dom-ready', () => { const level = store?.getUI().uiZoomLevel ?? 0 mainWindow.webContents.setZoomLevel(level) @@ -1203,6 +1217,9 @@ export function createMainWindow( ipcMain.removeListener(terminalInputFocusChannel, onTerminalInputFocused) ipcMain.removeListener(floatingTerminalInputFocusChannel, onFloatingTerminalInputFocused) ipcMain.removeListener(shortcutRecorderFocusChannel, onShortcutRecorderFocused) + // Why: powerMonitor is app-global; without this the closed window's + // resume relay would leak and fire against a destroyed webContents. + powerMonitor.removeListener('resume', onSystemResume) clearTrustedUIRendererWebContentsId(rendererWebContentsId) // Why: on updater-triggered shutdown, BrowserWindow can emit `closed` // after its webContents has already been destroyed. The destroyed diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index e278d863e21..ceb0d6a8d54 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -206,22 +206,18 @@ import type { WorkspaceSessionPatch, WorkspaceSessionState } from '../shared/types' - -type GitLabRepoSelectorArgs = { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null -} - -type GitHubRepoSelectorArgs = { - repoPath: string - repoId?: string | null - sourceContext?: TaskSourceContext | null -} +import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' +import type { + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../shared/pty-renderer-delivery-health' +import type { TerminalViewAttributes } from '../shared/terminal-view-attributes' +import type { PtyMainDeliveryDiagnostics } from '../shared/pty-delivery-diagnostics' import type { WarpThemeImportPreview, WarpThemeImportSource } from '../shared/terminal-custom-themes' + import type { SetupScriptImportCandidate } from '../shared/setup-script-imports' import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history' import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments' @@ -292,6 +288,7 @@ import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' +import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import type { RuntimeBrowserDriverState, RuntimeMobileSessionTabMove, @@ -445,6 +442,18 @@ import type { } from '../shared/workspace-cleanup' import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings' +type GitLabRepoSelectorArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + +type GitHubRepoSelectorArgs = { + repoPath: string + repoId?: string | null + sourceContext?: TaskSourceContext | null +} + export type BrowserApi = { registerGuest: (args: { browserPageId: string @@ -1206,6 +1215,10 @@ export type PreloadApi = { shellOverride?: string projectRuntime?: ProjectExecutionRuntimeResolution terminalColorQueryReplies?: { foreground?: string; background?: string } + // Why: hidden-at-spawn declaration — main marks the PTY hidden before + // its first byte so the delivery gate + model responder own spawn-time + // queries (terminal-query-authority.md §races). + initiallyHidden?: boolean // Why: closes the SIGKILL race documented in INVESTIGATION.md — main // sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before // pty:spawn returns. Only the renderer's daemon-host path threads these. @@ -1237,12 +1250,34 @@ export type PreloadApi = { clearBuffer: (id: string) => void kill: (id: string, opts?: { keepHistory?: boolean }) => Promise ackColdRestore: (id: string) => void - ackData: (id: string, charCount: number) => void + ackData: (id: string, charCount: number, processedChars?: number) => void + onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void) => () => void + respondDeliveryResync: (payload: { + requestId: number + processedCharsByPty: Record + }) => void + /** Renderer-initiated delivery health/heal lane over invoke — reaches main + * even when every main→renderer push channel is dead (field wedge). */ + reportRendererDeliveryState: ( + report: PtyRendererDeliveryStateReport + ) => Promise + /** Live pty:data listener count on the preload emitter (sync) — heal-time + * discriminator between a detached listener and a dead channel. */ + getPtyDataListenerCount: () => number /** One-shot signal that this page's pty:data dispatcher is registered, so * main can release sends held during the load/reload boot window. */ rendererDispatcherReady: () => void setActiveRendererPty: (id: string, active: boolean) => void setRendererPtyVisible: (id: string, visible: boolean) => void + /** Hidden-delivery gate (Phase 4): hidden=true lets main drop renderer + * byte delivery after model ingestion; reveal restores from snapshots. */ + setHiddenRendererPty: (id: string, hidden: boolean) => void + /** Ref-counted-on-the-renderer delivery-interest signal that suppresses + * the hidden-delivery gate while any raw-byte consumer is registered. */ + setPtyDeliveryInterest: (id: string, interested: boolean) => void + /** View-attribute bridge (Phase 5 slice 2): app-global composed terminal + * appearance push backing main's hidden-PTY OSC/DSR color replies. */ + publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void hasChildProcesses: (id: string) => Promise getForegroundProcess: (id: string) => Promise getCwd: (id: string) => Promise @@ -1258,8 +1293,16 @@ export type PreloadApi = { rows: number cwd?: string | null seq?: number + /** Start of main's pending renderer-delivery queue at snapshot time + * (equals `seq` when empty) — bounds the renderer's post-restore + * duplicate window. */ + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + /** Authoritative normal buffer paired with an alternate-screen frame. */ + scrollbackAnsi?: string + /** Trailing incomplete escape the emulator ingested; the restorer must + * write it after its post-replay resets, last before live chunks. */ pendingEscapeTailAnsi?: string } | null> getRendererDeliveryDebugSnapshot: () => Promise<{ @@ -1276,6 +1319,14 @@ export type PreloadApi = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + diagnostics: PtyMainDeliveryDiagnostics rendererLifecycleResetCount: number lastLifecycleResetClearedChars: number rendererPtyDispatcherReady: boolean @@ -1289,10 +1340,19 @@ export type PreloadApi = { seq?: number rawLength?: number background?: boolean - droppedBacklog?: boolean + droppedOutput?: boolean }) => void ) => () => void onReplay: (callback: (data: { id: string; data: string }) => void) => () => void + /** Out-of-band main→renderer signal that renderer-bound bytes were + * dropped (hidden-delivery gate / pending cap); the pane restores from + * the model snapshot. Never delivered in-band on pty:data. */ + onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void) => () => void + /** Batched derived side-effect facts for PTYs whose bytes transit local + * main; see docs/reference/terminal-side-effect-authority.md. */ + onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void + /** Title-only replay snapshot for (re)attach; attention facts never replay. */ + getSideEffectSnapshot: (id: string) => Promise onExit: (callback: (data: { id: string; code: number }) => void) => () => void onSerializeBufferRequest: ( callback: (data: { @@ -1993,6 +2053,10 @@ export type PreloadApi = { telemetryAcknowledgeBanner: () => Promise settings: { get: () => Promise + /** Synchronous persisted-settings read for startup decisions that cannot + * wait for async hydration (terminal side-effect authority). Blocking + * IPC — call sparingly. */ + getSync: () => GlobalSettings | null set: (args: Partial) => Promise listFonts: () => Promise previewGhosttyImport: () => Promise diff --git a/src/preload/e2e-config.ts b/src/preload/e2e-config.ts index 079bdceb181..f1cdf668ce3 100644 --- a/src/preload/e2e-config.ts +++ b/src/preload/e2e-config.ts @@ -20,5 +20,8 @@ const exposeStore = preloadEnv?.MODE === 'e2e' || isEnvFlagEnabled(preloadEnv?.V export const preloadE2EConfig = createE2EConfig({ headless: process.env.ORCA_E2E_HEADLESS === '1', exposeStore, - userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null + userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null, + // Why: Number('') is 0 and Number(undefined) is NaN; both coerce to null so + // only a real positive override reaches the renderer parking policy. + terminalParkingDelayMs: Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || null }) diff --git a/src/preload/index.ts b/src/preload/index.ts index 8f24dc18217..2e960b9c461 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -54,6 +54,13 @@ import type { WorktreeDefaultTabsLaunch, WorktreeRemoteBranchConflictEvent } from '../shared/types' +import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker' +import type { + PtyRendererDeliveryHealthReply, + PtyRendererDeliveryStateReport +} from '../shared/pty-renderer-delivery-health' +import type { TerminalViewAttributes } from '../shared/terminal-view-attributes' +import type { PtyMainDeliveryDiagnostics } from '../shared/pty-delivery-diagnostics' import type { WarpThemeImportPreview, WarpThemeImportSource @@ -131,6 +138,7 @@ import type { MigrationUnsupportedPtyEntry } from '../shared/agent-status-types' import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent' +import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts' import type { SpeechErrorEvent, SpeechLifecycleEvent, @@ -792,6 +800,10 @@ const api = { shellOverride?: string projectRuntime?: ProjectExecutionRuntimeResolution terminalColorQueryReplies?: { foreground?: string; background?: string } + // Why: hidden-at-spawn declaration — main marks the PTY hidden before + // its first byte so the delivery gate + model responder own spawn-time + // queries (terminal-query-authority.md §races). + initiallyHidden?: boolean // Why: closes the SIGKILL race documented in INVESTIGATION.md by // letting main patch + sync-flush the (worktreeId, tabId, leafId → // ptyId) binding before pty:spawn returns. Only the renderer's @@ -851,9 +863,39 @@ const api = { ackColdRestore: (id: string): void => { ipcRenderer.send('pty:ackColdRestore', { id }) }, - ackData: (id: string, charCount: number): void => { - ipcRenderer.send('pty:ackData', { id, charCount }) + /** charCount is the legacy per-chunk delta; processedChars is the + * cumulative per-pty total (self-healing under lost ACK messages). */ + ackData: (id: string, charCount: number, processedChars?: number): void => { + ipcRenderer.send('pty:ackData', { + id, + charCount, + ...(typeof processedChars === 'number' ? { processedChars } : {}) + }) }, + /** Main asks for the renderer's cumulative processed totals when terminal + * delivery looks stuck on lost ACKs. */ + onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, payload: { requestId: number }) => + callback(payload) + ipcRenderer.on('pty:requestDeliveryResync', listener) + return () => ipcRenderer.removeListener('pty:requestDeliveryResync', listener) + }, + respondDeliveryResync: (payload: { + requestId: number + processedCharsByPty: Record + }): void => { + ipcRenderer.send('pty:deliveryResyncResponse', payload) + }, + /** Renderer-initiated delivery health/heal lane. Rides invoke because the + * field wedge (v1.4.121-rc.0 snapshot) kills main→renderer push events + * while invoke stays alive — push-initiated recovery can't reach it. */ + reportRendererDeliveryState: ( + report: PtyRendererDeliveryStateReport + ): Promise => + ipcRenderer.invoke('pty:reportRendererDeliveryState', report), + /** Sync count of live pty:data listeners on this preload's emitter — the + * watchdog's "listener detached" vs "channel dead" discriminator. */ + getPtyDataListenerCount: (): number => ipcRenderer.listenerCount('pty:data'), rendererDispatcherReady: (): void => { ipcRenderer.send('pty:rendererDispatcherReady') }, @@ -863,6 +905,24 @@ const api = { setRendererPtyVisible: (id: string, visible: boolean): void => { ipcRenderer.send('pty:setRendererPtyVisible', { id, visible }) }, + /** Hidden-delivery gate (Phase 4): hidden=true lets main DROP renderer + * byte delivery after model ingestion; reveal restores from the model + * snapshot. Fire-and-forget like setActiveRendererPty. */ + setHiddenRendererPty: (id: string, hidden: boolean): void => { + ipcRenderer.send('pty:setHiddenRendererPty', { id, hidden }) + }, + /** Delivery-interest signal: any renderer party that needs raw bytes + * (dispatcher sidecars, eager pre-mount buffers) suppresses the + * hidden-delivery gate for that PTY while registered. */ + setPtyDeliveryInterest: (id: string, interested: boolean): void => { + ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested }) + }, + /** View-attribute bridge (Phase 5 slice 2): app-global composed terminal + * appearance push that lets main's model responder answer OSC 4/10/11/12 + * and DSR ?996n for hidden-gated PTYs with renderer-true values. */ + publishTerminalViewAttributes: (attributes: TerminalViewAttributes): void => { + ipcRenderer.send('pty:terminalViewAttributes', attributes) + }, kill: (id: string, opts?: { keepHistory?: boolean }): Promise => ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }), @@ -880,8 +940,10 @@ const api = { rows: number cwd?: string | null seq?: number + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string } | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }), @@ -899,6 +961,14 @@ const api = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryGatedActivePtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number + diagnostics: PtyMainDeliveryDiagnostics rendererLifecycleResetCount: number lastLifecycleResetClearedChars: number rendererPtyDispatcherReady: boolean @@ -934,7 +1004,7 @@ const api = { seq?: number rawLength?: number background?: boolean - droppedBacklog?: boolean + droppedOutput?: boolean }) => void ): (() => void) => { const listener = ( @@ -945,7 +1015,7 @@ const api = { seq?: number rawLength?: number background?: boolean - droppedBacklog?: boolean + droppedOutput?: boolean } ) => callback(data) ipcRenderer.on('pty:data', listener) @@ -959,6 +1029,32 @@ const api = { return () => ipcRenderer.removeListener('pty:replay', listener) }, + /** Out-of-band signal that main dropped renderer-bound bytes for a PTY + * (hidden-delivery gate / pending cap) — the pane must restore from the + * model snapshot. Deliberately NOT on pty:data: an in-band marker is + * ambiguous with chunks fully stripped by OSC-9999 cleaning. */ + onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, event: PtyModelRestoreNeededEvent) => + callback(event) + ipcRenderer.on('pty:modelRestoreNeeded', listener) + return () => ipcRenderer.removeListener('pty:modelRestoreNeeded', listener) + }, + + /** Batched derived side-effect facts (title/bell/agent transitions) for + * PTYs whose bytes transit local main. Per-PTY in-order; deliberately not + * synchronized with pty:data (terminal-side-effect-authority.md). */ + onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, batch: TerminalSideEffectBatch) => + callback(batch) + ipcRenderer.on('pty:sideEffect', listener) + return () => ipcRenderer.removeListener('pty:sideEffect', listener) + }, + + /** Title-only replay snapshot applied on (re)attach — attention facts + * (bells/completions) never replay. */ + getSideEffectSnapshot: (id: string): Promise => + ipcRenderer.invoke('pty:sideEffectSnapshot', { id }), + onExit: (callback: (data: { id: string; code: number }) => void): (() => void) => { const listener = (_event: Electron.IpcRendererEvent, data: { id: string; code: number }) => callback(data) @@ -1739,6 +1835,10 @@ const api = { settings: { get: (): Promise => ipcRenderer.invoke('settings:get'), + // Why: blocking read for the few startup decisions (terminal side-effect + // authority) that cannot wait for async hydration. Call sparingly. + getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'), + set: (args: Record): Promise => ipcRenderer.invoke('settings:set', args), @@ -3506,11 +3606,6 @@ const api = { ipcRenderer.on('terminal:zoom', listener) return () => ipcRenderer.removeListener('terminal:zoom', listener) }, - onSystemResumed: (callback: () => void): (() => void) => { - const listener = (_event: Electron.IpcRendererEvent) => callback() - ipcRenderer.on('system:resumed', listener) - return () => ipcRenderer.removeListener('system:resumed', listener) - }, readClipboardText: (options?: ReadClipboardTextOptions): Promise => ipcRenderer.invoke('clipboard:readText', options), readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise => @@ -3576,6 +3671,14 @@ const api = { ipcRenderer.on('window:fullscreen-changed', listener) return () => ipcRenderer.removeListener('window:fullscreen-changed', listener) }, + /** Fired when the OS resumes from sleep (main relays powerMonitor). A + * focus-preserving display wake fires no renderer focus/visibility + * events, so terminal wake recovery listens to this explicit signal. */ + onSystemResumed: (callback: () => void): (() => void) => { + const listener = () => callback() + ipcRenderer.on('system:resumed', listener) + return () => ipcRenderer.removeListener('system:resumed', listener) + }, /** Desktop custom titlebar only: minimize via renderer-drawn window controls. */ minimize: (): void => { ipcRenderer.send('window:minimize') diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ce443c55454..eeaff50abb5 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -130,6 +130,8 @@ import { } from './startup/startup-diagnostics' import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility' import { applyDocumentTheme } from './lib/document-theme' +import { getSystemPrefersDark } from './lib/terminal-theme' +import { publishTerminalViewAttributesAtAppStart } from './components/terminal-pane/terminal-appearance' import { isEditableTarget } from './lib/editable-target' import { getSelectedTextForFileSearch } from './lib/file-search-selection' import { useShortcutLabel } from './hooks/useShortcutLabel' @@ -176,6 +178,11 @@ import { subscribeBackgroundTerminalWorktreeMountRequests } from './components/terminal/background-terminal-worktree-mount' +// Why: agents alive during a hard kill (crash, forced update install) need a +// reasonably fresh resume record on disk; one minute bounds the lost window +// without measurable per-tick cost (the capture skips unchanged records). +const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000 + const isMac = navigator.userAgent.includes('Mac') const isWindows = !isMac && navigator.userAgent.includes('Windows') const shortcutPlatform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux' @@ -886,6 +893,14 @@ function App(): React.JSX.Element { // Load settings first so a persisted remote runtime does not boot against // the local filesystem and then hydrate stale local workspace state. await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings()) + // Why here: hidden-at-launch PTYs (background terminal reconnects, + // agent sessions) can query OSC 10/11 before any terminal pane mounts + // and main's responder is silent-until-first-push. Publish composed + // view attributes as soon as settings exist, before any spawn below. + publishTerminalViewAttributesAtAppStart( + useAppStore.getState().settings, + getSystemPrefersDark() + ) // Why: keybindings + onboarding are main-side reads with no dependency // on the catalog/session steps below, so start them now and await them // at their original positions — the round-trips overlap the local @@ -1365,6 +1380,22 @@ function App(): React.JSX.Element { return () => window.removeEventListener('beforeunload', captureAndFlush) }, []) + // Why: beforeunload never fires on a hard kill (crash, forced update + // install, TerminateProcess), so agents alive at that moment would leave no + // resume record. This periodic capture stores only agent session ids — not + // scrollback, see the no-periodic-scrollback note below — and the store + // action skips unchanged records, so idle ticks write nothing; real changes + // flow through the debounced session-write subscriber. + useEffect(() => { + const timer = window.setInterval(() => { + if (!shouldPersistWorkspaceSession(useAppStore.getState())) { + return + } + useAppStore.getState().captureAllSleepingAgentSessions() + }, SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS) + return () => window.clearInterval(timer) + }, []) + // Own the single window-close-request subscription at the always-mounted App // root. Why: the rich confirmation flow lives in Terminal, which is not // mounted on the no-workspace landing page (and is lazy-loaded elsewhere), so diff --git a/src/renderer/src/components/Terminal.tsx b/src/renderer/src/components/Terminal.tsx index c4d331697c2..f8ae284a365 100644 --- a/src/renderer/src/components/Terminal.tsx +++ b/src/renderer/src/components/Terminal.tsx @@ -73,6 +73,18 @@ import { import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options' import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface' import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs' +import { + getTerminalWorktreeColdParkRecheckDelayMs, + selectColdParkedTerminalWorktrees, + type TerminalWorktreeColdParkCandidate +} from './terminal-pane/terminal-hidden-view-parking' +import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides' +import { + canWatcherCoverParkedTerminalTab, + pruneParkedTerminalWatchers, + shouldDeferParkedPtyExitTabClose, + syncParkedTerminalTabWatchers +} from './terminal-pane/terminal-parked-tab-watchers' import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue' import { setWindowCloseRequestHandler } from './window-close-request-coordinator' import CodexRestartChip from './CodexRestartChip' @@ -132,6 +144,18 @@ const EDITOR_TAB_CONTENT_TYPES = new Set([ type TerminalStoreSnapshot = ReturnType +function haveSameWorktreeIds(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false + } + for (const id of left) { + if (!right.has(id)) { + return false + } + } + return true +} + function findUnifiedTabByVisibleId( state: TerminalStoreSnapshot, worktreeId: string, @@ -214,6 +238,8 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext { function Terminal(): React.JSX.Element | null { const mountedWorktreeIdsRef = useRef(new Set()) const measurableBackgroundWorktreeIdsRef = useRef(new Set()) + const terminalWorktreeHiddenSinceRef = useRef(new Map()) + const terminalWorktreeParkingTimersRef = useRef(new Map()) const allWorktrees = useAllWorktrees() const folderWorkspaces = useAppStore((s) => s.folderWorkspaces) const workspaceSurfaces = useMemo( @@ -230,6 +256,8 @@ function Terminal(): React.JSX.Element | null { const renderedActiveWorktreeId = activeWorktreeId const activeView = useAppStore((s) => s.activeView) const tabsByWorktree = useAppStore((s) => s.tabsByWorktree) + const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId) + const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false) const activeTabId = useAppStore((s) => s.activeTabId) const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree) const createTab = useAppStore((s) => s.createTab) @@ -737,11 +765,15 @@ function Terminal(): React.JSX.Element | null { // Only mount TerminalPanes for visited worktrees to prevent mass PTY // spawning when restoring a session with many saved worktree tabs. const measurableBackgroundWorktreeTimersRef = useRef(new Map()) + const [backgroundMountRevision, setBackgroundMountRevision] = useState(0) + const [terminalParkingRevision, setTerminalParkingRevision] = useState(0) + const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState>( + () => new Set() + ) // Why: background-mounted worktrees restricted to specific tabs (targeted // wake/resume) must not instantiate a TerminalPane per saved tab. A worktree // absent from this map mounts all of its tabs. const backgroundMountTabIdsByWorktreeRef = useRef(new Map>()) - const [, setBackgroundMountRevision] = useState(0) useEffect(() => { const timers = measurableBackgroundWorktreeTimersRef.current const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current @@ -798,6 +830,122 @@ function Terminal(): React.JSX.Element | null { closeDialogDebounceTimers.clear() } }, []) + + useEffect(() => { + const timers = terminalWorktreeParkingTimersRef.current + return () => { + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + } + }, []) + + // Why: worktree-level cold-park policy — hiddenSince bookkeeping, parked-set + // selection, and one recheck timer per still-pending deadline so React + // re-renders exactly when the hysteresis elapses instead of polling. + useEffect(() => { + const parkingTimers = terminalWorktreeParkingTimersRef.current + for (const timer of parkingTimers.values()) { + window.clearTimeout(timer) + } + parkingTimers.clear() + + const nowMs = Date.now() + const overrides = getTerminalParkingPolicyOverrides() + const portalWorktreeIds = new Set(activityTerminalPortals.map((portal) => portal.worktreeId)) + const currentWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id)) + for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) { + if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + } + } + + const retentionCandidates: TerminalWorktreeColdParkCandidate[] = [] + for (const workspace of workspaceSurfaces) { + const worktreeId = workspace.id + if (!mountedWorktreeIdsRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + continue + } + const isVisible = activeView === 'terminal' && renderedActiveWorktreeId === worktreeId + const shouldMeasureHiddenWorktree = + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktreeId) + const hasActivityTerminalPortal = portalWorktreeIds.has(worktreeId) + if (isVisible || shouldMeasureHiddenWorktree || hasActivityTerminalPortal) { + terminalWorktreeHiddenSinceRef.current.delete(worktreeId) + } else if (!terminalWorktreeHiddenSinceRef.current.has(worktreeId)) { + terminalWorktreeHiddenSinceRef.current.set(worktreeId, nowMs) + } + + retentionCandidates.push({ + worktreeId, + terminalTabs: tabsByWorktree[worktreeId] ?? [], + isVisible, + shouldMeasureHiddenWorktree, + hasActivityTerminalPortal, + hiddenSinceMs: terminalWorktreeHiddenSinceRef.current.get(worktreeId) ?? null + }) + } + + const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({ + worktrees: retentionCandidates, + pendingStartupByTabId, + parkingEnabled: terminalParkingEnabled, + nowMs, + ...overrides + }) + // Why: a worktree with any tab the byte watchers cannot cover (no + // capture, no layout snapshot, legacy leaf ids) must never park — it + // would go silent for bells/titles/completions, the failure that sank + // the first parking attempt. + for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) { + const tabs = tabsByWorktree[worktreeId] ?? [] + if (!tabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab))) { + nextParkedTerminalWorktreeIds.delete(worktreeId) + } + } + setParkedTerminalWorktreeIds((current) => + haveSameWorktreeIds(current, nextParkedTerminalWorktreeIds) + ? current + : nextParkedTerminalWorktreeIds + ) + + for (const candidate of retentionCandidates) { + if ( + candidate.isVisible || + candidate.shouldMeasureHiddenWorktree || + candidate.hasActivityTerminalPortal || + nextParkedTerminalWorktreeIds.has(candidate.worktreeId) + ) { + continue + } + const delayMs = getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: terminalParkingEnabled, + hiddenSinceMs: candidate.hiddenSinceMs, + nowMs, + ...overrides + }) + if (delayMs !== null && delayMs > 0) { + const worktreeId = candidate.worktreeId + const timer = window.setTimeout(() => { + parkingTimers.delete(worktreeId) + setTerminalParkingRevision((revision) => revision + 1) + }, delayMs) + parkingTimers.set(worktreeId, timer) + } + } + }, [ + activeView, + activityTerminalPortals, + backgroundMountRevision, + pendingStartupByTabId, + renderedActiveWorktreeId, + tabsByWorktree, + terminalParkingEnabled, + terminalParkingRevision, + workspaceSurfaces + ]) // Why: gated on workspaceSessionReady to prevent TerminalPane from mounting // before reconnectPersistedTerminals() has finished eagerly spawning PTYs. // Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId @@ -829,6 +977,59 @@ function Terminal(): React.JSX.Element | null { groupsByWorktree, activeGroupIdByWorktree ) + // Why: parked byte-watcher reconciliation for the legacy (non-split) + // terminal host, which renders TerminalPanes directly. In split mode each + // TerminalPaneOverlayLayer owns its worktree's watchers, so here we only + // dispose worktrees that render no overlay layer (no layout / unmounted) + // and prune watchers for deleted worktrees. + useEffect(() => { + pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id))) + for (const workspace of workspaceSurfaces) { + if ( + anyMountedWorktreeHasLayout && + mountedWorktreeIdsRef.current.has(workspace.id) && + getEffectiveLayoutForWorktree(workspace.id) + ) { + continue + } + const tabs = tabsByWorktree[workspace.id] ?? [] + const parkedTabIds = new Set() + if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspace.id)) { + const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId + const shouldMeasureHiddenWorktree = + !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) + const parked = + !isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(workspace.id) + if (parked) { + for (const tab of tabs) { + const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, { + worktreeId: workspace.id, + tabId: tab.id + }) + if (!activityTerminalPortal) { + parkedTabIds.add(tab.id) + } + } + } + } + syncParkedTerminalTabWatchers({ worktreeId: workspace.id, tabs, parkedTabIds }) + } + }, [ + activeView, + activityTerminalPortals, + anyMountedWorktreeHasLayout, + backgroundMountRevision, + getEffectiveLayoutForWorktree, + parkedTerminalWorktreeIds, + renderedActiveWorktreeId, + tabsByWorktree, + workspaceSurfaces + ]) + // Why: symmetric with useTerminalTabColdParking's unmount cleanup — when + // the terminal host unmounts, no reconciliation effect will run again, so + // dispose every remaining parked watcher here (overlay-layer children have + // already disposed theirs by the time this parent cleanup runs). + useEffect(() => () => pruneParkedTerminalWatchers(new Set()), []) // Auto-create first tab when worktree activates useEffect(() => { if (!workspaceSessionReady) { @@ -1127,6 +1328,12 @@ function Terminal(): React.JSX.Element | null { if (consumeSuppressedPtyExit(ptyId)) { return } + // Why: a parked multi-leaf tab has no PaneManager to promote split + // siblings, so closing the tab here would kill them; the reveal + // remount handles dead PTYs per leaf instead. + if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) { + return + } handleCloseTab(tabId) }, [consumeSuppressedPtyExit, handleCloseTab] @@ -1859,6 +2066,10 @@ function Terminal(): React.JSX.Element | null { activeView === 'terminal' && workspace.id === renderedActiveWorktreeId const shouldMeasureHiddenWorktree = !isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id) + const shouldColdParkTerminalPanes = + !isVisible && + !shouldMeasureHiddenWorktree && + parkedTerminalWorktreeIds.has(workspace.id) return ( | null }): React.JSX.Element { @@ -2207,6 +2430,8 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({ worktreeId={worktreeId} worktreePath={worktreePath} isWorktreeActive={isVisible} + coldParkTerminalPanes={shouldColdParkTerminalPanes} + shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree} activityTerminalPortals={activityTerminalPortals} backgroundMountTabIds={backgroundMountTabIds} /> diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 8e1c8f17d8d..c30f5754327 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -13,6 +13,8 @@ import TerminalPane from './TerminalPane' import { closeTerminalTab } from '../terminal/terminal-tab-actions' import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount' import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut' +import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers' +import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' type TerminalOverlayAssignment = { unifiedTabId: string @@ -238,6 +240,12 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({ if (consumeSuppressedPtyExit(ptyId)) { return } + // Why: a parked multi-leaf tab has no PaneManager to promote split + // siblings, so closing the tab here would kill them; the reveal + // remount handles dead PTYs per leaf instead. + if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) { + return + } closeTab(terminalTabId) leaveWorktreeIfEmpty() }} @@ -279,12 +287,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId, worktreePath, isWorktreeActive, + coldParkTerminalPanes = false, + shouldMeasureHiddenWorktree = false, activityTerminalPortals = EMPTY_ACTIVITY_PORTALS, backgroundMountTabIds = null }: { worktreeId: string worktreePath: string isWorktreeActive: boolean + coldParkTerminalPanes?: boolean + shouldMeasureHiddenWorktree?: boolean activityTerminalPortals?: ActivityTerminalPortalTarget[] /** Non-null for targeted background mounts: only these terminal tabs get a * TerminalPane, so waking one slept agent does not connect every saved tab. */ @@ -351,6 +363,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ return entries }, [groupActiveTabById, unifiedTabs]) + const parkedTerminalTabIds = useTerminalTabColdParking({ + worktreeId, + terminalTabs, + assignments, + isWorktreeActive, + coldParkTerminalPanes, + shouldMeasureHiddenWorktree, + activityTerminalPortals + }) + if (!worktreePath) { return null } @@ -369,6 +391,11 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ worktreeId, tabId: terminalTab.id }) + // Why: parking unmounts only the view; the parked watcher owns exit + // and side-effect handling until this tab is eligible to remount. + if (parkedTerminalTabIds.has(terminalTab.id)) { + return null + } return ( | null +} + +export function isAgentTaskCompleteOsNotificationEnabledFromState( + state: NotificationSettingsState +): boolean { + const notifications = state.settings?.notifications + return notifications?.enabled !== false && notifications?.agentTaskComplete !== false +} + +export function isTerminalAttentionEnabledFromState(state: NotificationSettingsState): boolean { + return state.settings?.experimentalTerminalAttention === true +} + +/** Completion tracking runs when either consumer (OS notification or the + * experimental terminal-attention marker) is enabled. */ +export function isAgentTaskCompleteTrackingEnabledFromState( + state: NotificationSettingsState +): boolean { + return ( + isAgentTaskCompleteOsNotificationEnabledFromState(state) || + isTerminalAttentionEnabledFromState(state) + ) +} + +export function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean { + return Boolean( + entry && + Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS && + (entry.lastAssistantMessage || entry.toolName || entry.toolInput) + ) +} + +export function canDispatchAgentNotificationAfterGrace( + entry: AgentStatusEntry | undefined, + options: { allowDoneDetailAfterGrace?: boolean } = {} +): boolean { + // Why: hook-backed goal/mission loops can report `done` between milestones. + // User-input states may notify as soon as detail arrives, but `done` waits + // for the max quiet window so resumed work can cancel the pending banner. + return ( + hasAgentNotificationDetail(entry) && + (entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true) + ) +} diff --git a/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts b/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts new file mode 100644 index 00000000000..0c855bac3c3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/hidden-reveal-reconciliation.fuzz.test.ts @@ -0,0 +1,558 @@ +import { describe, expect, it } from 'vitest' +import { + buildAgentTuiStreamOps, + mulberry32, + splitIntoRandomChunks, + type AgentTuiStreamDims +} from '../../../../shared/agent-tui-ansi-fuzz-stream' +import { extractPartialEscapeTail } from '../../../../shared/terminal-partial-escape-tail' +import { + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, + SNAPSHOT_REPLAY_PREAMBLE_ALT, + SNAPSHOT_REPLAY_PREAMBLE_NORMAL, + bufferHasSerializeHostileWrappedRow, + buildParityMainBufferSnapshot, + createRendererParityTerminal, + cursorPosition, + normalBufferRowsTrimmed, + visibleRows, + visibleRowStyles, + writeChunksToTerminal +} from '../../../../shared/terminal-restore-parity-fixture' + +// Property fuzz for the hidden-reveal seq-reconciliation path in +// pty-connection.ts. When a pane is hidden, main keeps metering PTY output as +// seq'd chunks. On reveal the renderer applies a HeadlessEmulator snapshot +// captured at seq S (paints everything <= S), then stitches the racing tail +// (chunks straddling / after S) on top WITHOUT duplicating or losing a byte. +// The byte arithmetic lives in two non-exported closures: +// - getChunkDataAfterSnapshot (pty-connection.ts ~L4629): slices a +// pending chunk against the snapshot seq. +// - reconcileChunkAgainstRestoredSnapshot (pty-connection.ts ~L4660): dedups +// backlog, detects a restarted seq domain, and heals dropped-output gaps by +// forcing a fresh restore. +// This suite mirrors those rules EXACTLY (each branch tagged with its source +// line) and asserts the invariant they guarantee: for any reveal boundary, +// snapshot(hidden prefix) + reconciled tail reproduces the same screen an +// always-visible terminal shows for the full stream. A wrong slice = a garble. +// +// Runtime knobs: +// FUZZ_ITERATIONS=2000 deep/nightly (default 200, <60s with the fidelity suite) +// FUZZ_SEED=1234 re-run exactly one seed + +const DEFAULT_ITERATIONS = 200 +const FIXED_SEED = readPositiveIntEnv('FUZZ_SEED') +const ITERATIONS = + FIXED_SEED !== null ? 1 : (readPositiveIntEnv('FUZZ_ITERATIONS') ?? DEFAULT_ITERATIONS) + +function readPositiveIntEnv(name: string): number | null { + const raw = Number(process.env[name]) + return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null +} + +const DIMS: readonly AgentTuiStreamDims[] = [ + { cols: 80, rows: 24 }, + { cols: 100, rows: 30 } +] + +/** A metered chunk exactly as main delivers to onData: text, the seq of the + * LAST raw byte, the raw (pre-OSC-strip) length, and delivery-domain markers. + * domain increments across a ptyId-exit seq restart (production clears the + * restored baseline at that boundary, so the tail writes verbatim). */ +type MeteredChunk = { + data: string + seq?: number + rawLength?: number + domain: number + droppedOutput?: boolean +} + +/** Mirror of getChunkDataAfterSnapshot (pty-connection.ts ~L4629): how much of + * a chunk in the SNAPSHOT'S seq domain survives once the snapshot at + * snapshotSeq has painted everything <= snapshotSeq. null = "cannot slice, + * force a fresh snapshot" (OSC stripping desynced raw offsets). Byte-identical + * to production; each return tagged with its branch. */ +function chunkDataAfterSnapshot( + chunk: MeteredChunk, + snapshotSeq: number | undefined +): string | null { + if (typeof snapshotSeq !== 'number' || typeof chunk.seq !== 'number') { + return chunk.data // L4633: unmetered — pass through + } + const rawLength = chunk.rawLength ?? chunk.data.length + const startSeq = chunk.seq - rawLength + if (snapshotSeq >= chunk.seq) { + return '' // L4638: fully before/at snapshot — already painted + } + if (snapshotSeq <= startSeq) { + return chunk.data // L4641: fully after snapshot — keep whole + } + const offset = snapshotSeq - startSeq + if (rawLength !== chunk.data.length) { + return null // L4645: OSC-stripped, offsets unmappable + } + return chunk.data.slice(offset) // L4648: straddles — drop the painted prefix +} + +type RevealResult = { + /** Full normal-buffer content (visible + scrollback, trailing blanks + * trimmed). Viewport-independent: a byte lost or duplicated by reconciliation + * changes this even when the visible viewport happens to line up. */ + content: string[] + rows: string[] + styles: string[] + cursor: { x: number; y: number } + /** True when the buffer scrolled (baseY > 0). The visible viewport's top + * anchor after a snapshot restore vs continuous writing can differ by a row + * or two purely from trailing-blank trimming — a snapshot-scrollback-depth + * concern owned by the fidelity suite, not seq reconciliation — so styles and + * cursor (viewport-relative) are only asserted on non-scrolled scenarios. */ + scrolled: boolean + /** True when the terminal ended on the alternate screen. Alt has no + * scrollback and a fixed viewport, so its content is the visible rows and the + * normal-buffer content comparison does not apply (the alt-screen restore + * contract is scrollback-free by design — serializeHeadlessTerminalBuffer). */ + alternate: boolean + forcedFreshRestore: boolean + knownSerializeWrapBug: boolean +} + +async function readScreen( + term: ReturnType +): Promise { + const alternate = term.terminal.buffer.active.type === 'alternate' + return { + content: alternate ? visibleRows(term.terminal) : normalBufferRowsTrimmed(term.terminal), + rows: visibleRows(term.terminal), + styles: visibleRowStyles(term.terminal), + cursor: cursorPosition(term.terminal), + scrolled: term.terminal.buffer.active.baseY > 0, + alternate, + forcedFreshRestore: false, + knownSerializeWrapBug: false + } +} + +/** The renderer's screen for a hide→reveal cycle. `revealIdx` is the delivery + * index at which the pane is revealed: chunks [0, revealIdx) were captured by + * the HeadlessEmulator snapshot; chunks [revealIdx, end) are the racing tail. + * The snapshot seq is the seq of the last hidden chunk in the snapshot's + * domain; the tail is stitched via the production slice/reconcile rules. */ +async function revealFromSnapshot( + dims: AgentTuiStreamDims, + chunks: MeteredChunk[], + revealIdx: number +): Promise { + const source = createRendererParityTerminal(dims) + const restored = createRendererParityTerminal(dims) + try { + // Snapshot source = every hidden chunk painted in order. + const hiddenChunks = chunks.slice(0, revealIdx).map((c) => c.data) + await writeChunksToTerminal(source.terminal, hiddenChunks) + // Snapshot seq = seq of the last hidden chunk that carried one (the seq the + // emulator would report). undefined when the hidden prefix was unmetered. + let snapshotSeq: number | undefined + let snapshotDomain = 0 + for (let i = revealIdx - 1; i >= 0; i--) { + if (typeof chunks[i]!.seq === 'number') { + snapshotSeq = chunks[i]!.seq + snapshotDomain = chunks[i]!.domain + break + } + } + const snapshot = buildParityMainBufferSnapshot(source, snapshotSeq ?? 0, { + // Mirror of HeadlessEmulator's ingest tracker: the hidden stream's + // trailing incomplete escape rides the snapshot out-of-band (Bug E fix). + pendingEscapeTail: extractPartialEscapeTail(hiddenChunks.join('')) + }) + const alt = snapshot.alternateScreen + const knownSerializeWrapBug = bufferHasSerializeHostileWrappedRow(source.terminal) + // Mirror of applyMainBufferSnapshot's write order: the pending escape tail + // is the FINAL replay write — any later ESC (e.g. the post-replay reset) + // would abort the dangling sequence before the racing tail completes it. + const preamble = + alt && snapshot.scrollbackAnsi !== undefined + ? `\x1b[?1049l\x1b[2J\x1b[3J\x1b[H${snapshot.scrollbackAnsi}${SNAPSHOT_REPLAY_PREAMBLE_ALT}` + : alt + ? SNAPSHOT_REPLAY_PREAMBLE_ALT + : SNAPSHOT_REPLAY_PREAMBLE_NORMAL + await writeChunksToTerminal(restored.terminal, [ + preamble, + snapshot.data, + POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY, + ...(snapshot.pendingEscapeTailAnsi ? [snapshot.pendingEscapeTailAnsi] : []) + ]) + + // Stitch the tail. A chunk in the snapshot's domain is sliced against the + // snapshot seq (drains the painted prefix); a chunk from a later domain + // (post-exit seq restart) writes verbatim — production clears the baseline + // at the ptyId-exit boundary, so no seq dedupe applies across domains. + let forcedFreshRestore = false + for (let i = revealIdx; i < chunks.length; i++) { + const chunk = chunks[i]! + if (chunk.domain !== snapshotDomain) { + await writeChunksToTerminal(restored.terminal, [chunk.data]) + continue + } + const sliced = chunkDataAfterSnapshot(chunk, snapshotSeq) + if (sliced === null) { + // Production re-fetches a fresh snapshot here; a fresh snapshot of the + // full stream trivially matches the always-visible screen, so mark the + // scenario as a fresh-restore (excluded from the stitch comparison). + forcedFreshRestore = true + continue + } + if (sliced) { + await writeChunksToTerminal(restored.terminal, [sliced]) + } + } + const result = await readScreen(restored) + result.forcedFreshRestore = forcedFreshRestore + result.knownSerializeWrapBug = knownSerializeWrapBug + return result + } finally { + source.terminal.dispose() + restored.terminal.dispose() + } +} + +async function alwaysVisible( + dims: AgentTuiStreamDims, + chunks: MeteredChunk[] +): Promise { + const term = createRendererParityTerminal(dims) + try { + await writeChunksToTerminal( + term.terminal, + chunks.map((c) => c.data) + ) + return await readScreen(term) + } finally { + term.terminal.dispose() + } +} + +/** Reference for the seq-reconciliation gate: a snapshot of the WHOLE stream + * (reveal at the very end, no racing tail). Sharing the snapshot machinery with + * revealFromSnapshot cancels out snapshot-fidelity gaps (Bug C cursor restore, + * Bug B bold loss — owned by the fidelity suite), so a diff against a mid-point + * reveal is attributable purely to the tail-stitch seq arithmetic. */ +async function fullSnapshotReference( + dims: AgentTuiStreamDims, + chunks: MeteredChunk[] +): Promise { + return revealFromSnapshot(dims, chunks, chunks.length) +} + +type Scenario = { + seed: number + dims: AgentTuiStreamDims + chunks: MeteredChunk[] + revealIdx: number + domains: number + hasDropped: boolean +} + +const TAIL_WORDS = [ + 'reading', + 'tokens 12.4k', + '你好世界', + '터미널', + '✅ done', + 'PASS test.ts', + '+142 -37', + 'diff --git' +] as const +// Why no SGR 7 (inverse): inverse marks trailing BLANK cells with an +// inverse-fg the serializer round-trips slightly differently depending on how +// much of the buffer it captures — a serialize SGR-fidelity nuance (the Bug B +// class) that has nothing to do with seq reconciliation. Keeping it out of the +// tail keeps the styles gate a clean function of the byte-stitch. +const TAIL_SGR = ['\x1b[0m', '\x1b[31m', '\x1b[1m', '\x1b[38;5;204m', '\x1b[22m'] as const + +/** Append-only racing tail: SGR runs + text + newlines only. No absolute/ + * relative cursor motion, scroll regions, alt frames, or DECSC — so the tail + * paints wherever the cursor sits and its screen effect is a pure function of + * which bytes the seq-reconciliation applies. Terminal-state-loss garbles + * (Bugs C/D/E, scroll region) are pinned separately; this suite isolates the + * seq slice. */ +function buildAppendOnlyTail(rng: () => number, lines: number): string { + const out: string[] = [] + for (let i = 0; i < lines; i++) { + const words: string[] = [] + const count = 1 + Math.floor(rng() * 3) + for (let w = 0; w < count; w++) { + words.push(TAIL_WORDS[Math.floor(rng() * TAIL_WORDS.length)]!) + } + const sgr = TAIL_SGR[Math.floor(rng() * TAIL_SGR.length)]! + out.push(`${sgr}${words.join(' ')}\x1b[0m\r\n`) + } + return out.join('') +} + +/** Builds a seeded metered stream with a random reveal boundary. A running seq + * counter tags the LAST byte of each chunk; at most ONE mid-stream restart + * bumps the domain and resets the counter low (a revived ptyId with a fresh + * main-side counter — production clears the restored baseline at that exit + * boundary, so the post-restart tail writes verbatim). Rare unmetered chunks + * (no seq) and droppedOutput markers model no-metering runtimes and pending-cap + * trims. Op counts are kept modest so most scenarios reach the full + * non-scrolled comparison (the tail-stitch invariant); scrolled scenarios fall + * back to the snapshot-depth-tolerant path (see the assertion loop). */ +function buildScenario(seed: number): Scenario { + const rng = mulberry32(seed) + const dims = DIMS[Math.floor(rng() * DIMS.length)]! + // Hidden prefix: full agent-TUI churn (cursor motion, panels, alt frames, + // scroll regions, DECSC…) so the SNAPSHOT is taken over rich state. + const prefixOps = buildAgentTuiStreamOps(rng, dims, { + includeMouseModes: false, + includeOscHyperlinks: false, + opCount: 3 + Math.floor(rng() * 6) + }) + const prefixStream = prefixOps.join('') + const prefixRaw = splitIntoRandomChunks(mulberry32(seed ^ 0x51ed270b), prefixStream, { + minLen: 2, + maxLen: 64 + }) + // Racing tail: append-only content. Isolates the seq-reconciliation byte + // stitch from terminal-state-loss garbles (Bugs C/D/E — pinned separately). + const tailStream = buildAppendOnlyTail(rng, 2 + Math.floor(rng() * 5)) + const tailRaw = splitIntoRandomChunks(mulberry32(seed ^ 0x2545f491), tailStream, { + minLen: 2, + maxLen: 40 + }) + + const chunks: MeteredChunk[] = [] + let seq = 100 + Math.floor(rng() * 50) + let domain = 0 + let hasDropped = false + // At most one restart, placed inside the tail so the snapshot's own domain + // has a stable prefix — mirrors a single mid-session ptyId revival. + const restartAt = + rng() < 0.25 ? prefixRaw.length + Math.floor(rng() * Math.max(1, tailRaw.length)) : -1 + const allRaw = [...prefixRaw, ...tailRaw] + for (let i = 0; i < allRaw.length; i++) { + const data = allRaw[i]! + if (i === restartAt) { + seq = 10 + Math.floor(rng() * 20) + domain = 1 + } + const rawLength = data.length + seq += rawLength + const chunk: MeteredChunk = { data, seq, rawLength, domain } + // Rare unmetered / dropped markers, only in the tail (the prefix must meter + // so the snapshot carries a seq). + if (i >= prefixRaw.length && rng() < 0.05) { + delete chunk.seq + delete chunk.rawLength + } + if (i >= prefixRaw.length && rng() < 0.04) { + chunk.droppedOutput = true + hasDropped = true + } + chunks.push(chunk) + } + + // Reveal at or after the prefix ends, so the racing tail is purely the + // append-only region — the snapshot captures all cursor-motion/scroll/alt + // state and the tail cannot depend on unserialized terminal state. Landing + // the reveal at various points INSIDE the append-only tail exercises the + // straddle and "snapshot seq inside the tail" seq-slice cases. + const tailSpan = Math.max(1, chunks.length - prefixRaw.length) + const revealIdx = Math.min(chunks.length, prefixRaw.length + Math.floor(rng() * tailSpan)) + return { seed, dims, chunks, revealIdx, domains: domain + 1, hasDropped } +} + +function firstRowDiff(a: string[], b: string[]): number | null { + const n = Math.max(a.length, b.length) + for (let i = 0; i < n; i++) { + if ((a[i] ?? '') !== (b[i] ?? '')) { + return i + } + } + return null +} + +function formatFailure(s: Scenario, stage: string, expected: unknown, actual: unknown): string { + return [ + `hidden-reveal reconciliation divergence — stage: ${stage}`, + `seed: ${s.seed} (re-run: FUZZ_SEED=${s.seed})`, + `dims: ${s.dims.cols}x${s.dims.rows} revealIdx: ${s.revealIdx}/${s.chunks.length} domains: ${s.domains} dropped: ${s.hasDropped}`, + `chunks: ${JSON.stringify( + s.chunks.map((c) => ({ seq: c.seq, len: c.data.length, dom: c.domain })) + )}`, + `expected (always-visible): ${JSON.stringify(expected)}`, + `actual (reveal-from-snapshot): ${JSON.stringify(actual)}` + ].join('\n') +} + +describe('hidden reveal seq-reconciliation fuzz', () => { + it('is a byte-exact identity when the snapshot seq splits a straddling chunk', async () => { + // Pin the core invariant on a hand-built case so the property test cannot + // pass vacuously. rawLength === data.length so the straddle slices cleanly. + // Chunk 0 fully hidden; chunk 1 straddles the reveal; chunk 2 is the tail. + const dims = { cols: 40, rows: 6 } + const c0 = 'red line one\r\n' + const c1 = 'green straddle\r\n' + const c2 = 'plain tail rest' + const chunks: MeteredChunk[] = [ + { data: c0, seq: c0.length, rawLength: c0.length, domain: 0 }, + { data: c1, seq: c0.length + c1.length, rawLength: c1.length, domain: 0 }, + { data: c2, seq: c0.length + c1.length + c2.length, rawLength: c2.length, domain: 0 } + ] + // Reveal after chunk 1 (snapshot seq = end of c1); tail = c2. Also exercise + // the straddle by revealing at chunk 1 with a snapshot seq mid-c1 handled by + // chunkDataAfterSnapshot when c1 is in the tail — covered by revealIdx=1. + const revealAfterC1 = await revealFromSnapshot(dims, chunks, 2) + const control = await alwaysVisible(dims, chunks) + expect(revealAfterC1.rows).toEqual(control.rows) + expect(revealAfterC1.styles).toEqual(control.styles) + expect(revealAfterC1.cursor).toEqual(control.cursor) + + // Reveal at chunk 1: c0 hidden (snapshot seq = end of c0), c1+c2 are the + // tail. c1 is fully after the snapshot seq so it writes whole — identity. + const revealAtC1 = await revealFromSnapshot(dims, chunks, 1) + expect(revealAtC1.rows).toEqual(control.rows) + expect(revealAtC1.styles).toEqual(control.styles) + expect(revealAtC1.cursor).toEqual(control.cursor) + }) + + // ── Bug D regression guard: DECSC saved-cursor register across hide/reveal ── + // The serialized screen cannot carry the VT100 saved-cursor register, so a + // hidden DECSC followed by a post-reveal DECRC restored to home and the next + // writes clobbered the wrong cells. FIXED: the snapshot epilogue re-saves at + // the source's saved position before the final absolute CUP + // (serializeWithAbsoluteCursor + readSavedCursorRegister). Found by fuzz + // seed 3; mechanism in notes/garble-fuzz-divergences.md (Bug D). + it('preserves the DECSC saved-cursor register across a hide/reveal boundary', async () => { + const dims = { cols: 20, rows: 4 } + // Hidden: write 'AB', DECSC saves cursor at r0c2, move to r3c9, write 'CD'. + const hidden: MeteredChunk = { + data: 'AB\x1b7\x1b[4;10HCD', + seq: 'AB\x1b7\x1b[4;10HCD'.length, + rawLength: 'AB\x1b7\x1b[4;10HCD'.length, + domain: 0 + } + // Tail: DECRC restores the saved cursor, write 'X' → live shows 'ABX'. + const tail: MeteredChunk = { + data: '\x1b8X', + seq: hidden.seq! + 3, + rawLength: 3, + domain: 0 + } + const reveal = await revealFromSnapshot(dims, [hidden, tail], 1) + const live = await alwaysVisible(dims, [hidden, tail]) + // Pre-fix this showed 'XB' (DECRC landed at home) instead of live's 'ABX'. + expect(reveal.rows).toEqual(live.rows) + expect(reveal.cursor).toEqual(live.cursor) + }) + + // ── Bug E regression guard: snapshot boundary mid-escape-sequence ── + // A PTY read (one delivery record) can split an escape; the partial lives in + // the emulator's parser, not the serialized screen, so the racing tail's + // continuation rendered literal ('ABmCD'). FIXED: the emulator tracks the + // unparsed trailing partial (terminal-partial-escape-tail.ts) and the + // snapshot ships it out-of-band (pendingEscapeTailAnsi); the reveal replay + // re-arms it as the final write. Found by fuzz seed 4; mechanism in + // notes/garble-fuzz-divergences.md (Bug E). + it('completes an escape sequence split across the hide/reveal boundary', async () => { + const dims = { cols: 20, rows: 3 } + // Hidden prefix ends mid-escape: 'AB' then ESC[3 (no final byte). + const hidden: MeteredChunk = { data: 'AB\x1b[3', seq: 5, rawLength: 5, domain: 0 } + // Tail completes it: 'm' → ESC[3m (italic), then 'CD' italic. Live: 'ABCD'. + const tail: MeteredChunk = { data: 'mCD', seq: 8, rawLength: 3, domain: 0 } + const reveal = await revealFromSnapshot(dims, [hidden, tail], 1) + const live = await alwaysVisible(dims, [hidden, tail]) + // Pre-fix the reveal showed 'ABmCD' (the 'm' became literal). + expect(reveal.rows).toEqual(live.rows) + }) + + it(`stitches the racing tail losslessly across ${ITERATIONS} seeded hide/reveal scenarios`, async () => { + let statsCompared = 0 + let statsSkippedScrolled = 0 + let statsForcedFresh = 0 + let statsKnownWrapBug = 0 + for (let i = 0; i < ITERATIONS; i++) { + const seed = FIXED_SEED ?? 1 + i + const scenario = buildScenario(seed) + // Bug E (snapshot boundary mid-escape-sequence) is no longer tolerated: + // the snapshot now carries the trailing partial escape out-of-band and + // the reveal replay re-arms it last, so these scenarios must compare + // clean like any other — a regression fails the corpus loudly. + // Primary control: a snapshot of the WHOLE stream. Sharing the snapshot + // machinery isolates the seq-reconciliation tail-stitch from + // snapshot-fidelity gaps (fidelity suite's Bugs B/C). If snapshot-at-S + + // tail differs from snapshot-of-everything, a byte was lost/duped/mis- + // sliced. Also compare against always-visible where the two agree, to + // catch a tail that diverges from live output. + const [reveal, reference, live] = await Promise.all([ + revealFromSnapshot(scenario.dims, scenario.chunks, scenario.revealIdx), + fullSnapshotReference(scenario.dims, scenario.chunks), + alwaysVisible(scenario.dims, scenario.chunks) + ]) + if (reveal.forcedFreshRestore || reference.forcedFreshRestore) { + statsForcedFresh += 1 + continue + } + if (reveal.knownSerializeWrapBug || reference.knownSerializeWrapBug) { + statsKnownWrapBug += 1 + continue + } + if (reveal.alternate !== reference.alternate) { + expect.fail( + formatFailure(scenario, 'alt-screen-state', reference.alternate, reveal.alternate) + ) + } + // Scrolled scenarios: top-row survival is a snapshot-depth question + // (fidelity suite), not seq reconciliation. + if (reveal.scrolled || reference.scrolled) { + statsSkippedScrolled += 1 + continue + } + statsCompared += 1 + // Seq-reconciliation gate (snapshot-fidelity-neutral). + if (firstRowDiff(reveal.content, reference.content) !== null) { + expect.fail( + formatFailure(scenario, 'tail-stitch-content', reference.content, reveal.content) + ) + } + if (firstRowDiff(reveal.rows, reference.rows) !== null) { + expect.fail(formatFailure(scenario, 'tail-stitch-visible', reference.rows, reveal.rows)) + } + if (firstRowDiff(reveal.styles, reference.styles) !== null) { + expect.fail(formatFailure(scenario, 'tail-stitch-styles', reference.styles, reveal.styles)) + } + if (JSON.stringify(reveal.cursor) !== JSON.stringify(reference.cursor)) { + expect.fail(formatFailure(scenario, 'tail-stitch-cursor', reference.cursor, reveal.cursor)) + } + // Stronger gate: when a full-stream SNAPSHOT already matches the + // always-visible LIVE screen (no fidelity gap in play), the mid-point + // reveal must match live too — a true end-to-end garble check. + if ( + !live.scrolled && + live.alternate === reference.alternate && + firstRowDiff(reference.rows, live.rows) === null && + firstRowDiff(reference.styles, live.styles) === null && + JSON.stringify(reference.cursor) === JSON.stringify(live.cursor) + ) { + if (firstRowDiff(reveal.rows, live.rows) !== null) { + expect.fail(formatFailure(scenario, 'live-visible', live.rows, reveal.rows)) + } + if (firstRowDiff(reveal.styles, live.styles) !== null) { + expect.fail(formatFailure(scenario, 'live-styles', live.styles, reveal.styles)) + } + if (JSON.stringify(reveal.cursor) !== JSON.stringify(live.cursor)) { + expect.fail(formatFailure(scenario, 'live-cursor', live.cursor, reveal.cursor)) + } + } + } + // Guard against a degenerate corpus that skips its way to green: a healthy + // fraction of scenarios must reach the full non-scrolled comparison. + if (FIXED_SEED === null) { + expect(statsCompared).toBeGreaterThan(ITERATIONS * 0.2) + } + expect( + statsCompared + statsSkippedScrolled + statsForcedFresh + statsKnownWrapBug + ).toBeLessThanOrEqual(ITERATIONS) + }, 120_000) +}) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts new file mode 100644 index 00000000000..bd492d5194c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.test.ts @@ -0,0 +1,813 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSideEffectFact } from '../../../../shared/terminal-side-effect-facts' +import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher' + +const PTY_ID = 'pty-parked-1' +const TAB_ID = 'tab-1' +const WORKTREE_ID = 'repo-1::/tmp/wt-1' +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const PANE_KEY = `${TAB_ID}:${LEAF_ID}` +const PANE_ID = 1 +// Mirrors PARKED_NOTIFICATION_GRACE_MS / AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS. +const NOTIFICATION_GRACE_MS = 250 + +// Real agent-detection titles: braille spinner classifies as working, +// the "✳ " Claude prefix as idle, and both as Claude agents. +const WORKING_TITLE_OSC = '\x1b]0;⠋ Build feature\x07' +const IDLE_TITLE = '✳ Build feature' +const IDLE_TITLE_OSC = `\x1b]0;${IDLE_TITLE}\x07` + +type MockStoreState = { + settings: { + theme?: 'system' | 'dark' | 'light' + promptCacheTimerEnabled?: boolean + experimentalTerminalAttention?: boolean + terminalMainSideEffectAuthority?: boolean + terminalHiddenDeliveryGate?: boolean + notifications?: { enabled?: boolean; agentTaskComplete?: boolean } + } | null + setRuntimePaneTitle: ReturnType + clearRuntimePaneTitle: ReturnType + updateTabTitle: ReturnType + markWorktreeUnread: ReturnType + markTerminalTabUnread: ReturnType + markTerminalPaneUnread: ReturnType + setCacheTimerStartedAt: ReturnType + observeTerminalGitHubPullRequestLink: ReturnType +} + +const dispatchTerminalNotification = vi.fn() +let mockStoreState: MockStoreState + +vi.mock('./use-notification-dispatch', () => ({ + dispatchTerminalNotification +})) + +vi.mock('@/lib/terminal-theme', () => ({ + getSystemPrefersDark: () => true +})) + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => mockStoreState + } +})) + +function createMockStoreState(): MockStoreState { + return { + // Why: terminalMainSideEffectAuthority false pins the legacy byte-parser + // mode this suite was written for; the authority-on fact-consumer mode is + // covered by the dedicated describe block below. + settings: { + theme: 'system', + promptCacheTimerEnabled: true, + experimentalTerminalAttention: false, + terminalMainSideEffectAuthority: false, + notifications: { enabled: true, agentTaskComplete: true } + }, + setRuntimePaneTitle: vi.fn(), + clearRuntimePaneTitle: vi.fn(), + updateTabTitle: vi.fn(), + markWorktreeUnread: vi.fn(), + markTerminalTabUnread: vi.fn(), + markTerminalPaneUnread: vi.fn(), + setCacheTimerStartedAt: vi.fn(), + observeTerminalGitHubPullRequestLink: vi.fn() + } +} + +describe('startParkedTerminalByteWatcher', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let onData: ((payload: { id: string; data: string }) => void) | null = null + + function emit(data: string): void { + onData?.({ id: PTY_ID, data }) + } + + // The output processor defers title/bell side effects onto a 0ms drain timer. + function flushSideEffects(): void { + vi.advanceTimersByTime(0) + } + + async function startWatcher( + overrides: Partial = {} + ): Promise<{ dispose: () => void; sendInput: ReturnType }> { + const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher') + const sendInput = vi.fn() + const dispose = startParkedTerminalByteWatcher({ + ptyId: PTY_ID, + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneId: PANE_ID, + sendInput, + ...overrides + }) + return { dispose, sendInput } + } + + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + dispatchTerminalNotification.mockClear() + onData = null + mockStoreState = createMockStoreState() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + pty: { + onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => { + onData = callback + return () => {} + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + ackData: vi.fn() + } + } + } as unknown as typeof window + }) + + afterEach(() => { + vi.useRealTimers() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('forwards every OSC title in order to the pane and tab title store actions', async () => { + const { dispose } = await startWatcher() + + emit(`${WORKING_TITLE_OSC}${IDLE_TITLE_OSC}`) + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle.mock.calls).toEqual([ + [TAB_ID, PANE_ID, '⠋ Build feature'], + [TAB_ID, PANE_ID, IDLE_TITLE] + ]) + expect(mockStoreState.updateTabTitle.mock.calls).toEqual([ + [TAB_ID, '⠋ Build feature'], + [TAB_ID, IDLE_TITLE] + ]) + dispose() + }) + + it('drops the bare cursor-agent native title before it reaches the store', async () => { + const { dispose } = await startWatcher() + + emit('\x1b]0;Cursor Agent\x07') + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle).not.toHaveBeenCalled() + expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled() + dispose() + }) + + it('does not drive the tab title when drivesTabTitle is false', async () => { + const { dispose } = await startWatcher({ drivesTabTitle: false }) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled() + dispose() + }) + + it('marks unread on BEL and schedules the delayed terminal-bell OS notification', async () => { + const { dispose } = await startWatcher() + + emit('build finished\x07') + flushSideEffects() + + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID) + expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID) + expect(mockStoreState.markTerminalPaneUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'terminal-bell', + paneKey: PANE_KEY + }) + dispose() + }) + + it('marks the exact pane unread when experimental terminal attention is enabled', async () => { + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: true + } + const { dispose } = await startWatcher() + + emit('\x07') + flushSideEffects() + + expect(mockStoreState.markTerminalPaneUnread).toHaveBeenCalledWith(PANE_KEY) + dispose() + }) + + it('does not treat an OSC-terminator BEL as a bell, even split across chunks', async () => { + const { dispose } = await startWatcher() + + emit('\x1b]0;par') + emit('tial title\x07') + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('fires the prompt-cache timer and agent-task-complete on working→idle', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + flushSideEffects() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + PANE_KEY, + expect.any(Number) + ) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY + }) + dispose() + }) + + it('suppresses the completion OS notification when only terminal attention is on', async () => { + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: true, + notifications: { enabled: true, agentTaskComplete: false } + } + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY, + suppressOsNotification: true + }) + dispose() + }) + + it('skips completion dispatch when tracking is fully disabled, keeping the cache timer', async () => { + mockStoreState.settings = { + ...mockStoreState.settings, + experimentalTerminalAttention: false, + notifications: { enabled: false } + } + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + PANE_KEY, + expect.any(Number) + ) + dispose() + }) + + it('lets a same-burst completion supersede the pending bell OS notification', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + flushSideEffects() + emit(`${IDLE_TITLE_OSC}\x07`) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 8) + + // The bell still marks unread immediately; only the OS notification yields. + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID) + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith( + WORKTREE_ID, + expect.objectContaining({ source: 'agent-task-complete' }) + ) + dispose() + }) + + it('cancels the pending completion and clears the cache timer when working resumes', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + emit(WORKING_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + dispose() + }) + + it('answers a DECSET 2031 subscribe split across chunks via sendInput', async () => { + const { dispose, sendInput } = await startWatcher() + + emit('\x1b[?20') + expect(sendInput).not.toHaveBeenCalled() + + emit('31h') + expect(sendInput).toHaveBeenCalledTimes(1) + // theme=system + prefers-dark → dark reply per terminal-color-scheme-protocol. + expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + emit('\x1b[?2031l') + expect(sendInput).toHaveBeenCalledTimes(1) + dispose() + }) + + it('stops answering DECSET 2031 after dispose', async () => { + const { dispose, sendInput } = await startWatcher() + + dispose() + emit('\x1b[?2031h') + + expect(sendInput).not.toHaveBeenCalled() + }) + + it('observes GitHub PR links across chunk boundaries', async () => { + const { dispose } = await startWatcher() + + emit('PR: https://github.com/orca-dev/orca/pull/42') + expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() + + emit('1\r\ndone') + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1) + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith( + WORKTREE_ID, + expect.objectContaining({ + url: 'https://github.com/orca-dev/orca/pull/421', + number: 421, + slug: { owner: 'orca-dev', repo: 'orca' } + }) + ) + dispose() + }) + + it('fires completion when seeded with a working title and the agent goes idle while parked', async () => { + // Why: the pane was working at park time; the watcher's fresh tracker + // must be seeded or this working→idle transition can never fire. + const { dispose } = await startWatcher({ initialTitle: '⠋ Build feature' }) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + + expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY + }) + dispose() + }) + + it('does not fire completion for an idle title without a seed or observed transition', async () => { + const { dispose } = await startWatcher() + + emit(IDLE_TITLE_OSC) + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('clears the watcher-written runtime title slot on dispose', async () => { + const { dispose } = await startWatcher() + + emit(IDLE_TITLE_OSC) + flushSideEffects() + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + + dispose() + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID) + }) + + it('leaves the runtime title slot alone on dispose when it never wrote one', async () => { + const { dispose } = await startWatcher() + + emit('plain output with no titles\r\n') + flushSideEffects() + + dispose() + expect(mockStoreState.clearRuntimePaneTitle).not.toHaveBeenCalled() + }) + + it('shutdown dispose cancels the armed completion timer and silences the final flush', async () => { + const { dispose } = await startWatcher() + + emit(WORKING_TITLE_OSC) + emit(IDLE_TITLE_OSC) + flushSideEffects() + + // Equivalent to shutdownWorktreeTerminals → disposeParkedTerminalWatchersForPtyIds. + dispose() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + // The teardown flush that main emits after pty.kill must be a no-op. + emit('final teardown flush\x07') + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + }) + + it('dispose unregisters the sidecar and cancels the pending bell notification', async () => { + const { dispose } = await startWatcher() + + emit('\x07') + flushSideEffects() + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1) + + dispose() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + emit('\x07') + flushSideEffects() + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1) + + // Idempotent: a second dispose must not throw or clobber another watcher. + dispose() + }) + + it('disposes the previous watcher when a new one starts for the same PTY', async () => { + await startWatcher({ paneId: 1 }) + const second = await startWatcher({ paneId: 2 }) + + emit(IDLE_TITLE_OSC) + flushSideEffects() + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledTimes(1) + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2, IDLE_TITLE) + second.dispose() + }) + + // ─── Main side-effect authority (terminal-side-effect-authority.md) ──── + // + // With the kill switch on, the watcher must not register byte parsers — + // main is the single byte parser and the watcher's policy block consumes + // pty:sideEffect facts instead. The byte sidecar stays ONLY for the 2031 + // reply (query authority never moves to main); PR links arrive as facts. + describe('with main side-effect authority on', () => { + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } + } + + async function dispatchFacts( + facts: TerminalSideEffectFact[], + options: { seq?: number; replay?: boolean } = {} + ): Promise { + const handler = await import('./terminal-side-effect-facts-handler') + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: PTY_ID, + seq: options.seq ?? 0, + ...(options.replay ? { replay: true } : {}), + facts + }) + } + + /** Feed chunks the way OrcaRuntimeService.onPtyData does: OSC 9999 strip, + * shared title tracker, one fact batch per chunk — the main half of the + * migration-safety parity check. */ + async function emitViaMainTrackerFacts(chunks: string[]): Promise { + const { createAgentStatusOscProcessor } = await import('../../../../shared/agent-status-osc') + const { createTerminalTitleTracker } = + await import('../../../../shared/terminal-output-side-effects') + const handler = await import('./terminal-side-effect-facts-handler') + const processStatusChunk = createAgentStatusOscProcessor() + let pending: TerminalSideEffectFact[] = [] + const tracker = createTerminalTitleTracker({ + onTitle: (normalizedTitle, rawTitle) => + pending.push({ kind: 'title', normalizedTitle, rawTitle }), + onAgentBecameWorking: () => pending.push({ kind: 'agent-working' }), + onAgentBecameIdle: (title) => pending.push({ kind: 'agent-idle', title }), + onAgentExited: () => pending.push({ kind: 'agent-exited' }), + onBell: () => pending.push({ kind: 'bell' }) + }) + let seq = 0 + for (const chunk of chunks) { + seq += chunk.length + tracker.handleChunk(processStatusChunk(chunk).cleanData) + if (pending.length > 0) { + handler._dispatchTerminalSideEffectBatchForTest({ ptyId: PTY_ID, seq, facts: pending }) + pending = [] + } + } + tracker.dispose() + } + + type RecordedCall = [string, ...unknown[]] + + /** Wrap the policy-visible store actions so byte mode and fact mode can be + * compared as one ordered outcome sequence. Timestamps are masked. */ + function recordPolicyOutcomes(): RecordedCall[] { + const calls: RecordedCall[] = [] + mockStoreState.setRuntimePaneTitle.mockImplementation((...args: unknown[]) => { + calls.push(['setRuntimePaneTitle', ...args]) + }) + mockStoreState.updateTabTitle.mockImplementation((...args: unknown[]) => { + calls.push(['updateTabTitle', ...args]) + }) + mockStoreState.markWorktreeUnread.mockImplementation((...args: unknown[]) => { + calls.push(['markWorktreeUnread', ...args]) + }) + mockStoreState.markTerminalTabUnread.mockImplementation((...args: unknown[]) => { + calls.push(['markTerminalTabUnread', ...args]) + }) + mockStoreState.markTerminalPaneUnread.mockImplementation((...args: unknown[]) => { + calls.push(['markTerminalPaneUnread', ...args]) + }) + mockStoreState.setCacheTimerStartedAt.mockImplementation((key: unknown, at: unknown) => { + calls.push(['setCacheTimerStartedAt', key, typeof at === 'number' ? '' : at]) + }) + dispatchTerminalNotification.mockImplementation((...args: unknown[]) => { + calls.push(['dispatchTerminalNotification', ...args]) + }) + return calls + } + + it('does not consume bytes: a byte BEL produces no unread or notification', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + emit('build finished\x07') + flushSideEffects() + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('applies bell facts with the byte-mode policy: unread now, OS notification delayed', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([{ kind: 'bell' }]) + + expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID) + expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'terminal-bell', + paneKey: PANE_KEY + }) + dispose() + }) + + it('fires the cache timer and completion from working→idle facts', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([ + { kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' }, + { kind: 'agent-working' } + ]) + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + + await dispatchFacts([ + { kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }, + { kind: 'agent-idle', title: IDLE_TITLE } + ]) + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + PANE_KEY, + expect.any(Number) + ) + + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, { + source: 'agent-task-complete', + terminalTitle: IDLE_TITLE, + paneKey: PANE_KEY + }) + dispose() + }) + + it('clears state without completion attention for stale-derived idle facts', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([ + { kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' }, + { kind: 'agent-working' } + ]) + // Main's unthrottled 3s stale-title rewrite: titles/cache clear, but a + // merely-paused agent must not earn a task-complete notification. + await dispatchFacts([ + { + kind: 'title', + normalizedTitle: 'Build feature', + rawTitle: 'Build feature', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Build feature', staleWorkingTitleClear: true } + ]) + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenLastCalledWith( + TAB_ID, + PANE_ID, + 'Build feature' + ) + expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + dispose() + }) + + it('replay batches restore the title only — attention facts never replay', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts( + [ + { kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }, + { kind: 'bell' }, + { kind: 'agent-idle', title: IDLE_TITLE } + ], + { replay: true } + ) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(mockStoreState.setCacheTimerStartedAt).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + dispose() + }) + + it('answers DECSET 2031 from the main 2031-subscribe fact, never the byte scan', async () => { + // Why: with the hidden-delivery gate on (default), parked PTY bytes are + // dropped in main — the fact is the only 2031 signal, and the byte + // sidecar must NOT exist (its registration would re-enable delivery). + enableMainAuthority() + const { dispose, sendInput } = await startWatcher() + + emit('\x1b[?2031h') + expect(sendInput).not.toHaveBeenCalled() + + await dispatchFacts([{ kind: '2031-subscribe' }]) + expect(sendInput).toHaveBeenCalledTimes(1) + expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + // Why: pr-link facts arrive on the channel; byte-scanning here too + // would observe every link twice. + emit('PR: https://github.com/orca-dev/orca/pull/42\r\n') + expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() + dispose() + }) + + it('marks the PTY hidden for delivery on start and clears it on dispose', async () => { + enableMainAuthority() + const setHiddenRendererPty = vi.fn() + ;( + window as unknown as { api: { pty: Record } } + ).api.pty.setHiddenRendererPty = setHiddenRendererPty + const { dispose } = await startWatcher() + + expect(setHiddenRendererPty).toHaveBeenCalledWith(PTY_ID, true) + + dispose() + // Why: the unhide must land before reveal re-registers pane handlers — + // the watcher registry disposes watchers before the remount effect runs. + expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false) + }) + + it('keeps the byte 2031 responder and no hidden bit when the gate kill switch is off', async () => { + enableMainAuthority() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalHiddenDeliveryGate: false + } as MockStoreState['settings'] + const setHiddenRendererPty = vi.fn() + ;( + window as unknown as { api: { pty: Record } } + ).api.pty.setHiddenRendererPty = setHiddenRendererPty + const { dispose, sendInput } = await startWatcher() + + // Gate off — bytes keep flowing, so the split-chunk byte scan answers. + emit('\x1b[?20') + expect(sendInput).not.toHaveBeenCalled() + emit('31h') + expect(sendInput).toHaveBeenCalledTimes(1) + expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + // Why: a 2031-subscribe fact must not double-fire the reply in byte + // mode — exactly one responder owns the answer at any time. + await dispatchFacts([{ kind: '2031-subscribe' }]) + expect(sendInput).toHaveBeenCalledTimes(1) + + expect(setHiddenRendererPty).not.toHaveBeenCalled() + dispose() + }) + + it('observes PR links from pr-link facts with worktree attribution', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + const link = { + url: 'https://github.com/orca-dev/orca/pull/421', + slug: { owner: 'orca-dev', repo: 'orca' }, + number: 421 + } + await dispatchFacts([{ kind: 'pr-link', link }]) + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1) + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith( + WORKTREE_ID, + link + ) + dispose() + }) + + it('dispose unregisters the fact consumer and clears a written title slot', async () => { + enableMainAuthority() + const { dispose } = await startWatcher() + + await dispatchFacts([{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }]) + expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE) + + dispose() + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID) + + await dispatchFacts([{ kind: 'bell' }]) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4) + expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled() + expect(dispatchTerminalNotification).not.toHaveBeenCalled() + }) + + // The key migration-safety check: the same bytes produce the identical + // ordered store outcome whether the watcher parses them directly (kill + // switch off) or consumes main-derived facts over the channel. + it('produces identical store outcomes via the channel as the byte parser did', async () => { + const fixtureChunks = [WORKING_TITLE_OSC, 'agent response body\r\n', `${IDLE_TITLE_OSC}\x07`] + + // Pass 1: legacy byte-parser mode. + const byteModeCalls = recordPolicyOutcomes() + { + const { dispose } = await startWatcher() + for (const chunk of fixtureChunks) { + emit(chunk) + flushSideEffects() + } + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + dispose() + } + + // Pass 2: fresh modules/store, authority on, facts derived by the + // shared main tracker from the same bytes. + vi.resetModules() + mockStoreState = createMockStoreState() + dispatchTerminalNotification.mockReset() + const factModeCalls = recordPolicyOutcomes() + { + enableMainAuthority() + const { dispose } = await startWatcher() + await emitViaMainTrackerFacts(fixtureChunks) + vi.advanceTimersByTime(NOTIFICATION_GRACE_MS) + dispose() + } + + expect(byteModeCalls.length).toBeGreaterThan(0) + expect(factModeCalls).toEqual(byteModeCalls) + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts new file mode 100644 index 00000000000..d5bd9eec579 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-byte-watcher.ts @@ -0,0 +1,347 @@ +/** + * Parked terminal side-effect watcher. + * + * Why: parking unmounts the TerminalPane subtree, which tears down the pane's + * side-effect consumers — the parked tab's only source of bell, title, + * agent-completion, and PR-link policy. (Losing them is the gap that sank the + * first parking attempt.) Under main side-effect authority the watcher is + * purely fact-driven (one pty:sideEffect consumer, no byte parsing); with the + * kill switch off it registers the legacy byte parsers on the dispatcher + * sidecar channel. DECSET 2031 ownership follows the hidden-delivery gate: + * gate ON answers from main's '2031-subscribe' fact (no parked bytes exist), + * gate OFF keeps the byte sidecar (parked-terminal-mode2031-responder.ts). + * Either way the reply is sent from the renderer — query authority never + * moves to main. See docs/reference/terminal-hidden-view-parking.md and + * docs/reference/terminal-side-effect-authority.md. + */ +import { isClaudeAgent } from '../../../../shared/agent-detection' +import { makePaneKey } from '../../../../shared/stable-pane-id' +import { useAppStore } from '@/store' +import { + mode2031SequenceFor, + resolveTerminalColorSchemeMode +} from '../../../../shared/terminal-color-scheme-protocol' +import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import { + AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, + isAgentTaskCompleteOsNotificationEnabledFromState, + isAgentTaskCompleteTrackingEnabledFromState +} from './agent-task-complete-policy' +import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder' +import { subscribeToPtyData } from './pty-data-sidecar-subscriptions' +import { createPtyOutputProcessor } from './pty-transport' +import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate' +import { + isMainTerminalSideEffectAuthorityForPty, + registerTerminalSideEffectFactConsumer +} from './terminal-side-effect-facts-handler' +import { dispatchTerminalNotification } from './use-notification-dispatch' +import { acquireHiddenRendererPtyDeliveryClaim } from './pty-renderer-delivery-claims' + +// Why: mirrors AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS in pty-connection.ts. +// The parked path must keep the live path's BEL-vs-completion race window so +// notification behavior is identical whether a tab is parked or mounted. +const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS + +type StoreState = ReturnType + +function isAgentTaskCompleteOsNotificationEnabled(state: StoreState): boolean { + return isAgentTaskCompleteOsNotificationEnabledFromState(state) +} + +function isAgentTaskCompleteTrackingEnabled(state: StoreState): boolean { + return isAgentTaskCompleteTrackingEnabledFromState(state) +} + +export type ParkedTerminalByteWatcherOptions = { + ptyId: string + tabId: string + worktreeId: string + /** Stable terminal-layout leaf UUID. Combined with tabId into the paneKey + * used for cache-timer, unread, and notification attribution. */ + leafId: string + /** PaneManager pane id the unmounted pane used. Runtime pane titles are + * keyed by it, so the watcher must write the slot the live path wrote — + * a different id would leave a stale (possibly "working") title behind. */ + paneId: number + /** Whether this PTY's pane was the tab's active split pane. Mirrors the + * live path, where only the focused split drives the tab title. */ + drivesTabTitle?: boolean + /** The pane's last known runtime title at park time. Seeds the agent + * tracker so an agent that was working when the pane unmounted still + * fires its completion when it goes idle while parked. */ + initialTitle?: string + /** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */ + sendInput: (data: string) => void +} + +const parkedWatcherDisposersByPtyId = new Map void>() + +export function startParkedTerminalByteWatcher( + options: ParkedTerminalByteWatcherOptions +): () => void { + const { ptyId, tabId, worktreeId, paneId, sendInput } = options + const drivesTabTitle = options.drivesTabTitle ?? true + const paneKey = makePaneKey(tabId, options.leafId) + + // Why: one watcher per PTY. A stale watcher from a previous park cycle would + // double-fire bell/completion side effects for the same bytes. + parkedWatcherDisposersByPtyId.get(ptyId)?.() + + let disposed = false + let pendingBellNotification = false + // Why: a watcher-written runtime title (especially into a negative fallback + // slot) has no live pane to overwrite it after reveal; a stale 'working' + // entry would pin worktree status forever. Track writes so dispose can + // clear exactly the slot this watcher touched. + let wroteRuntimeTitleSlot = false + let bellNotificationTimer: ReturnType | null = null + let agentTaskCompleteTimer: ReturnType | null = null + + const clearBellNotificationTimer = (): void => { + if (bellNotificationTimer !== null) { + clearTimeout(bellNotificationTimer) + bellNotificationTimer = null + } + } + + const clearAgentTaskCompleteTimer = (): void => { + if (agentTaskCompleteTimer !== null) { + clearTimeout(agentTaskCompleteTimer) + agentTaskCompleteTimer = null + } + } + + // Why: like the live path, a BEL OS notification only yields when the + // pending completion would itself produce an OS notification. + const hasPendingAgentTaskCompleteNotification = (): boolean => + agentTaskCompleteTimer !== null && + isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState()) + + const scheduleTerminalBellNotification = (): void => { + if (bellNotificationTimer !== null) { + return + } + bellNotificationTimer = setTimeout(() => { + bellNotificationTimer = null + if (disposed) { + pendingBellNotification = false + return + } + if (hasPendingAgentTaskCompleteNotification()) { + return + } + pendingBellNotification = false + dispatchTerminalNotification(worktreeId, { source: 'terminal-bell', paneKey }) + }, PARKED_NOTIFICATION_GRACE_MS) + } + + // Why: one policy block for both consumption modes — byte parsing (kill + // switch off) and pty:sideEffect facts (main authority on). The semantics + // must be identical or flipping the switch changes notification behavior. + const sideEffectCallbacks = { + onTitleChange: (title: string): void => { + const state = useAppStore.getState() + wroteRuntimeTitleSlot = true + state.setRuntimePaneTitle(tabId, paneId, title) + if (drivesTabTitle) { + state.updateTabTitle(tabId, title) + } + }, + onBell: (): void => { + const state = useAppStore.getState() + state.markWorktreeUnread(worktreeId) + state.markTerminalTabUnread(tabId) + if (state.settings?.experimentalTerminalAttention === true) { + state.markTerminalPaneUnread(paneKey) + } + // Why: agent CLIs often emit BEL in the same completion burst as their + // working→idle title change. Delay only the OS notification so the + // richer agent-task-complete notification can win (live-path parity). + pendingBellNotification = true + if (!hasPendingAgentTaskCompleteNotification()) { + scheduleTerminalBellNotification() + } + }, + onAgentBecameIdle: (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => { + // Why: stale-derived idles come from main's unthrottled 3s timer, not + // observed bytes — clear session state, never schedule the completion + // notification a merely-paused agent did not earn (live-path parity). + if (meta?.staleWorkingTitleClear) { + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + return + } + const state = useAppStore.getState() + // Why: mirrors pty-connection — null settings means "not hydrated yet"; + // a spurious timestamp is harmless while a dropped one loses the timer. + if ( + isClaudeAgent(title) && + (state.settings === null || state.settings.promptCacheTimerEnabled) + ) { + state.setCacheTimerStartedAt(paneKey, Date.now()) + } + if (!isAgentTaskCompleteTrackingEnabled(state)) { + return + } + clearAgentTaskCompleteTimer() + agentTaskCompleteTimer = setTimeout(() => { + agentTaskCompleteTimer = null + if (disposed) { + return + } + // Why: the completion supersedes a concurrent BEL so each completion + // burst yields exactly one OS notification, same as the live path. + pendingBellNotification = false + clearBellNotificationTimer() + dispatchTerminalNotification(worktreeId, { + source: 'agent-task-complete', + terminalTitle: title, + paneKey, + ...(isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState()) + ? {} + : { suppressOsNotification: true }) + }) + }, PARKED_NOTIFICATION_GRACE_MS) + }, + onAgentBecameWorking: (): void => { + // Why: a new API call refreshes the prompt-cache TTL, so clear any + // running countdown; it restarts when the agent next becomes idle. + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + clearAgentTaskCompleteTimer() + if (pendingBellNotification) { + scheduleTerminalBellNotification() + } + }, + onAgentExited: (): void => { + // Why: title reverting to a plain shell means the agent session ended; + // a stale countdown must not survive in the sidebar while parked. + useAppStore.getState().setCacheTimerStartedAt(paneKey, null) + } + } + + // Why: parking eligibility excludes remote-runtime and SSH PTYs, so every + // watched PTY's bytes transit local main — when the authority switch is on, + // the watcher must NOT register byte parsers (the fact consumer below is + // the single policy consumer; double registration would double-fire bells). + const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({ + settings: useAppStore.getState().settings, + runtimeEnvironmentId: null + }) + // Why: under the Phase-4 gate a parked PTY needs no renderer bytes at all — + // facts carry side effects and the reveal remount restores from the model + // snapshot. Decided once at watcher start: it picks which 2031 responder + // (byte sidecar vs fact reply) exists, so it must never flip per chunk. + const hiddenDeliveryGateActive = + mainSideEffectAuthority && + isRendererHiddenPtyDeliveryGateEnabled(useAppStore.getState().settings) + + const sendMode2031Reply = (): void => { + const settings = useAppStore.getState().settings + sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()))) + } + + // Why (byte-parser mode only): reuse the transport's output processor so + // the parked path keeps the exact live-path parsing semantics — all-titles + // ordering, normalization, the cursor-agent native-title drop, the + // OSC-aware stateful bell detector, and the working/idle agent tracker. + // initialAgentTitle: an agent already working at park time must still + // produce a working→idle transition; main's continuous tracker covers this + // in fact-consumer mode. + const processor = mainSideEffectAuthority + ? null + : createPtyOutputProcessor({ + ...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}), + ...sideEffectCallbacks + }) + // Why (byte-parser mode only): with main authority, pr-link facts arrive on + // the channel below; byte-scanning too would observe every link twice. + const observeTerminalGitHubPRLink = mainSideEffectAuthority + ? null + : createTerminalGitHubPRLinkDetector() + const unregisterFactConsumer = mainSideEffectAuthority + ? registerTerminalSideEffectFactConsumer({ + ptyId, + // Why: no title snapshot on park — the pane's runtime title slot is + // already current at park time, exactly like the byte-parser mode. + callbacks: { + ...sideEffectCallbacks, + onPrLink: (link) => + useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link), + // Why (gate mode only): bytes never arrive while gated, so the 2031 + // subscribe arrives as a main-tracker fact instead of a byte scan. + // The reply is still sent from here — query authority stays with + // the view/watcher (model/view contract invariant 6). + ...(hiddenDeliveryGateActive ? { onMode2031Subscribe: sendMode2031Reply } : {}) + } + }) + : null + + // Why: no xterm exists while parked, so nothing answers a DECSET 2031 + // subscription. With the hidden-delivery gate OFF the byte responder is the + // parked path's only byte consumer under main authority. With the gate ON it + // must NOT register: its subscribeToPtyData sidecar doubles as a + // delivery-interest signal that would force-feed bytes to the gated PTY — + // the fact callback above replaces the byte scan. + const stopMode2031Responder = hiddenDeliveryGateActive + ? null + : startParkedTerminalMode2031Responder({ ptyId, sendInput }) + + // Why: parked tabs are the canonical hidden view — mark the PTY gated so + // main stops renderer byte delivery; dispose clears the bit before the + // reveal remount re-registers pane handlers (existing dispose ordering). + const releaseHiddenDeliveryClaim = hiddenDeliveryGateActive + ? acquireHiddenRendererPtyDeliveryClaim(ptyId) + : null + + // Why (byte-parser mode only): with main authority the watcher consumes + // pty:sideEffect facts exclusively and registers NO byte parsers here — + // title/bell/agent parsing and the PR-link scan would double-fire policy. + const unsubscribeByteParsers = + processor === null + ? null + : subscribeToPtyData(ptyId, (data) => { + // Why: empty pane callbacks — the watcher wants only the parser + // side effects; there is no xterm to deliver bytes to. + processor.processData(data, {}) + if (observeTerminalGitHubPRLink) { + for (const link of observeTerminalGitHubPRLink(data)) { + useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link) + } + } + }) + + const dispose = (): void => { + if (disposed) { + return + } + disposed = true + // Why: unhide BEFORE the reveal remount registers pane handlers — main + // resumes delivery and (if bytes were dropped) emits the restore marker + // the remounted pane's restore machinery consumes. + releaseHiddenDeliveryClaim?.() + stopMode2031Responder?.() + unsubscribeByteParsers?.() + unregisterFactConsumer?.() + // Why: cancels the deferred side-effect drain, stale-title timer, and + // tracker/bell-detector state so the watcher cannot fire after the + // revealed pane's live parsers take over. + processor?.clearAccumulatedState() + clearBellNotificationTimer() + clearAgentTaskCompleteTimer() + pendingBellNotification = false + // Why: the store merge never deletes title slots, so a watcher-written + // entry would strand after reveal (the revealing pane re-registers under + // its own pane id) and could pin worktree status 'working'. The revealed + // pane repopulates its slot via its own title flow. + if (wroteRuntimeTitleSlot) { + wroteRuntimeTitleSlot = false + useAppStore.getState().clearRuntimePaneTitle(tabId, paneId) + } + if (parkedWatcherDisposersByPtyId.get(ptyId) === dispose) { + parkedWatcherDisposersByPtyId.delete(ptyId) + } + } + parkedWatcherDisposersByPtyId.set(ptyId, dispose) + return dispose +} diff --git a/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts new file mode 100644 index 00000000000..da804026de6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/parked-terminal-mode2031-responder.ts @@ -0,0 +1,50 @@ +/** + * DECSET 2031 color-scheme responder for parked terminals (byte-scan mode). + * + * Why a dedicated byte sidecar: no xterm exists while a tab is parked, so + * nothing answers a TUI's mode-2031 theme subscription. Query authority stays + * with the view/watcher (model/view contract invariant 6), so this reply can + * never move to main. Phase 4: this subscribeToPtyData registration doubles + * as a delivery-interest signal, so it is only used while the hidden-delivery + * gate is OFF — gated parked PTYs answer from the main tracker's + * '2031-subscribe' fact instead (parked-terminal-byte-watcher.ts). + * + * Survives Phase 6 (skip-grammar deletion): mounted switch-off hidden panes + * answer 2031 from xterm once the background queue drains, but a PARKED tab + * has no xterm in any switch-off mode, and the '2031-subscribe' fact is only + * consumed while the gate is ON — this sidecar stays the only answerer here. + */ +import { + mode2031SequenceFor, + resolveTerminalColorSchemeMode, + scanMode2031Sequences +} from '../../../../shared/terminal-color-scheme-protocol' +import { useAppStore } from '@/store' +import { getSystemPrefersDark } from '@/lib/terminal-theme' +import { subscribeToPtyData } from './pty-data-sidecar-subscriptions' + +export type ParkedTerminalMode2031ResponderOptions = { + ptyId: string + /** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */ + sendInput: (data: string) => void +} + +export function startParkedTerminalMode2031Responder( + options: ParkedTerminalMode2031ResponderOptions +): () => void { + const { ptyId, sendInput } = options + // Why: a DECSET 2031 subscribe can be split across PTY chunks; the scan + // carries a bounded tail between chunks so split sequences still match. + let scanTail = '' + return subscribeToPtyData(ptyId, (data) => { + const scan = scanMode2031Sequences(scanTail, data) + scanTail = scan.tail + if (!scan.subscribe) { + return + } + // Why: reply with the resolved theme so TUIs that subscribe while parked + // still learn it before the pane is ever revealed. + const settings = useAppStore.getState().settings + sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()))) + }) +} diff --git a/src/renderer/src/components/terminal-pane/pty-connection-types.ts b/src/renderer/src/components/terminal-pane/pty-connection-types.ts index 70abd4fae3f..8e151c6ab5c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-types.ts @@ -76,4 +76,10 @@ export type PtyConnectionDeps = { setCacheTimerStartedAt: (key: string, ts: number | null) => void syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void + /** Records a DECSET 2031 subscription answered from main's + * '2031-subscribe' fact, mirroring the xterm CSI handler's registry write + * (paneMode2031 + last replied theme) so later theme flips push CSI 997. + * The reply itself is sent by the fact handler — query authority stays + * with the view (model/view contract invariant 6). */ + recordPaneMode2031Subscription?: (paneId: number, repliedMode: 'dark' | 'light') => void } 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 72aac62a8e8..f4e80152546 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -149,6 +149,8 @@ type StoreState = { terminalWindowsShell?: string terminalWindowsWslDistro?: string | null localWindowsRuntimeDefault?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string | null } + terminalMainSideEffectAuthority?: boolean + terminalHiddenDeliveryGate?: boolean notifications?: { enabled?: boolean agentTaskComplete?: boolean @@ -195,7 +197,10 @@ type StoreState = { } type ConnectCallbacks = { - onData?: (data: string, meta?: { seq?: number; rawLength?: number; background?: boolean }) => void + onData?: ( + data: string, + meta?: { seq?: number; rawLength?: number; background?: boolean; droppedOutput?: boolean } + ) => void onReplayData?: (data: string, meta?: { clearBeforeReplay?: boolean }) => void onError?: (msg: string) => void } @@ -379,7 +384,8 @@ function createPane(paneId: number) { type: 'normal' as const, viewportY: 0, baseY: 0, - cursorY: 0 + cursorY: 0, + cursorX: 0 } return { id: paneId, @@ -397,6 +403,7 @@ function createPane(paneId: number) { sendFocusMode: false }, options: { + scrollback: 5_000, ignoreBracketedPasteMode: false, theme: { foreground: '#eeeeee', @@ -738,7 +745,14 @@ describe('connectPanePty', () => { projects: [], sshConnectionStates: new Map(), cacheTimerByKey: {}, - settings: { promptCacheTimerEnabled: true, experimentalTerminalAttention: true }, + // Why: terminalMainSideEffectAuthority false pins the legacy renderer + // byte-parser wiring this suite asserts on (onTitleChange/onBell on the + // transport). The authority-on fact-consumer mode has its own tests. + settings: { + promptCacheTimerEnabled: true, + experimentalTerminalAttention: true, + terminalMainSideEffectAuthority: false + }, codexRestartNoticeByPtyId: {}, deferredSshReconnectTargets: [], deferredSshSessionIdsByTabId: {}, @@ -811,6 +825,8 @@ describe('connectPanePty', () => { hasChildProcesses: vi.fn().mockResolvedValue(false), write: vi.fn(), writeAccepted: vi.fn().mockResolvedValue(true), + setHiddenRendererPty: vi.fn(), + setPtyDeliveryInterest: vi.fn(), ackColdRestore: vi.fn(), onClearBufferRequest: vi.fn(() => vi.fn()), onSerializeBufferRequest: vi.fn(() => vi.fn()), @@ -2367,6 +2383,32 @@ describe('connectPanePty', () => { expect(manager.setActivePane).toHaveBeenCalledWith(1, { focus: true }) }) + it('closes a hidden split pane whose PTY exits before output instead of keeping a ghost', async () => { + // Why (regression, ghost blank pane): the keep above is a visible-failure + // UX. A hidden pane's bytes are withheld by the hidden-delivery gate, so + // "no output" proves nothing there — keeping it strands a binding-less + // pane that remounts as a permanently blank ghost on reveal. + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-pane-2') + transportFactoryQueue.push(transport) + const manager = createManager(2, 2) + const deps = createDeps({ + restoredLeafId: LEAF_2, + isVisibleRef: { current: false }, + paneTransportsRef: { current: new Map([[1, createMockTransport('pty-pane-1')]]) } + }) + + connectPanePty(createPane(2) as never, manager as never, deps as never) + const onPtyExit = createdTransportOptions[0]?.onPtyExit as ((ptyId: string) => void) | undefined + expect(onPtyExit).toBeTypeOf('function') + + onPtyExit?.('pty-pane-2') + + expect(deps.clearExitedPanePtyLayoutBinding).toHaveBeenCalledWith(2, 'pty-pane-2') + expect(deps.onPtyExitRef.current).not.toHaveBeenCalled() + expect(manager.closePane).toHaveBeenCalledWith(2) + }) + it('keeps a worktree sole terminal mounted when its freshly-spawned PTY exits before input (direnv failure)', async () => { // Why (regression): a PR worktree can ship an .envrc whose direnv command // fails, so the only terminal's login shell exits non-zero immediately. The @@ -7316,18 +7358,89 @@ describe('connectPanePty', () => { expect(transport.sendInput).not.toHaveBeenCalled() }) - it('keeps non-visible local PTY bytes on the live xterm path for release', async () => { - const pendingTimeouts: (() => void)[] = [] - const originalSetTimeout = globalThis.setTimeout - globalThis.setTimeout = vi.fn((fn: () => void) => { - pendingTimeouts.push(fn) - return 999 as unknown as ReturnType - }) as unknown as typeof setTimeout + // Why: Phase 6 deleted the hidden-skip eligibility grammar. With the kill + // switch off, EVERY hidden chunk — plain, control-heavy, rich glyphs, + // synchronized frames, embedded queries — rides the bounded background + // scheduler queue and parses in xterm; nothing is content-scanned per chunk. + it('queues hidden PTY bytes on the background scheduler without per-chunk scanning', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + + connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + expect(capturedDataCallback.current).not.toBeNull() + vi.useFakeTimers() try { + const hiddenChunks = [ + 'plain hidden text\r\n', + '\x1b[2J\x1b[Hcontrol redraw\r\n', + '\x1b[2J\x1b[H╭ table 😀 ╮\r\n', + '\x1b[?2026h| Sam Syntax | 😀 |\r\n\x1b[?2026l', + '\x1b[?2026h\x1b[6n' + ] + for (const chunk of hiddenChunks) { + capturedDataCallback.current?.(chunk) + } + + // Background path defers writes; nothing is written synchronously. + expect(pane.terminal.write).not.toHaveBeenCalled() + vi.advanceTimersByTime(50) + // The drain may coalesce queued chunks into one write — assert content. + const written = pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + for (const chunk of hiddenChunks) { + expect(written).toContain(chunk) + } + // No model restore is latched for bounded hidden output. + expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() + } finally { + vi.useRealTimers() + } + }) + + describe('hidden-delivery gate', () => { + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } as StoreState['settings'] + } + + function getSetHiddenRendererPtyMock(): ReturnType { + return window.api.pty.setHiddenRendererPty as unknown as ReturnType + } + + async function connectHiddenPane(deps: ReturnType): Promise<{ + transport: MockTransport + pane: ReturnType + dataCallback: ( + data: string, + meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } + ) => void + binding: { syncProcessTracking: () => void; dispose: () => void } + }> { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + const capturedDataCallback: { + current: + | (( + data: string, + meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } + ) => void) + | null + } = { current: null } transport.connect.mockImplementation( async ({ callbacks }: { callbacks: ConnectCallbacks }) => { capturedDataCallback.current = callbacks.onData ?? null @@ -7335,101 +7448,721 @@ describe('connectPanePty', () => { } ) transportFactoryQueue.push(transport) - const pane = createPane(1) const manager = createManager(1) + const binding = connectPanePty(pane as never, manager as never, deps as never) as { + syncProcessTracking: () => void + dispose: () => void + } + await flushAsyncTicks(6) + expect(capturedDataCallback.current).not.toBeNull() + return { transport, pane, dataCallback: capturedDataCallback.current!, binding } + } + + it('marks the PTY hidden on hidden output and clears it before requesting restore on reveal', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'model snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + pane.terminal.options.scrollback = 50_000 + + dataCallback('hidden output\r\n', { seq: 16, rawLength: 16 }) + expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) + + // Why: with the skip grammar gone, gated drops latch the restore via + // main's out-of-band marker, not a renderer-side content scan. + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + + // Reveal rides the visible-resume backlog recovery hook. + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 50_000 }) + // The unhide IPC must precede the snapshot request (seq-guard contract). + const unhideOrder = setHiddenRendererPty.mock.invocationCallOrder.at(-1)! + const snapshotOrder = getMainBufferSnapshot.mock.invocationCallOrder[0]! + expect(unhideOrder).toBeLessThan(snapshotOrder) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('model snapshot'), + expect.any(Function) + ) + }) + + it('clears the hidden bit on visibility flips through syncProcessTracking', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { dataCallback, binding } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + ;(deps.isVisibleRef as { current: boolean }).current = true + binding.syncProcessTracking() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + + // Hiding again re-marks through the same lifecycle hook. + ;(deps.isVisibleRef as { current: boolean }).current = false + binding.syncProcessTracking() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + }) + + it('marks hidden codex panes immediately — no startup renderer-query window remains', async () => { + enableMainAuthority() const deps = createDeps({ - isVisibleRef: { current: false } + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const { transport, dataCallback } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + // Why: Phase 6 deleted the 10s codex window — codex startups gate like + // any hidden pane and the main responder answers their startup probes. + dataCallback('startup probe output\r\n') + expect(setHiddenRendererPty).toHaveBeenCalledWith('pty-id', true) + + // The fact stays the sole 2031 responder for gate-managed PTYs. + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 8, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + }) + + it('latches model restore from the out-of-band marker and restores on reveal', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'dropped bytes snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + // Why: the marker subscription is keyed by the live PTY id — the byte + // path latches it on the first hidden chunk, like the hidden mark. + dataCallback('pre-drop output\r\n', { seq: 16, rawLength: 17 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + + // Main dropped gated bytes and signalled it out-of-band. + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'hidden-drop', markerSeq: 64 }) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + ;(deps.isVisibleRef as { current: boolean }).current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('dropped bytes snapshot'), + expect.any(Function) + ) + }) + + it('answers each 2031-subscribe fact exactly once, before any hidden mark exists', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport } = await connectHiddenPane(deps) + // Simulate the transport's spawn completion so the pane registers its + // side-effect fact consumer (the mock transport never calls onPtySpawn). + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + // Why: no pty:data has flowed, so no hidden mark was sent — the fact + // can outrun the mark (codex post-startup-window race) and must still + // reply: ownership is structural, never mark-dependent. + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(1) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + + // Why: a visible gated pane still answers via the fact — the lifecycle + // suppresses the xterm CSI reply for gate-managed panes, so this stays + // the only reply for the new subscribe. + ;(deps.isVisibleRef as { current: boolean }).current = true + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 24, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).toHaveBeenCalledTimes(2) + expect(transport.sendInput).toHaveBeenLastCalledWith('\x1b[?997;1n') + }) + + it('registers the fact-answered 2031 subscription for later theme flips', async () => { + enableMainAuthority() + const recordPaneMode2031Subscription = vi.fn() + const deps = createDeps({ + isVisibleRef: { current: false }, + recordPaneMode2031Subscription + }) + const { transport } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const factsHandler = await import('./terminal-side-effect-facts-handler') + + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] }) - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) + expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;1n') + // Why: without the registry write, applyTerminalAppearance's + // maybePushMode2031Flip never pushes CSI 997 after a theme change and + // the revealed TUI keeps a stale theme. + expect(recordPaneMode2031Subscription).toHaveBeenCalledWith(1, 'dark') + }) - expect(capturedDataCallback.current).not.toBeNull() - capturedDataCallback.current?.('hello\r\n') - expect(pane.terminal.write).not.toHaveBeenCalledWith('hello\r\n') + it('reports the gate-managed predicate on the binding for the xterm 2031 observer', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { binding } = await connectHiddenPane(deps) + const bindingWithPredicate = binding as typeof binding & { + isHiddenDeliveryGateManagedPty: () => boolean + } + expect(bindingWithPredicate.isHiddenDeliveryGateManagedPty()).toBe(true) + }) - for (const fn of pendingTimeouts) { - fn() + it('declares hidden-at-spawn on connect for hidden panes', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport } = await connectHiddenPane(deps) + // Why: waiting for the first dataCallback sync left a spawn-time query + // window where neither side replied (the spawn-time DA1 loss). The flag + // lets main mark the PTY hidden before its first byte. + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ initiallyHidden: true }) + ) + }) + + it('keeps visible spawns undeclared (visible spawn unchanged)', async () => { + enableMainAuthority() + const deps = createDeps() + const { transport } = await connectHiddenPane(deps) + expect(transport.connect.mock.calls[0]![0]).not.toHaveProperty('initiallyHidden') + }) + + it('declares hidden-at-spawn for hidden codex panes too', async () => { + enableMainAuthority() + const deps = createDeps({ + isVisibleRef: { current: false }, + startup: { command: 'codex' } + }) + const { transport } = await connectHiddenPane(deps) + // Why: the 10s codex startup window is deleted — codex spawns are + // main-owned from byte zero, with the model responder answering their + // startup probes (including ConPTY's blocking DA1; the main-side pin is + // pty.test.ts 'answers DA1 from the model on the first chunk of a + // hidden-at-spawn PTY'). + expect(transport.connect).toHaveBeenCalledWith( + expect.objectContaining({ initiallyHidden: true }) + ) + }) + + it('does not gate or fact-reply when the hidden-delivery kill switch is off', async () => { + enableMainAuthority() + mockStoreState.settings = { + ...mockStoreState.settings, + terminalHiddenDeliveryGate: false + } as StoreState['settings'] + const deps = createDeps({ isVisibleRef: { current: false } }) + const { transport, dataCallback, binding } = await connectHiddenPane(deps) + // Why: the lifecycle's xterm CSI observer consults this predicate — + // kill switch off must keep the legacy xterm reply path. + expect( + ( + binding as typeof binding & { isHiddenDeliveryGateManagedPty: () => boolean } + ).isHiddenDeliveryGateManagedPty() + ).toBe(false) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).not.toHaveBeenCalled() + + // Why: gate off keeps the byte-scan responder authoritative — the fact + // must not produce a second reply for the same subscribe. + const factsHandler = await import('./terminal-side-effect-facts-handler') + factsHandler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-id', + seq: 12, + facts: [{ kind: '2031-subscribe' }] + }) + expect(transport.sendInput).not.toHaveBeenCalled() + }) + + it('clears a marked-hidden PTY on dispose so a remount is never gated', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: false } }) + const { dataCallback, binding } = await connectHiddenPane(deps) + const setHiddenRendererPty = getSetHiddenRendererPtyMock() + + dataCallback('hidden output\r\n') + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', true) + + binding.dispose() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith('pty-id', false) + }) + + it('never treats a live chunk that strips to empty as a restore marker', async () => { + // Why: a chunk that is purely OSC 9999 reaches the data callback as '' + // (transport stripping) — only the out-of-band pty:modelRestoreNeeded + // channel may trigger a snapshot restore, or visible panes would be + // spuriously cleared and repainted mid-session. + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { dataCallback } = await connectHiddenPane(deps) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + + dataCallback('', { seq: 32, rawLength: 24 }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + }) + + it('gates marker re-arms during an in-flight foreground restore and repaints once after', async () => { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const firstSnapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + }>() + getMainBufferSnapshot + .mockReturnValueOnce(firstSnapshot.promise) + .mockResolvedValue({ data: 'post-flood repaint\r\n', cols: 100, rows: 30, seq: 96 }) + const { _dispatchPtyModelRestoreNeededForTest } = await import('./pty-model-restore-channel') + + _dispatchPtyModelRestoreNeededForTest({ id: 'pty-id', reason: 'pending-cap', markerSeq: 64 }) + await flushAsyncTicks(4) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + try { + // Why (rc.7.perf feedback loop): a second drop marker while the first + // snapshot is still serializing on a VISIBLE pane is this pane's own + // restore backpressure. Re-fetching per marker kept the loop alive for + // the whole flood — the marker must NOT schedule another fetch. + vi.useFakeTimers() + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 80 + }) + firstSnapshot.resolve({ data: 'first snapshot\r\n', cols: 100, rows: 30, seq: 64 }) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + // Flood quiet: the suppression window elapses and exactly ONE deferred + // repaint fetches a fresh snapshot to heal the dropped gap. + vi.advanceTimersByTime(2_100) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + describe('foreground flood restore feedback loop (rc.7.perf)', () => { + function writtenFloodData(pane: ReturnType): string { + return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') } - expect(pane.terminal.write).toHaveBeenCalledWith('hello\r\n') - } finally { - globalThis.setTimeout = originalSetTimeout - } - }) + async function startInFlightRestore(): Promise<{ + pane: ReturnType + transport: MockTransport + dataCallback: ( + data: string, + meta?: { seq?: number; rawLength?: number; droppedOutput?: boolean } + ) => void + getMainBufferSnapshot: ReturnType + resolveFirstSnapshot: (snapshot: { + data: string + cols: number + rows: number + seq: number + }) => void + }> { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { pane, transport, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const firstSnapshot = createDeferred<{ + data: string + cols: number + rows: number + seq: number + }>() + getMainBufferSnapshot + .mockReturnValueOnce(firstSnapshot.promise) + .mockResolvedValue({ data: 'repaint snapshot\r\n', cols: 100, rows: 30, seq: 5_000_000 }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 64 + }) + await flushAsyncTicks(4) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + return { + pane, + transport, + dataCallback, + getMainBufferSnapshot, + resolveFirstSnapshot: (snapshot) => firstSnapshot.resolve(snapshot) + } + } - it('keeps visually rich hidden PTY bytes on the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' - }) - transportFactoryQueue.push(transport) + it('abandons the restore on queue overflow, writes the stream through, and repaints once', async () => { + const { pane, dataCallback, getMainBufferSnapshot, resolveFirstSnapshot } = + await startInFlightRestore() - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } + // Flood while the snapshot is in flight: overflows the 512KB restore + // queue — the live stream is outrunning snapshot fetch+replay. + dataCallback('f'.repeat(300 * 1024), { seq: 300 * 1024 + 64, rawLength: 300 * 1024 }) + dataCallback('g'.repeat(300 * 1024), { seq: 600 * 1024 + 64, rawLength: 300 * 1024 }) + + try { + vi.useFakeTimers() + resolveFirstSnapshot({ data: 'flood snapshot\r\n', cols: 100, rows: 30, seq: 64 }) + await flushAsyncTicks(20) + + // Cut 1: the overflow abandons the restore instead of re-fetching. + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + + // Cut 2: drop sentinels and seq-gap chunks during the flood window + // must not re-arm restores — the post-gap bytes write through. + dataCallback('', { droppedOutput: true }) + dataCallback('AFTER-FLOOD', { seq: 700 * 1024, rawLength: 11 }) + await flushAsyncTicks(8) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(writtenFloodData(pane)).toContain('AFTER-FLOOD') + + // After the flood goes quiet: exactly ONE deferred repaint. + vi.advanceTimersByTime(2_100) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + vi.advanceTimersByTime(5_000) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('salvages stateful queries out of an overflowing restore queue', async () => { + const { pane, transport, dataCallback } = await startInFlightRestore() + + // Queue 400KB, then a chunk that overflows the cap and carries a DSR + // probe. The content is discarded (snapshot owns it) but the probe's + // reply is SYNTHESIZED directly — replaying it into xterm would race + // the restore's discard and the replay guard's auto-reply swallow. + dataCallback('a'.repeat(400 * 1024), { seq: 400 * 1024, rawLength: 400 * 1024 }) + dataCallback(`${'b'.repeat(200 * 1024)}\x1b[6n`, { + seq: 600 * 1024 + 4, + rawLength: 200 * 1024 + 4 + }) + await flushAsyncTicks(8) + + const replies = transport.sendInput.mock.calls.map((call) => String(call[0])) + // oxlint-disable-next-line no-control-regex -- the ESC byte IS the payload: this matches the CPR reply + expect(replies.some((reply) => /^\u001b\[\d+;\d+R$/.test(reply))).toBe(true) + const written = writtenFloodData(pane) + expect(written).not.toContain('aaaa') + expect(written).not.toContain('bbbb') + }) + + it('keeps the hidden-pane drop sentinel arming a reveal restore (gate unchanged)', async () => { + enableMainAuthority() + const isVisibleRef = { current: false } + const deps = createDeps({ isVisibleRef }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'hidden reveal snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + + // Hidden pane: the sentinel latches restore-needed but must not fetch. + dataCallback('', { droppedOutput: true }) + await flushAsyncTicks(8) + expect(getMainBufferSnapshot).not.toHaveBeenCalled() + + // Reveal: the latched restore fetches exactly one snapshot. + isVisibleRef.current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(writtenFloodData(pane)).toContain('hidden reveal snapshot') + }) }) - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) + describe('post-restore backlog reconciliation', () => { + async function restoreVisiblePaneToBaseline(): Promise<{ + pane: ReturnType + dataCallback: (data: string, meta?: { seq?: number; rawLength?: number }) => void + getMainBufferSnapshot: ReturnType + }> { + enableMainAuthority() + const deps = createDeps({ isVisibleRef: { current: true } }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'restored snapshot\r\n', + cols: 100, + rows: 30, + seq: 64 + }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 64 + }) + await flushAsyncTicks(20) + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(1) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('restored snapshot'), + expect.any(Function) + ) + pane.terminal.write.mockClear() + return { pane, dataCallback, getMainBufferSnapshot } + } - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - try { - const hiddenTuiChunk = '\x1b[2J\x1b[H╭ table 😀 ╮\r\n' - capturedDataCallback.current?.(hiddenTuiChunk) + function writtenData(pane: ReturnType): string { + return pane.terminal.write.mock.calls.map((call) => String(call[0])).join('') + } - expect(pane.terminal.write).not.toHaveBeenCalledWith(hiddenTuiChunk) - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(hiddenTuiChunk) - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } - }) + it('drops backlog chunks the restored snapshot already covers', async () => { + const { pane, dataCallback } = await restoreVisiblePaneToBaseline() - it('keeps split hidden synchronized output frames on the live xterm path', async () => { - const { connectPanePty } = await import('./pty-connection') - const transport = createMockTransport('pty-id') - const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } - transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { - capturedDataCallback.current = callbacks.onData ?? null - return 'pty-id' + // Whole chunk at or before the baseline seq: duplicate, never written. + dataCallback('OLD-DUPLICATE', { seq: 60, rawLength: 13 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).not.toContain('OLD-DUPLICATE') + + // Contiguous post-baseline chunk flows through normally. + dataCallback('NEW', { seq: 67, rawLength: 3 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('NEW') + }) + + it('slices a partial overlap when raw and clean lengths match', async () => { + const { pane, dataCallback } = await restoreVisiblePaneToBaseline() + + // start seq 61 < baseline 64 < end seq 67 — only the last 3 chars are new. + dataCallback('ABCDEF', { seq: 67, rawLength: 6 }) + await flushAsyncTicks(8) + + const written = writtenData(pane) + expect(written).toContain('DEF') + expect(written).not.toContain('ABC') + }) + + it('forces a fresh snapshot for an overlap whose offsets cannot be mapped', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'second snapshot\r\n', + cols: 100, + rows: 30, + seq: 80 + }) + + // rawLength (6) !== data.length (4): renderer-side OSC stripping makes + // the slice offset unmappable — restore from a fresh snapshot instead. + dataCallback('ABCD', { seq: 67, rawLength: 6 }) + await flushAsyncTicks(20) + + expect(writtenData(pane)).not.toContain('ABCD') + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(writtenData(pane)).toContain('second snapshot') + }) + + it('detects a seq gap after restore and forces another restore', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'gap-heal snapshot\r\n', + cols: 100, + rows: 30, + seq: 120 + }) + + // Why: a chunk starting past the continuity point (start seq 87 > + // expected 64) means main trimmed bytes after the one-shot overflow + // marker was consumed — only the model snapshot can heal the gap. + dataCallback('AFTER-GAP', { seq: 96, rawLength: 9 }) + await flushAsyncTicks(20) + + expect(writtenData(pane)).not.toContain('AFTER-GAP') + expect(getMainBufferSnapshot).toHaveBeenCalledTimes(2) + expect(writtenData(pane)).toContain('gap-heal snapshot') + }) + + it('writes genuinely-new live output whose seq sits below an empty-backlog baseline', async () => { + // E2E twin (terminal-hidden-tui-visual-restore "keeps newer live + // output correct"): main's snapshot seq is a cumulative PTY counter + // (shell init + prompt echo + hidden frame), while a synthetic live + // chunk meters only its own frames — far below the baseline. With an + // empty pending queue main can never re-deliver seqs at or below the + // snapshot, so the chunk must write, never silently drop. + enableMainAuthority() + const isVisibleRef = { current: true } + const deps = createDeps({ isVisibleRef }) + const { pane, dataCallback } = await connectHiddenPane(deps) + const transportOptions = createdTransportOptions.at(-1) as { + onPtySpawn?: (ptyId: string) => void + } + transportOptions.onPtySpawn?.('pty-id') + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + // Visible prompt echo metered in main's cumulative seq domain. + dataCallback('$ node frame-script.mjs\r\n', { seq: 2_315, rawLength: 25 }) + // Pane hides mid-stream; main drops the hidden frame and marks restore. + isVisibleRef.current = false + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'hidden-drop', + markerSeq: 2_472 + }) + // Reveal: the snapshot covers everything ingested; pending queue empty + // (pendingDeliveryStartSeq === seq). + getMainBufferSnapshot.mockResolvedValue({ + data: 'LOW_RISK_RESTORE_FRAME_40\r\n', + cols: 100, + rows: 30, + seq: 2_472, + pendingDeliveryStartSeq: 2_472 + }) + isVisibleRef.current = true + const { requestTerminalBacklogRecovery } = + await import('@/lib/pane-manager/pane-terminal-output-scheduler') + requestTerminalBacklogRecovery(pane.terminal as never) + await flushAsyncTicks(20) + expect(writtenData(pane)).toContain('LOW_RISK_RESTORE_FRAME_40') + pane.terminal.write.mockClear() + + // Newer live frame injected with a seq domain unrelated to main's + // counter (e2e __terminalPtyDataInjection twin). + dataCallback('LOW_RISK_RESTORE_FRAME_41\r\n', { seq: 315, rawLength: 27 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('LOW_RISK_RESTORE_FRAME_41') + + // The retired baseline keeps subsequent low-seq live chunks flowing. + dataCallback('progress=041\r\n', { seq: 329, rawLength: 14 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('progress=041') + }) + + it('keeps suppressing backlog duplicates inside the reported pending window', async () => { + const { pane, dataCallback, getMainBufferSnapshot } = await restoreVisiblePaneToBaseline() + getMainBufferSnapshot.mockResolvedValue({ + data: 'windowed snapshot\r\n', + cols: 100, + rows: 30, + seq: 96, + pendingDeliveryStartSeq: 80 + }) + const { _dispatchPtyModelRestoreNeededForTest } = + await import('./pty-model-restore-channel') + _dispatchPtyModelRestoreNeededForTest({ + id: 'pty-id', + reason: 'pending-cap', + markerSeq: 96 + }) + await flushAsyncTicks(20) + expect(writtenData(pane)).toContain('windowed snapshot') + pane.terminal.write.mockClear() + + // Inside the pending window (80, 96]: a draining backlog duplicate. + dataCallback('IN-WINDOW-DUP-16', { seq: 96, rawLength: 16 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).not.toContain('IN-WINDOW-DUP-16') + + // Past the baseline: genuinely-new live output still flows. + dataCallback('PAST-BASELINE', { seq: 109, rawLength: 13 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('PAST-BASELINE') + + // Below the pending window (≤ 80): main can never re-send these seqs, + // so this is a foreign seq domain — written, never silently dropped. + dataCallback('BELOW-WINDOW', { seq: 60, rawLength: 12 }) + await flushAsyncTicks(8) + expect(writtenData(pane)).toContain('BELOW-WINDOW') + }) }) - transportFactoryQueue.push(transport) - - const pane = createPane(1) - const manager = createManager(1) - const deps = createDeps({ - isVisibleRef: { current: false } - }) - - connectPanePty(pane as never, manager as never, deps as never) - await flushAsyncTicks(6) - - expect(capturedDataCallback.current).not.toBeNull() - vi.useFakeTimers() - try { - const startChunk = '\x1b[?2026h' - const plainRowChunk = '| Sam Syntax | Compiler | Online |\r\n' - const endChunk = 'LONG_TABLE_SCROLL_RESTORE_marker\r\n\x1b[?2026l' - - capturedDataCallback.current?.(startChunk) - capturedDataCallback.current?.(plainRowChunk) - capturedDataCallback.current?.(endChunk) - - expect(pane.terminal.write).not.toHaveBeenCalledWith(plainRowChunk) - vi.advanceTimersByTime(50) - expect(pane.terminal.write).toHaveBeenCalledWith(`${startChunk}${plainRowChunk}${endChunk}`) - expect(window.api.pty.getMainBufferSnapshot).not.toHaveBeenCalled() - } finally { - vi.useRealTimers() - } }) it('schedules WebGL atlas recovery after hidden synchronized output parses', async () => { @@ -7986,7 +8719,7 @@ describe('connectPanePty', () => { binding.dispose() }) - it('side-channel answers mode 2031 when hidden Codex output is snapshot-backed', async () => { + it('writes mode 2031 through hidden xterm instead of side-channel answering it', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } @@ -8002,17 +8735,10 @@ describe('connectPanePty', () => { const pane = createPane(1) const manager = createManager(1) - const paneMode2031Ref = { current: new Map() } - const paneLastThemeModeRef = { current: new Map() } const binding = connectPanePty( pane as never, manager as never, - createDeps({ - isVisibleRef: { current: false }, - paneMode2031Ref, - paneLastThemeModeRef, - startup: { command: 'codex' } - }) as never + createDeps({ isVisibleRef: { current: false } }) as never ) await flushAsyncTicks(6) @@ -8021,10 +8747,8 @@ describe('connectPanePty', () => { capturedDataCallback.current?.('\x1b[?2031h') vi.advanceTimersByTime(50) - expect(transport.sendInput).toHaveBeenCalledWith('\x1b[?997;2n') - expect(paneMode2031Ref.current.get(1)).toBe(true) - expect(paneLastThemeModeRef.current.get(1)).toBe('light') - expect(pane.terminal.write).not.toHaveBeenCalledWith('\x1b[?2031h') + expect(transport.sendInput).not.toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith('\x1b[?2031h') } finally { vi.useRealTimers() } @@ -8583,6 +9307,53 @@ describe('connectPanePty', () => { } }) + it('repaints from the main-owned snapshot when main drops pending output at the cap', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { droppedOutput?: boolean }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + getMainBufferSnapshot.mockResolvedValue({ + data: 'healed from snapshot\r\n', + cols: 100, + rows: 30 + }) + + const pane = createPane(1) + const manager = createManager(1) + const binding = connectPanePty( + pane as never, + manager as never, + createDeps({ isVisibleRef: { current: true } }) as never + ) + try { + await flushAsyncTicks(6) + getMainBufferSnapshot.mockClear() + + // Main hit the per-PTY pending cap while the renderer was starved and + // sent the droppedOutput sentinel: the stream has a gap, so the pane + // must repaint from the authoritative main-owned buffer. + capturedDataCallback.current?.('', { droppedOutput: true }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalled() + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('healed from snapshot'), + expect.any(Function) + ) + } finally { + binding.dispose() + } + }) + it('keeps split stateful Codex queries live after becoming visible', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -8868,12 +9639,21 @@ describe('connectPanePty', () => { }) it('does not apply stale background Codex query chunks after hidden snapshot restore', async () => { - const visibilityChangeHandler: { current: (() => void) | null } = { current: null } + // Fire-all like a real event target: the pane resync handler and the + // stale-visibility trust handler both listen for visibilitychange. + const visibilityChangeListeners: (() => void)[] = [] + const visibilityChangeHandler = { + current: (): void => { + for (const listener of visibilityChangeListeners) { + listener() + } + } + } ;(globalThis as { document?: Document }).document = { visibilityState: 'visible', addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { if (type === 'visibilitychange') { - visibilityChangeHandler.current = listener as () => void + visibilityChangeListeners.push(listener as () => void) } }), removeEventListener: vi.fn() @@ -9169,17 +9949,21 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('writes ordinary hidden remote runtime output live instead of restoring a snapshot', async () => { + it('restores overflowed hidden remote runtime output from its serialized snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('remote:env-1@@terminal-1') const capturedDataCallback: { current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null } = { current: null } + // Why: with the skip grammar gone, the model restore for remote-runtime + // PTYs is latched by background-queue overflow, not per-chunk scanning. + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const live = 'visible remote output\r\n' transport.serializeBuffer = vi.fn().mockResolvedValue({ - data: 'remote snapshot\r\n', + data: 'remote snapshot with hidden remote output\r\n', cols: 120, rows: 40, - seq: 40, + seq: hidden.length + live.length, source: 'headless' }) transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { @@ -9197,26 +9981,27 @@ describe('connectPanePty', () => { const disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden remote output\r\n' - const live = 'visible remote output\r\n' capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden, expect.any(Function)) ;(deps.isVisibleRef as { current: boolean }).current = true capturedDataCallback.current?.(live, { - seq: 40 + live.length, + seq: hidden.length + live.length, rawLength: live.length }) await flushAsyncTicks(20) expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(transport.serializeBuffer).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(hidden) - expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + expect(transport.serializeBuffer).toHaveBeenCalledWith({ scrollbackRows: 5000 }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('remote snapshot with hidden remote output'), + expect.any(Function) + ) disposable.dispose() }) - it('keeps inactive split-pane hidden output live instead of deferring snapshot restore', async () => { + it('defers inactive split-pane plain hidden output restore until the pane returns', async () => { const { resetHiddenOutputRestoreSchedulerForTests } = await import('./hidden-output-restore-scheduler') let disposable: { dispose: () => void } | null = null @@ -9250,7 +10035,9 @@ describe('connectPanePty', () => { disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden inactive output\r\n' + // Why: overflowing the background queue is what latches the model + // restore now — the per-chunk skip grammar is gone. + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const live = 'visible inactive output\r\n' expect(capturedDataCallback.current).not.toBeNull() capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -9268,9 +10055,12 @@ describe('connectPanePty', () => { await new Promise((resolve) => setTimeout(resolve, 30)) await flushAsyncTicks(20) - expect(getMainBufferSnapshot).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(hidden) - expect(pane.terminal.write).toHaveBeenCalledWith(live, expect.any(Function)) + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.write).not.toHaveBeenCalledWith(hidden) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('inactive snapshot'), + expect.any(Function) + ) } finally { disposable?.dispose() resetHiddenOutputRestoreSchedulerForTests() @@ -9311,7 +10101,8 @@ describe('connectPanePty', () => { disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden inactive output\r\n' + // Why: overflow latches the model restore (no skip grammar remains). + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const live = 'visible inactive output\r\n' expect(capturedDataCallback.current).not.toBeNull() capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -9335,7 +10126,7 @@ describe('connectPanePty', () => { } }) - it('does not retry remote snapshots for ordinary hidden runtime output', async () => { + it('retries null remote snapshots for overflowed hidden runtime output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('remote:env-1@@terminal-1') const capturedDataCallback: { @@ -9360,7 +10151,8 @@ describe('connectPanePty', () => { const disposable = connectPanePty(pane as never, manager as never, deps as never) await flushAsyncTicks(6) - const hidden = 'hidden remote output\r\n' + // Why: overflow latches the model restore (no skip grammar remains). + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) const firstLive = 'first visible output\r\n' capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) @@ -9371,7 +10163,7 @@ describe('connectPanePty', () => { }) await flushAsyncTicks(20) - expect(transport.serializeBuffer).not.toHaveBeenCalled() + expect(transport.serializeBuffer).toHaveBeenCalledTimes(1) expect(pane.terminal.write).not.toHaveBeenCalledWith( expect.stringContaining('Orca skipped hidden terminal output'), expect.any(Function) @@ -9384,11 +10176,17 @@ describe('connectPanePty', () => { await new Promise((resolve) => setTimeout(resolve, 80)) await flushAsyncTicks(20) - expect(transport.serializeBuffer).not.toHaveBeenCalled() - expect(pane.terminal.write).toHaveBeenCalledWith(firstLive, expect.any(Function)) + expect(transport.serializeBuffer).toHaveBeenCalledTimes(2) + expect(pane.terminal.write).toHaveBeenCalledWith( + expect.stringContaining('remote recovered snapshot'), + expect.any(Function) + ) disposable.dispose() }) + // Why: pins the entire switch-off hidden fallback chain — hidden bytes ride + // the background queue, the 2MB lossy cap drops the backlog and latches the + // restore, and reveal repaints from the model snapshot. it('restores hidden backlog overflow from the main terminal snapshot on foreground output', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -9438,7 +10236,7 @@ describe('connectPanePty', () => { disposable.dispose() }) - it('clears only the alternate screen when restoring an alternate-screen snapshot', async () => { + it('rebuilds normal and alternate buffers from an authoritative alternate snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') const capturedDataCallback: { @@ -9459,7 +10257,8 @@ describe('connectPanePty', () => { cols: 100, rows: 30, seq: hidden.length + live.length, - alternateScreen: true + alternateScreen: true, + scrollbackAnsi: 'preserved-shell-history\r\n' }) const pane = createPane(1) @@ -9481,23 +10280,25 @@ describe('connectPanePty', () => { await flushAsyncTicks(20) expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) - // Why: the destructive clear wipes xterm's scrollback. Alt-screen TUIs keep - // their history in xterm, so restoring one must NOT emit the clear. - expect(pane.terminal.write).not.toHaveBeenCalledWith( - '\x1b[2J\x1b[3J\x1b[H', + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b[?1049l\x1b[2J\x1b[3J\x1b[H', expect.any(Function) ) - // Why: the snapshot's ?1049h no-ops on an already-alt pane and serialized - // frames skip blank cells, so the pre-hide frame bleeds through unless the - // alt screen is cleared (without \x1b[3J) before the snapshot paints. expect(pane.terminal.write).toHaveBeenCalledWith( - '\x1b[?1049h\x1b[2J\x1b[H', + 'preserved-shell-history\r\n', + expect.any(Function) + ) + expect(pane.terminal.write).toHaveBeenCalledWith( + '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H', expect.any(Function) ) const writes = (pane.terminal.write as ReturnType).mock.calls.map( (call) => call[0] ) - expect(writes.indexOf('\x1b[?1049h\x1b[2J\x1b[H')).toBeLessThan( + expect(writes.indexOf('preserved-shell-history\r\n')).toBeLessThan( + writes.indexOf('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') + ) + expect(writes.indexOf('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H')).toBeLessThan( writes.indexOf('altscreen-snapshot\r\n') ) expect(pane.terminal.write).toHaveBeenCalledWith('altscreen-snapshot\r\n', expect.any(Function)) @@ -10154,14 +10955,23 @@ describe('connectPanePty', () => { it('keeps recovery pending when hidden output arrives during an in-flight snapshot', async () => { let visibilityState: DocumentVisibilityState = 'visible' - const visibilityChangeHandler: { current: (() => void) | null } = { current: null } + // Fire-all like a real event target: the pane resync handler and the + // stale-visibility trust handler both listen for visibilitychange. + const visibilityChangeListeners: (() => void)[] = [] + const visibilityChangeHandler = { + current: (): void => { + for (const listener of visibilityChangeListeners) { + listener() + } + } + } ;(globalThis as { document?: Document }).document = { get visibilityState() { return visibilityState }, addEventListener: vi.fn((type: string, listener: EventListenerOrEventListenerObject) => { if (type === 'visibilitychange') { - visibilityChangeHandler.current = listener as () => void + visibilityChangeListeners.push(listener as () => void) } }), removeEventListener: vi.fn() @@ -10588,6 +11398,73 @@ describe('connectPanePty', () => { disposable.dispose() }) + it('replays rich headless snapshots as the future hidden TUI view source', async () => { + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport('pty-id') + const capturedDataCallback: { + current: ((data: string, meta?: { seq?: number; rawLength?: number }) => void) | null + } = { current: null } + transport.connect.mockImplementation(async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-id' + }) + transportFactoryQueue.push(transport) + const getMainBufferSnapshot = window.api.pty.getMainBufferSnapshot as unknown as ReturnType< + typeof vi.fn + > + const hidden = 'x'.repeat(2 * 1024 * 1024 + 1) + const richSnapshot = [ + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + '\x1b[2;36m╭────────────────────────────╮\x1b[0m\r\n', + '\x1b[2;36m│ Codex rich restore 🟢 ███░ │\x1b[0m\r\n', + '\x1b[2;36m│ status streaming │\x1b[0m\r\n', + '\x1b[2;36m╰────────────────────────────╯\x1b[0m', + '\x1b[6;4H\x1b[?25h' + ].join('') + const visibleTrigger = 'visible-trigger\r\n' + getMainBufferSnapshot.mockResolvedValue({ + data: richSnapshot, + cols: 96, + rows: 18, + seq: hidden.length + visibleTrigger.length, + source: 'headless' + }) + + const pane = createPane(1) + const refresh = vi.fn() + const terminal = pane.terminal as typeof pane.terminal & { + _core?: { refresh: typeof refresh } + } + terminal._core = { refresh } + terminal.write = vi.fn((_data: string, callback?: () => void) => { + callback?.() + }) + const manager = createManager(1) + const deps = createDeps({ + isVisibleRef: { current: false } + }) + const disposable = connectPanePty(pane as never, manager as never, deps as never) + await flushAsyncTicks(6) + + capturedDataCallback.current?.(hidden, { seq: hidden.length, rawLength: hidden.length }) + ;(deps.isVisibleRef as { current: boolean }).current = true + capturedDataCallback.current?.(visibleTrigger, { + seq: hidden.length + visibleTrigger.length, + rawLength: visibleTrigger.length + }) + await flushAsyncTicks(20) + + expect(getMainBufferSnapshot).toHaveBeenCalledWith('pty-id', { scrollbackRows: 5000 }) + expect(pane.terminal.resize).toHaveBeenCalledWith(96, 18) + expect(pane.terminal.write).toHaveBeenCalledWith(richSnapshot, expect.any(Function)) + expect(pane.terminal.write).not.toHaveBeenCalledWith(visibleTrigger, expect.any(Function)) + expect(refresh).toHaveBeenCalledWith(0, 39, true) + expect(deps.replayingPanesRef.current.size).toBe(0) + disposable.dispose() + }) + it('refreshes visible rows after replaying a hidden TUI snapshot', async () => { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport('pty-id') @@ -12439,6 +13316,452 @@ describe('connectPanePty', () => { expect(deps.markTerminalPaneUnread).not.toHaveBeenCalled() }) + // ─── Main side-effect authority (terminal-side-effect-authority.md) ──── + // + // With the kill switch on (the default), local/SSH transports must not + // register title/bell/agent byte parsers; the pane's policy callbacks are + // registered as the PTY's single pty:sideEffect fact consumer instead. + describe('with main side-effect authority on', () => { + const SIDE_EFFECT_PARSER_CALLBACKS = [ + 'onTitleChange', + 'onBell', + 'onAgentBecameIdle', + 'onAgentBecameWorking', + 'onAgentExited' + ] as const + + function enableMainAuthority(): void { + mockStoreState.settings = { + ...mockStoreState.settings, + terminalMainSideEffectAuthority: true + } + } + + it('omits byte-parser callbacks from the local transport options', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + + expect(createdTransportOptions[0]).toBeDefined() + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeUndefined() + } + // The lifecycle callbacks stay on the transport — only side-effect + // parsing moves to the fact consumer. + expect(createdTransportOptions[0]?.onPtySpawn).toBeTypeOf('function') + expect(createdTransportOptions[0]?.onPtyExit).toBeTypeOf('function') + }) + + it('keeps byte-parser callbacks on remote-runtime transports', async () => { + enableMainAuthority() + enableActiveRuntimeEnvironment() + const { connectPanePty } = await import('./pty-connection') + const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + + expect(createRemoteRuntimePtyTransport).toHaveBeenCalledWith('env-1', expect.any(Object)) + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeTypeOf('function') + } + }) + + it('consumes pty:sideEffect facts with the live-path policy after spawn', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const pane = createPane(1) + const manager = createManager(1) + const deps = createDeps() + connectPanePty(pane as never, manager as never, deps as never) + + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-1') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-1', + seq: 10, + facts: [ + { kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }, + { kind: 'bell' } + ] + }) + + expect(deps.setRuntimePaneTitle).toHaveBeenCalledWith('tab-1', 1, 'Codex working') + expect(deps.markWorktreeUnread).toHaveBeenCalledTimes(1) + expect(deps.markTerminalTabUnread).toHaveBeenCalledWith('tab-1') + expect(deps.dispatchNotification).not.toHaveBeenCalled() + vi.advanceTimersByTime(250) + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ + source: 'terminal-bell', + paneKey: makePaneKey('tab-1', LEAF_1) + }) + ) + }) + + it('stops consuming facts after the pane binding is disposed', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const deps = createDeps() + const binding = connectPanePty( + createPane(1) as never, + createManager(1) as never, + deps as never + ) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-2') + + binding.dispose() + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-2', + seq: 1, + facts: [{ kind: 'bell' }] + }) + + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + expect(deps.markTerminalTabUnread).not.toHaveBeenCalled() + }) + + it('schedules the completion notification for genuine working→idle facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-genuine') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-genuine', + seq: 1, + facts: [ + { kind: 'title', normalizedTitle: '⠋ Codex working', rawTitle: '⠋ Codex working' }, + { kind: 'agent-working' } + ] + }) + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-genuine', + seq: 2, + facts: [ + { kind: 'title', normalizedTitle: '* Codex done', rawTitle: '* Codex done' }, + { kind: 'agent-idle', title: '* Codex done' } + ] + }) + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS) + + expect(deps.dispatchNotification).toHaveBeenCalledWith( + expect.objectContaining({ source: 'agent-task-complete' }) + ) + }) + + it('clears state without completion attention for stale-derived facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-stale') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-stale', + seq: 1, + facts: [ + { kind: 'title', normalizedTitle: '⠋ Codex working', rawTitle: '⠋ Codex working' }, + { kind: 'agent-working' } + ] + }) + // Main's unthrottled 3s stale-title rewrite for a merely-paused agent. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-stale', + seq: 2, + facts: [ + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ] + }) + + // The cleared title still lands; the cache timer is cleared. + expect(deps.setRuntimePaneTitle).toHaveBeenLastCalledWith('tab-1', 1, 'Codex') + expect(deps.setCacheTimerStartedAt).toHaveBeenLastCalledWith( + makePaneKey('tab-1', LEAF_1), + null + ) + // But no task-complete notification or unread attention is scheduled. + vi.advanceTimersByTime(AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS * 2) + expect(deps.dispatchNotification).not.toHaveBeenCalled() + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + expect(deps.markTerminalPaneUnread).not.toHaveBeenCalled() + }) + + it('drops the agent status from a command-finished fact like the byte path did', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'hi', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'codex', + stateHistory: [] + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-133') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-133', + seq: 1, + facts: [{ kind: 'command-finished', exitCode: 130 }] + }) + + expect(mockStoreState.dropAgentStatus).toHaveBeenCalledWith(paneKey) + expect(mockStoreState.removeAgentStatus).not.toHaveBeenCalled() + }) + + it('routes pr-link facts to the worktree PR observer', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-pr') + + const link = { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-pr', + seq: 1, + facts: [{ kind: 'pr-link', link }] + }) + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith('wt-1', link) + }) + + it('does not byte-scan PR links or OSC 133 — facts are the only consumer', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-authority-bytes' + } + ) + transportFactoryQueue.push(transport) + const paneKey = makePaneKey('tab-1', LEAF_1) + mockStoreState.agentStatusByPaneKey = { + [paneKey]: { + paneKey, + state: 'done', + prompt: 'hi', + updatedAt: 1000, + stateStartedAt: 1000, + agentType: 'codex', + stateHistory: [] + } + } + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('Created https://github.com/acme/orca/pull/42\r\n') + capturedDataCallback.current?.('\x1b]133;D;130\x07prompt $ ') + + expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled() + expect(mockStoreState.dropAgentStatus).not.toHaveBeenCalled() + }) + + it('seeds and settles Command Code status from command-code facts', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-cc') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc', + seq: 1, + facts: [{ kind: 'command-code-working', prompt: 'say hi' }] + }) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'say hi', + agentType: 'command-code' + }) + + // Why: the done fact is a hint — the settle timer stays in the pane + // policy because it must consult the live status row before completing. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc', + seq: 2, + facts: [{ kind: 'command-code-done', prompt: 'say hi' }] + }) + vi.advanceTimersByTime(1499) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ state: 'working' }) + vi.advanceTimersByTime(1) + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'done', + prompt: 'say hi', + agentType: 'command-code' + }) + }) + + it('keeps Command Code working when a working fact lands before the done settles', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + vi.useFakeTimers() + const paneKey = makePaneKey('tab-1', LEAF_1) + + connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-fact-cc-repaint') + + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 1, + facts: [{ kind: 'command-code-working', prompt: 'Run a slow command' }] + }) + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 2, + facts: [{ kind: 'command-code-done', prompt: 'Run a slow command' }] + }) + vi.advanceTimersByTime(1000) + // An active repaint within the settle window cancels the pending done. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-fact-cc-repaint', + seq: 3, + facts: [{ kind: 'command-code-working', prompt: 'Run a slow command' }] + }) + vi.advanceTimersByTime(2000) + + expect(mockStoreState.agentStatusByPaneKey[paneKey]).toMatchObject({ + state: 'working', + prompt: 'Run a slow command', + agentType: 'command-code' + }) + }) + + it('does not byte-scan Command Code output — facts are the only consumer', async () => { + enableMainAuthority() + const { connectPanePty } = await import('./pty-connection') + const transport = createMockTransport() + const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null } + transport.connect.mockImplementation( + async ({ callbacks }: { callbacks: ConnectCallbacks }) => { + capturedDataCallback.current = callbacks.onData ?? null + return 'pty-authority-cc-bytes' + } + ) + transportFactoryQueue.push(transport) + + connectPanePty( + createPane(1) as never, + createManager(1) as never, + createDeps({ startup: { command: 'command-code --trust' } }) as never + ) + await flushAsyncTicks() + expect(capturedDataCallback.current).not.toBeNull() + + capturedDataCallback.current?.('# Command Code v0.27.2\r\n') + capturedDataCallback.current?.('❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m') + + expect(mockStoreState.setAgentStatus).not.toHaveBeenCalled() + }) + + it('honors the persisted kill switch for panes bound before settings hydrate', async () => { + // Pre-hydration: the store has no settings yet, but the user persisted + // the kill switch off. The pane must register byte parsers, not a fact + // consumer — and hydration must not produce a second consumer. + mockStoreState.settings = null + ;(window.api as unknown as Record).settings = { + getSync: vi.fn(() => ({ terminalMainSideEffectAuthority: false })) + } + const { connectPanePty } = await import('./pty-connection') + const handler = await import('./terminal-side-effect-facts-handler') + const transport = createMockTransport() + transportFactoryQueue.push(transport) + + const deps = createDeps() + connectPanePty(createPane(1) as never, createManager(1) as never, deps as never) + + for (const callback of SIDE_EFFECT_PARSER_CALLBACKS) { + expect(createdTransportOptions[0]?.[callback]).toBeTypeOf('function') + } + const onPtySpawn = createdTransportOptions[0]?.onPtySpawn as (ptyId: string) => void + onPtySpawn('pty-prehydration') + + // No fact consumer registered: channel batches are dropped. + handler._dispatchTerminalSideEffectBatchForTest({ + ptyId: 'pty-prehydration', + seq: 1, + facts: [{ kind: 'bell' }] + }) + expect(deps.markWorktreeUnread).not.toHaveBeenCalled() + + // Hydration lands with the switch still off: byte parsing stays the + // single consumer — one BEL marks unread exactly once. + mockStoreState.settings = { terminalMainSideEffectAuthority: false } + notifyStoreSubscribers() + const onBell = createdTransportOptions[0]?.onBell as () => void + onBell() + expect(deps.markWorktreeUnread).toHaveBeenCalledTimes(1) + }) + }) + it('lets concurrent agent-complete notifications win over terminal bell notifications', async () => { const { connectPanePty } = await import('./pty-connection') const { useNotificationDispatch } = await vi.importActual( diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 94fd3774fa4..ae7dbfbcf1c 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -13,6 +13,17 @@ import { TerminalKittyKeyboardModeTracker } from '../../../../shared/terminal-ki import { isRuntimeOwnedSshTargetId } from '../../../../shared/execution-host' import { createTerminalZeroDimensionsMessage } from '../../../../shared/terminal-zero-dimensions-diagnostic' import { parseTerminalOscColorQuery } from '../../../../shared/terminal-osc-color-reply' +import { + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS, + containsCsiRendererQuery, + containsStatefulRendererQuery, + extractHiddenStartupRendererQueryData, + findCsiFinalByteIndex, + isStatefulRendererReplyCsiQuery, + isStatelessRendererReplyCsiQuery +} from '../../../../shared/terminal-reply-query-extraction' +import { takeCurrentPtyDeliveryAckCredit } from './terminal-pty-ack-gate' +import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' import { isTerminalQueryReply } from '../../../../shared/terminal-query-reply' import type { PtyBufferSnapshot, PtyConnectResult } from './pty-transport' import { createIpcPtyTransport } from './pty-transport' @@ -37,6 +48,12 @@ import { isPtyLocked } from '@/lib/pane-manager/mobile-driver-state' import { reconcilePtySizeAcrossFrames, type PtySizeReconcileHandle } from './pty-size-reconcile' import { createPtySizeReassertion } from './pty-size-reassertion' import { isPaneReplaying, replayIntoTerminal, replayIntoTerminalAsync } from './replay-guard' +import { + isDocumentVisibilityProvenStale, + registerStaleDocumentVisibilityRecovery +} from './stale-document-visibility' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' import { nativeWindowsRewriteNeedsFollowupRenderRefresh, terminalOutputPrefersRenderRefresh, @@ -81,7 +98,10 @@ import { writeTerminalOutput } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { recordAgentHibernationPaneOutput } from '@/lib/agent-hibernation-output-activity' -import { isLocalNativeWindowsConpty } from '@/lib/pane-manager/windows-pty-compatibility' +import { + isLocalNativeWindowsConpty, + resolveWindowsShellOverride +} from '@/lib/pane-manager/windows-pty-compatibility' import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' import { captureTerminalWriteScrollIntent, @@ -133,21 +153,30 @@ import { import { getTerminalPasteSshRemotePlatform } from './terminal-paste-ssh-platform' import { resolveTerminalPasteRuntime } from './terminal-paste-runtime' import { isKnownTuiAgentTerminalStartupCommand } from './terminal-startup-command-classifier' -import { createCommandCodeOutputStatusDetector } from './command-code-output-status' +import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status' import type { PtyDataMeta } from './pty-dispatcher' import { getEagerPtyBufferHandle } from './pty-dispatcher' -import { createTerminalGitHubPRLinkDetector } from '@/lib/terminal-github-pr-link-detector' +import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' import { scheduleTerminalWebglAtlasRecovery } from './terminal-webgl-atlas-recovery' import { CONPTY_DA1_RESPONSE, + DEFAULT_DA1_RESPONSE, createTerminalPixelSizeQueryResponder, installTerminalCapabilityReplyHandlers, sendTerminalOscColorQueryReplies } from './terminal-capability-replies' +import { registerPtyModelRestoreNeededHandler } from './pty-model-restore-channel' +import { + acquireHiddenRendererPtyDeliveryClaim, + declareRendererPtyDeliveryVisible, + releaseRendererPtyVisibilityClaim, + setRendererPtyVisibilityClaim +} from './pty-renderer-delivery-claims' import { cancelScheduledHiddenOutputRestore, scheduleHiddenOutputRestore } from './hidden-output-restore-scheduler' +import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-scrollback' import { getExecutionHostIdForWorktree, getSettingsForWorktreeRuntimeOwner, @@ -185,14 +214,23 @@ import { beginAgentStartupDeliveryAttempt, releaseAgentStartupDeliveryAttempt } from '@/lib/agent-startup-delayed-delivery' +import { + AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS, + AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS, + canDispatchAgentNotificationAfterGrace, + isAgentTaskCompleteOsNotificationEnabledFromState, + isAgentTaskCompleteTrackingEnabledFromState +} from './agent-task-complete-policy' +import { + isMainTerminalSideEffectAuthorityForPty, + registerTerminalSideEffectFactConsumer +} from './terminal-side-effect-facts-handler' +import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate' const pendingSpawnByPaneKey = new Map>() const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' const REMOTE_PTY_ID_PREFIX = 'remote:' const PTY_CONNECT_DIAG_LIMIT = 200 -const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250 -const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500 -const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000 const COMMAND_CODE_OUTPUT_DONE_SETTLE_MS = 1500 const SSH_SHELL_READY_STARTUP_FALLBACK_MS = 1500 const MANUAL_AGENT_COMMAND_MAX_CHARS = 4096 @@ -202,11 +240,22 @@ const STARTUP_DRAFT_PASTE_QUIET_MS = 1500 export const STARTUP_CWD_FALLBACK_NOTICE = '\r\n[Orca opened this terminal at the workspace root because its saved start folder no longer exists.]\r\n' const STARTUP_DRAFT_PASTE_TIMEOUT_MS = 8000 -const HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS = 5000 const HIDDEN_OUTPUT_RESTORE_PENDING_CHARS = 512 * 1024 const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MS = 50 const HIDDEN_OUTPUT_RESTORE_DEFERRED_RETRY_MAX = 3 const HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS = 750 +// Why (rc.7.perf DSR-timeout feedback loop): under a foreground flood the +// restore pipeline is its own bottleneck — each synchronous snapshot replay +// starves ACK processing, main pins at the in-flight cap, drops at the +// pending cap, and every drop marker re-armed another restore until the +// flood ended. Backpressure evidence opens this suppression window: drop +// markers inside it must not re-arm restores; live bytes write through and +// ONE deferred repaint (when the window closes) heals the visual gap. +const HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS = 2000 +// Backstop for the same loop: a single in-flight restore task may re-iterate +// (fresh-snapshot marks, unmappable slices) only this many times before it +// abandons and lets live bytes flow. +const HIDDEN_OUTPUT_RESTORE_MAX_LOOP_ITERATIONS = 3 const TERMINAL_RENDERER_RISK_SCAN_TAIL_CHARS = 256 const SYNCHRONIZED_OUTPUT_START_SEQUENCE = '\x1b[?2026h' const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' @@ -357,6 +406,9 @@ type E2eTerminalHiddenSnapshotOverride = { const e2eTerminalHiddenSnapshotOverrides = new Map() +// Why: the per-chunk hidden-skip grammar is deleted (Phase 6) — hidden bytes +// either never reach the renderer (delivery gate) or ride the background +// scheduler queue. Only the mode-2031 fact-reply counter still has a producer. type E2eTerminalPtyOutputDebugSnapshot = { hiddenRendererSkipCount: number hiddenRendererSkippedChars: number @@ -516,157 +568,6 @@ function containsHiddenStartupRendererQuery(data: string): boolean { return containsCsiRendererQuery(data) || data.includes('\x1b]10;?') || data.includes('\x1b]11;?') } -const HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS = 64 - -function extractHiddenStartupRendererQueryData( - data: string, - pending: string -): { - statelessQueryData: string - statefulQueryData: string - oscColorQueryData: string - pending: string -} { - const input = pending + data - let statelessQueryData = '' - let statefulQueryData = '' - let oscColorQueryData = '' - let offset = 0 - - while (offset < input.length) { - const candidateIndex = input.indexOf('\x1b', offset) - if (candidateIndex === -1) { - break - } - if (candidateIndex + 1 >= input.length) { - return { - statelessQueryData, - statefulQueryData, - oscColorQueryData, - pending: input.slice(candidateIndex) - } - } - if (input.startsWith('\x1b[', candidateIndex)) { - const finalByteIndex = findCsiFinalByteIndex(input, candidateIndex + 2) - if (finalByteIndex === -1) { - return { - statelessQueryData, - statefulQueryData, - oscColorQueryData, - pending: input.slice( - candidateIndex, - candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS - ) - } - } - const sequence = input.slice(candidateIndex, finalByteIndex + 1) - if (isStatelessRendererReplyCsiQuery(sequence)) { - statelessQueryData += sequence - } else if (isStatefulRendererReplyCsiQuery(sequence)) { - statefulQueryData += sequence - } - offset = finalByteIndex + 1 - continue - } - - if (input.startsWith('\x1b]', candidateIndex)) { - const query = parseTerminalOscColorQuery(input, candidateIndex) - if (query.kind === 'partial') { - return { - statelessQueryData, - statefulQueryData, - oscColorQueryData, - pending: input.slice( - candidateIndex, - candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS - ) - } - } - if (query.kind === 'none') { - offset = candidateIndex + 2 - continue - } - oscColorQueryData += input.slice(candidateIndex, query.endIndex) - offset = query.endIndex - continue - } - - if (parseTerminalOscColorQuery(input, candidateIndex).kind === 'partial') { - return { - statelessQueryData, - statefulQueryData, - oscColorQueryData, - pending: input.slice(candidateIndex) - } - } - - { - offset = candidateIndex + 1 - continue - } - } - - return { statelessQueryData, statefulQueryData, oscColorQueryData, pending: '' } -} - -function containsCsiRendererQuery(data: string): boolean { - let offset = data.indexOf('\x1b[') - while (offset !== -1) { - const finalByteIndex = findCsiFinalByteIndex(data, offset + 2) - if (finalByteIndex === -1) { - return false - } - const sequence = data.slice(offset, finalByteIndex + 1) - if (isStatelessRendererReplyCsiQuery(sequence) || isStatefulRendererReplyCsiQuery(sequence)) { - return true - } - offset = data.indexOf('\x1b[', finalByteIndex + 1) - } - return false -} - -function containsStatefulRendererQuery(data: string): boolean { - let offset = data.indexOf('\x1b[') - while (offset !== -1) { - const finalByteIndex = findCsiFinalByteIndex(data, offset + 2) - if (finalByteIndex === -1) { - return false - } - const sequence = data.slice(offset, finalByteIndex + 1) - if (isStatefulRendererReplyCsiQuery(sequence)) { - return true - } - offset = data.indexOf('\x1b[', finalByteIndex + 1) - } - return false -} - -function findCsiFinalByteIndex(data: string, offset: number): number { - for (let index = offset; index < data.length; index++) { - const code = data.charCodeAt(index) - if (code >= 0x40 && code <= 0x7e) { - return index - } - } - return -1 -} - -function isStatelessRendererReplyCsiQuery(sequence: string): boolean { - if (sequence.endsWith('c')) { - return true - } - return ( - sequence === '\x1b[5n' || - sequence === '\x1b[>q' || - sequence === '\x1b[14t' || - sequence === '\x1b[16t' - ) -} - -function isStatefulRendererReplyCsiQuery(sequence: string): boolean { - return sequence === '\x1b[6n' || (sequence.startsWith('\x1b[?') && sequence.endsWith('$p')) -} - let codexRestartNoticePresenceSource: Record< string, { previousAccountLabel: string; nextAccountLabel: string } @@ -691,40 +592,19 @@ type PanePtyBinding = IDisposable & { sampleForegroundAgentOnFocus: () => void reconcileIfSessionDead: (liveSessionIds: Set, snapshotRequestedAt?: number) => void reconcileIfSessionMissing: (hasPty: HasPty, livenessRequestedAt?: number) => void + /** True when the hidden-delivery gate structurally manages the pane's + * current PTY. The lifecycle's xterm CSI ?2031h observer consults this to + * stay silent — main's '2031-subscribe' fact is the sole responder for + * gate-managed PTYs. */ + isHiddenDeliveryGateManagedPty: () => boolean } function isAgentTaskCompleteNotificationEnabled(): boolean { - return isAgentTaskCompleteNotificationEnabledFromState(useAppStore.getState()) -} - -function isAgentTaskCompleteNotificationEnabledFromState( - state: ReturnType -): boolean { - const notifications = state.settings?.notifications - return notifications?.enabled !== false && notifications?.agentTaskComplete !== false -} - -function isTerminalAttentionEnabledFromState( - state: ReturnType -): boolean { - return state.settings?.experimentalTerminalAttention === true + return isAgentTaskCompleteOsNotificationEnabledFromState(useAppStore.getState()) } function isAgentTaskCompleteTrackingEnabled(): boolean { - const state = useAppStore.getState() - return ( - isAgentTaskCompleteNotificationEnabledFromState(state) || - isTerminalAttentionEnabledFromState(state) - ) -} - -function isAgentTaskCompleteTrackingEnabledFromState( - state: ReturnType -): boolean { - return ( - isAgentTaskCompleteNotificationEnabledFromState(state) || - isTerminalAttentionEnabledFromState(state) - ) + return isAgentTaskCompleteTrackingEnabledFromState(useAppStore.getState()) } const agentTaskCompleteTrackingEnabledListeners = new Set<() => void>() @@ -734,7 +614,7 @@ let agentTaskCompleteTrackingSettingsSnapshot: string | null = null function getAgentTaskCompleteTrackingSettingsSnapshot( state: ReturnType ): string { - return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteNotificationEnabledFromState(state)}` + return `${isAgentTaskCompleteTrackingEnabledFromState(state)}:${isAgentTaskCompleteOsNotificationEnabledFromState(state)}` } function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () => void { @@ -768,27 +648,6 @@ function subscribeAgentTaskCompleteTrackingEnabled(listener: () => void): () => } } -function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean { - return Boolean( - entry && - Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS && - (entry.lastAssistantMessage || entry.toolName || entry.toolInput) - ) -} - -function canDispatchAgentNotificationAfterGrace( - entry: AgentStatusEntry | undefined, - options: { allowDoneDetailAfterGrace?: boolean } = {} -): boolean { - // Why: hook-backed goal/mission loops can report `done` between milestones. - // User-input states may notify as soon as detail arrives, but `done` waits - // for the max quiet window so resumed work can cancel the pending banner. - return ( - hasAgentNotificationDetail(entry) && - (entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true) - ) -} - function recordPtyConnectDiagnostic(message: string): void { if (!e2eConfig.exposeStore) { return @@ -936,7 +795,13 @@ function shouldWritePtyOutputForeground(isPaneVisible: boolean): boolean { // Why: Electron can keep visible panes mounted while the whole app is // backgrounded. Treat hidden documents like background tabs so Chromium // timer throttling cannot pin terminal writes on the renderer foreground path. - return document.visibilityState === 'visible' + if (document.visibilityState === 'visible') { + return true + } + // Why: macOS occlusion tracking can wedge visibilityState at 'hidden' after + // display sleep; proven-stale means real user input contradicted it, so the + // hidden-delivery gate must not keep dropping a watched pane's bytes. + return isDocumentVisibilityProvenStale() } function containsSynchronizedOutputStart(data: string): boolean { @@ -1074,6 +939,7 @@ export function connectPanePty( let unregisterDocumentVisibilityRecovery: (() => void) | null = null let cleanupHiddenOutputRestoreDeferredRetry = (): void => {} let cleanupHiddenOutputRestoreForegroundDeadline = (): void => {} + let cleanupHiddenOutputRestoreFloodRepaint = (): void => {} let resetRendererOrderedSeqForPtyExit: (exitedPtyId: string) => void = () => {} let cleanupStartupDraftPasteTimers = (): void => {} let unregisterE2ePtyDataInjection = (): void => {} @@ -1096,6 +962,11 @@ export function connectPanePty( // window still drains on the fast path instead of the 1s coalesce fallback. let synchronizedForegroundFrameInteractive = false let suppressSnapshotReplayPtyResize = false + // Why: hidden-delivery gate sync is wired up alongside the deferred PTY + // output plumbing inside the connect frame; lifecycle hooks (visibility + // flips, exit, dispose) run before/after it exists, so start with no-ops. + let syncHiddenRendererPtyDelivery: () => void = () => {} + let releaseHiddenRendererPtyDelivery: () => void = () => {} // Why: idle callbacks are registered before the deferred PTY output plumbing // exists. Start with the shared scheduler, then switch to the PTY writer // below so hidden-tab resets keep backlog-recovery callbacks and byte order. @@ -1863,6 +1734,40 @@ export function connectPanePty( hasKnownAgentIdentity: paneHasKnownAgentIdentity, onConfirmedShellForeground: clearStaleAgentTabTitleOnConfirmedShell }) + // Why: one command-finished policy whether the signal arrives as bytes + // (remote PTYs, kill switch off) or as a main-derived pty:sideEffect fact — + // routing both through this handler keeps the drop/interrupt semantics + // identical across authority modes. + const handleCommandFinished = (_bestEffortExitCode: number | null): void => { + clearCommandInferredPaneAgentAfterPtySideEffects() + paneForegroundAgentTracker.onCommandFinished() + // Why: the finished command may have moved HEAD or the index (e.g. + // `git checkout`); nudge git UI now instead of waiting for a poll. + dispatchTerminalCommandFinishedEvent(deps.worktreeId) + const state = useAppStore.getState() + const entry = state.agentStatusByPaneKey[cacheKey] + const inferenceResult = flushPendingInterruptInference() + if (inferenceResult === true) { + // Why: OSC 133 D means the foreground shell command exited. If an + // interrupt was inferred first, drop only when the current interrupted + // row is still the same turn; otherwise a killed OpenCode CLI leaves a + // stale "interrupted" row even though the process is gone. + dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true }) + return + } + if (inferenceResult instanceof Promise) { + void inferenceResult.then((applied) => { + dropCommandFinishedStatusIfSameTurn(entry, { + allowInferredInterrupt: applied === true + }) + }) + return + } + // Why: OSC 133 D marks the foreground shell command exiting. Remove the + // row without retaining a done snapshot; this section represents a live + // agent process, and the shell prompt means that process is gone. + dropCommandFinishedStatusIfSameTurn(entry) + } const sampleVisiblePaneForegroundAgent = (): void => { if (!deps.isVisibleRef.current) { return @@ -1895,37 +1800,10 @@ export function connectPanePty( } const commandLifecycle = createTerminalCommandLifecycle({ onCommandStarted: () => paneForegroundAgentTracker.onCommandStarted(), - onCommandFinished: () => { - clearCommandInferredPaneAgentAfterPtySideEffects() - paneForegroundAgentTracker.onCommandFinished() - // Why: the finished command may have moved HEAD or the index (e.g. - // `git checkout`); nudge git UI now instead of waiting for a poll. - dispatchTerminalCommandFinishedEvent(deps.worktreeId) - const state = useAppStore.getState() - const entry = state.agentStatusByPaneKey[cacheKey] - const inferenceResult = flushPendingInterruptInference() - if (inferenceResult === true) { - // Why: OSC 133 D means the foreground shell command exited. If an - // interrupt was inferred first, drop only when the current interrupted - // row is still the same turn; otherwise a killed OpenCode CLI leaves a - // stale "interrupted" row even though the process is gone. - dropCommandFinishedStatusIfSameTurn(entry, { allowInferredInterrupt: true }) - return - } - if (inferenceResult instanceof Promise) { - void inferenceResult.then((applied) => { - dropCommandFinishedStatusIfSameTurn(entry, { - allowInferredInterrupt: applied === true - }) - }) - return - } - // Why: OSC 133 D marks the foreground shell command exiting. Remove the - // row without retaining a done snapshot; this section represents a live - // agent process, and the shell prompt means that process is gone. - dropCommandFinishedStatusIfSameTurn(entry) - } + onCommandFinished: handleCommandFinished }) + // Why: the xterm OSC 133 swallow is rendering hygiene, not a side effect — + // it stays attached in every authority mode. commandLifecycle.attachXtermConsumer(pane.terminal) const onTerminalKeyDown = (event: KeyboardEvent): void => { if (isPlainEscapeKeyEvent(event)) { @@ -2001,6 +1879,47 @@ export function connectPanePty( // Why: bind time lets async liveness reconcile ignore a request started // before this PTY bound (newborn race). Null disables the guard (fail-safe). let activePanePtyBindingBoundAt: number | null = null + + // Why: with main side-effect authority on, the pane's title/bell/agent + // policy callbacks consume pty:sideEffect facts instead of transport byte + // parsers (which stay unregistered) — same policy code, single consumer. + // restoreTitleOnRegister replaces the eager-replay title restore: main's + // title-only snapshot carries the no-attention-replay rule. + let unregisterSideEffectFactConsumer: (() => void) | null = null + const registerSideEffectFactConsumerForPty = (ptyId: string): void => { + if (!mainSideEffectAuthority || disposed) { + return + } + unregisterSideEffectFactConsumer?.() + unregisterSideEffectFactConsumer = registerTerminalSideEffectFactConsumer({ + ptyId, + callbacks: { + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onCommandFinished: handleCommandFinished, + onPrLink: (link) => + useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link), + // Why: the Command Code settle policy stays here — the done settle + // timer must consult the live store row (which hook events and + // renderer seeds also write), so main only emits scrape facts. + onCommandCodeWorking: seedCommandCodeOutputWorkingStatus, + onCommandCodeDone: scheduleCommandCodeOutputDoneStatus, + // Why: gated hidden panes never see the subscribe bytes; the fact + // replaces the byte scan (and the old post-latch subscribe drop). + ...(hiddenDeliveryGateActive + ? { onMode2031Subscribe: handleHiddenMode2031SubscribeFact } + : {}) + }, + restoreTitleOnRegister: true + }) + } + const dropSideEffectFactConsumer = (): void => { + unregisterSideEffectFactConsumer?.() + unregisterSideEffectFactConsumer = null + } const clearPanePtyFitBinding = (): void => { // Why: fit bindings live in a module-level map, so pane teardown must // clear them explicitly instead of relying on DOM removal. @@ -2191,6 +2110,10 @@ export function connectPanePty( } handledExitPtyId = ptyId agentCompletionCoordinator.dispose() + dropSideEffectFactConsumer() + // Why: main clears gate state on PTY exit too; this only resets the + // pane-local marker so a reused pane cannot skip re-marking a new PTY. + releaseHiddenRendererPtyDelivery() clearPanePtyFitBinding() // Why: the negotiating application died with its PTY; any replacement // session starts with kitty keyboard flags at zero. @@ -2273,6 +2196,7 @@ export function connectPanePty( return } if ( + deps.isVisibleRef.current && hadExistingPaneTransportAtConnect && !restoredPtyIdForTransport && !Number.isFinite(lastTerminalInputAt) && @@ -2280,6 +2204,10 @@ export function connectPanePty( ) { // Why: a freshly split pane can lose its newborn PTY during setup; keep // the split visible so the failed session does not immediately collapse. + // Hidden panes must close instead: the hidden-delivery gate withholds + // their bytes, so "no output" is meaningless there, and keeping one + // strands a binding-less pane the exit path never revisits — it remounts + // as a permanently blank ghost on reveal. focusSurvivingPtyPaneAfterKeptExit() return } @@ -2294,7 +2222,11 @@ export function connectPanePty( let hasConsideredInitialCacheTimerSeed = false let allowInitialIdleCacheSeed = false - const onTitleChange = (title: string, rawTitle: string): void => { + const onTitleChange = ( + title: string, + rawTitle: string, + meta?: { staleWorkingTitleClear?: boolean } + ): void => { // Why: one owner-aware decision drives the display label, the runtime/tab // title, task-completion tracking, and the renderer gate, so raw title text // can no longer disable GPU behind stronger owner evidence (#7428/#7447). @@ -2317,7 +2249,11 @@ export function connectPanePty( } manager.setPaneGpuRendering(pane.id, decision.rendererPolicy.gpuEnabled) deps.setRuntimePaneTitle(deps.tabId, pane.id, paneTitle) - if (syncAgentTaskCompleteTrackingEnabled()) { + // Why: a stale-derived cleared title comes from main's unthrottled 3s + // timer, not agent output. It must update the visible title but never + // feed completion tracking — observeTitle would classify the cleared + // title as idle and mint a task-complete for a merely-paused agent. + if (!meta?.staleWorkingTitleClear && syncAgentTaskCompleteTrackingEnabled()) { agentCompletionCoordinator.observeTitle(decision.rawTitle) } // Why: only the focused pane should drive the tab title — otherwise two @@ -2436,11 +2372,6 @@ export function connectPanePty( }, COMMAND_CODE_OUTPUT_DONE_SETTLE_MS) } - const commandCodeOutputStatusDetector = createCommandCodeOutputStatusDetector({ - startupCommand: paneStartup?.command, - onWorking: seedCommandCodeOutputWorkingStatus, - onDone: scheduleCommandCodeOutputDoneStatus - }) const observeTerminalGitHubPRLink = createTerminalGitHubPRLinkDetector() const reportPanePtyVisibility = (ptyId: string | null | undefined, visible: boolean): void => { if (!ptyId || isRemoteRuntimePtyId(ptyId)) { @@ -2448,7 +2379,7 @@ export function connectPanePty( // renderer-visibility registry, so reporting them here is misleading. return } - window.api.pty.setRendererPtyVisible?.(ptyId, visible) + setRendererPtyVisibilityClaim(transport, ptyId, visible) } const bindActivePanePty = ( ptyId: string, @@ -2467,6 +2398,8 @@ export function connectPanePty( // Why: record bind time on the spawn/attach chokepoint so the reconcile // guard knows this binding is newer than any pre-bind snapshot. activePanePtyBindingBoundAt = performance.now() + registerSideEffectFactConsumerForPty(ptyId) + syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, ptyId) const tabPtyIds = useAppStore.getState().ptyIdsByTabId?.[deps.tabId] ?? [] if (options.updateTabPtyId !== 'if-missing' || !tabPtyIds.includes(ptyId)) { @@ -2686,7 +2619,16 @@ export function connectPanePty( // findable after the OS banner is gone. Double-firing with a concurrent BEL // is handled by delaying the BEL OS notification below; main still keeps a // 5 s per-worktree dedupe as the final guard. - const onAgentBecameIdle = (title: string): void => { + const onAgentBecameIdle = (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => { + // Why: a stale-derived idle comes from main's UNTHROTTLED 3s timer, not + // observed bytes — a merely-paused agent (>3s silent mid-task, window + // minimized) would otherwise mint a false task-complete OS notification + // that renderer timer throttling previously damped. Clear session-tied + // state only; never schedule completion attention from it. + if (meta?.staleWorkingTitleClear) { + deps.setCacheTimerStartedAt(cacheKey, null) + return + } // Why: only start the prompt-cache countdown for Claude agents — other // agents have different (or no) prompt-caching semantics and showing a // timer for them would be misleading. @@ -2775,7 +2717,10 @@ export function connectPanePty( userAgent: navigator.userAgent, connectionId, cwd: deps.cwd, - shellOverride, + // Why: main folds the global Windows shell into its spawn classification + // (pty.ts effectiveShellOverride); fold it here too so both sides treat + // a global-WSL default identically (terminal-query-authority.md ConPTY). + shellOverride: resolveWindowsShellOverride(shellOverride, state.settings?.terminalWindowsShell), executionHostId }) if (isNativeWindowsConpty) { @@ -2819,6 +2764,37 @@ export function connectPanePty( }) : undefined const shouldOwnAgentStatusInRenderer = runtimeEnvironmentId !== null + // Why: when main holds side-effect authority for this PTY's bytes, the + // transport must NOT register title/bell/agent byte parsers — the + // pty:sideEffect fact consumer below is the single policy consumer. + // Decided once at transport creation so a fact never has two consumers. + const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({ + settings: state.settings, + runtimeEnvironmentId + }) + // Why: Phase-4 hidden-delivery gate — only meaningful under main authority + // (renderer byte parsers need bytes otherwise). Decided once at pane + // creation: it picks the mode-2031 answer path (fact reply vs byte scan), + // which must have exactly one owner. + const hiddenDeliveryGateActive = + mainSideEffectAuthority && isRendererHiddenPtyDeliveryGateEnabled(state.settings) + // Why: structural per-PTY gate predicate (authority on + gate on + bytes + // transit local main, which implies snapshot-backed). Shared by the hidden + // mark sync and mode-2031 reply ownership so reply ownership can never + // disagree with what main may drop — and never depends on the racy hidden + // mark (a fact can outrun the pty:data task that sets it). + const isHiddenDeliveryGateManagedPty = (ptyId: string | null): ptyId is string => + hiddenDeliveryGateActive && Boolean(ptyId) && !isRemoteRuntimePtyId(ptyId) + // Why (byte-parser mode only): with main authority the Command Code scrape + // runs in main's per-PTY tracker and arrives as command-code facts; running + // the byte detector too would double-drive the seed/settle policy above. + const commandCodeOutputStatusDetector = mainSideEffectAuthority + ? null + : createCommandCodeOutputStatusDetector({ + startupCommand: paneStartup?.command, + onWorking: seedCommandCodeOutputWorkingStatus, + onDone: scheduleCommandCodeOutputDoneStatus + }) const shouldDeliverStartupViaTerminalPaste = paneStartup?.delivery === 'terminal-paste' const hadExistingPaneTransportAtConnect = deps.paneTransportsRef.current.size > 0 let lastTerminalInputAt = Number.NEGATIVE_INFINITY @@ -2880,12 +2856,16 @@ export function connectPanePty( ...(paneStartup?.launchAgent ? { launchAgent: paneStartup.launchAgent } : {}), ...(paneStartup?.telemetry ? { telemetry: paneStartup.telemetry } : {}), onPtyExit: onExit, - onTitleChange, onPtySpawn, - onBell, - onAgentBecameIdle, - onAgentBecameWorking, - onAgentExited, + ...(mainSideEffectAuthority + ? {} + : { + onTitleChange, + onBell, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited + }), // Why: local IPC terminals are now model-owned in main: OrcaRuntimeService // parses OSC 9999 before renderer delivery and forwards through the hook // server with local/SSH identity. Remote-runtime streams do not pass through @@ -2955,6 +2935,30 @@ export function connectPanePty( const transport = runtimeEnvironmentId ? createRemoteRuntimePtyTransport(runtimeEnvironmentId, transportOptions) : createIpcPtyTransport(transportOptions) + // Why (gate mode only): for gate-managed PTYs this fact is the SOLE 2031 + // responder — visible, hidden, marked or not. Conditioning the reply on the + // hidden mark double-fired (mark set + bytes delivered live via interest → + // fact AND xterm both replied) or dropped the reply entirely (fact outran + // the pty:data task that set the mark). The xterm-side CSI reply and the + // skipped-byte scan are disabled for these panes (same structural + // predicate), so exactly one reply goes out. + const handleHiddenMode2031SubscribeFact = (): void => { + if (disposed || !isHiddenDeliveryGateManagedPty(transport.getPtyId())) { + return + } + const mode = resolveTerminalColorSchemeMode( + useAppStore.getState().settings, + getSystemPrefersDark() + ) + // Why immediate: a mode-2031 query reply must beat the remote input debounce + // or it can miss the querying program's read window (#7329). + transport.sendInputImmediate(mode2031SequenceFor(mode)) + // Why: register the subscription exactly like the xterm CSI handler + // would — without the registry entry, later theme flips never push the + // CSI 997 update and the TUI keeps a stale theme after reveal. + deps.recordPaneMode2031Subscription?.(pane.id, mode) + recordHiddenMode2031Reply() + } deps.paneTransportsRef.current.set(pane.id, transport) const terminalCapabilityRepliesDisposable = installTerminalCapabilityReplyHandlers({ terminal: pane.terminal, @@ -3439,10 +3443,15 @@ export function connectPanePty( // into the seed when the user is mid-TUI; the read-fallback path // omits it because it wants the user's currently-visible content. const alt = pane.terminal.buffer.active.type === 'alternate' + // Why serializeWithAbsoluteCursor: SerializeAddon's relative + // cursor restore lands one column short when replay of a + // margin-filling final row leaves the target wrap-pending. const data = opts?.altScreenForcesZeroRows && alt - ? pane.serializeAddon.serialize({ scrollback: 0 }) - : pane.serializeAddon.serialize({ scrollback: opts?.scrollbackRows }) + ? serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { scrollback: 0 }) + : serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { + scrollback: opts?.scrollbackRows + }) return { data, cols: pane.terminal.cols, @@ -3873,6 +3882,7 @@ export function connectPanePty( ...(coldRestoreOverride ? { launchConfig: coldRestoreOverride.launchConfig } : {}), ...(coldRestoreOverride ? { launchToken: coldRestoreOverride.launchToken } : {}), ...(coldRestoreOverride ? { launchAgent: coldRestoreOverride.agent } : {}), + ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { onData: dataCallback, onReplayData: replayDataCallback, @@ -4283,6 +4293,57 @@ export function connectPanePty( // can reuse the pane object for a different session before visibility. let hiddenOutputRestorePtyId: string | null = null let hiddenOutputRestoreGeneration = 0 + // Flood-backpressure suppression (HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS). + let hiddenOutputRestoreFloodSuppressedUntil = 0 + let hiddenOutputRestoreFloodRepaintTimer: ReturnType | null = null + // Why: after a snapshot restore, main can still drain ACK-backlog chunks + // whose bytes the snapshot already covers — writing them unguarded + // duplicates visible output. Track the restored baseline seq (per PTY) + // and the expected next chunk start so dataCallback can drop/slice + // overlaps and detect seq gaps from main-side pending-cap trims whose + // one-shot marker was already consumed. + let restoredSnapshotBaselineSeq: number | null = null + let restoredSnapshotBaselinePtyId: string | null = null + let restoredSnapshotExpectedStartSeq: number | null = null + // Why: main samples its pending renderer-delivery queue with the snapshot. + // Chunks at or below this seq can never be backlog duplicates (delivery is + // once-and-in-order), so the dedupe window is (windowStart, baseline]. + let restoredSnapshotDeliveryWindowStartSeq: number | null = null + + function setRestoredSnapshotBaseline( + ptyId: string, + snapshot: { seq?: number; pendingDeliveryStartSeq?: number } + ): void { + if (typeof snapshot.seq !== 'number') { + clearRestoredSnapshotBaseline() + return + } + const windowStartSeq = + typeof snapshot.pendingDeliveryStartSeq === 'number' + ? Math.min(snapshot.pendingDeliveryStartSeq, snapshot.seq) + : null + if (windowStartSeq !== null && windowStartSeq >= snapshot.seq) { + // Why: main reported an empty undelivered backlog — no chunk at or + // below the snapshot seq can ever arrive again (delivery is once and + // in order) and a future pending-cap trim re-arms the out-of-band + // marker. Arming a baseline anyway would misread live chunks from a + // foreign seq domain (restarted counter / synthetic injection) as + // duplicates or trim gaps and silently drop genuinely-new output. + clearRestoredSnapshotBaseline() + return + } + restoredSnapshotBaselineSeq = snapshot.seq + restoredSnapshotBaselinePtyId = ptyId + restoredSnapshotExpectedStartSeq = snapshot.seq + restoredSnapshotDeliveryWindowStartSeq = windowStartSeq + } + + function clearRestoredSnapshotBaseline(): void { + restoredSnapshotBaselineSeq = null + restoredSnapshotBaselinePtyId = null + restoredSnapshotExpectedStartSeq = null + restoredSnapshotDeliveryWindowStartSeq = null + } let foregroundImmediateBudgetChars = 0 let foregroundImmediateBudgetWindowStart = 0 let foregroundRewriteChunkEndedWithCarriageReturn = false @@ -4332,28 +4393,170 @@ export function connectPanePty( return transport.serializeBuffer(opts) } - function respondToSkippedMode2031Subscribe(data: string): void { - const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data) - hiddenMode2031ScanTail = scan.tail - if (scan.finalState === 'unsubscribed') { - deps.paneMode2031Ref.current.delete(pane.id) - deps.paneLastThemeModeRef.current.delete(pane.id) - } - if (scan.finalState !== 'subscribed') { + // Why: hidden/parked panes used to mark hidden only at the first + // dataCallback sync, leaving a spawn-time window where neither side + // answered queries (the spawn-time DA1 loss). Declaring hidden on the + // spawn IPC lets main mark the PTY before its first byte — including + // codex spawns: the model responder answers their startup probes from + // byte zero now that the 10s renderer query window is gone. + // Remote-runtime PTYs are never gate-markable (no local main transit). + function shouldDeclareHiddenAtSpawn(): boolean { + return ( + hiddenDeliveryGateActive && + !runtimeEnvironmentId && + !disposed && + !shouldWritePtyOutputForeground(deps.isVisibleRef.current) + ) + } + + // ── Hidden-delivery gate sync (Phase 4) ───────────────────────────── + // Why: marks this pane's PTY hidden in main while no visible view needs + // its bytes; main then drops delivery after model ingestion and reveal + // restores from the snapshot. The marked id is tracked locally so PTY + // changes (reattach/restart) can never leave a stale id gated. + let hiddenDeliverySyncedPtyId: string | null = null + let releaseHiddenDeliveryClaim: (() => void) | null = null + let modelRestoreSubscribedPtyId: string | null = null + let unregisterModelRestoreNeeded: (() => void) | null = null + + function isHiddenOutputRestoreFloodSuppressed(): boolean { + return Date.now() < hiddenOutputRestoreFloodSuppressedUntil + } + + // True when a drop/gap signal on a visible pane is attributable to this + // pane's OWN restore backpressure (a restore is replaying right now, or + // one was just cut off for outrunning the stream). Such signals must not + // re-arm restores — that is the rc.7.perf feedback loop. + function isForegroundRestoreBackpressureContext(): boolean { + return ( + shouldWritePtyOutputForeground(deps.isVisibleRef.current) && + (hiddenOutputRestoreInFlight !== null || isHiddenOutputRestoreFloodSuppressed()) + ) + } + + function clearHiddenOutputRestoreFloodRepaintTimer(): void { + if (hiddenOutputRestoreFloodRepaintTimer === null) { return } - const settings = useAppStore.getState().settings - const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()) - // Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer - // mode 2031 out-of-band so TUIs still render the snapshot with the same - // theme-dependent styling they would have used in a visible pane. - deps.paneMode2031Ref.current.set(pane.id, true) - // Why: a query reply — send immediately so the remote input debounce - // cannot delay it past the querying program's read window (#7329). - transport.sendInputImmediate(mode2031SequenceFor(mode)) - deps.paneLastThemeModeRef.current.set(pane.id, mode) - recordHiddenMode2031Reply() + clearTimeout(hiddenOutputRestoreFloodRepaintTimer) + hiddenOutputRestoreFloodRepaintTimer = null } + cleanupHiddenOutputRestoreFloodRepaint = clearHiddenOutputRestoreFloodRepaintTimer + + function resetHiddenOutputRestoreFloodSuppression(): void { + hiddenOutputRestoreFloodSuppressedUntil = 0 + clearHiddenOutputRestoreFloodRepaintTimer() + } + + // Extends the suppression window and (re)schedules the single deferred + // repaint for when the flood goes quiet. Every backpressure signal resets + // the timer, so it fires exactly once, SUPPRESS_MS after the last signal. + function noteHiddenOutputRestoreFloodBackpressure(): void { + hiddenOutputRestoreFloodSuppressedUntil = Date.now() + HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS + const ptyId = transport.getPtyId() + if (ptyId === null) { + return + } + clearHiddenOutputRestoreFloodRepaintTimer() + hiddenOutputRestoreFloodRepaintTimer = setTimeout(() => { + hiddenOutputRestoreFloodRepaintTimer = null + if (disposed || transport.getPtyId() !== ptyId) { + return + } + // Why one repaint: bytes were dropped during the flood, so the screen + // has a gap the live stream cannot heal. Now that the flood is quiet, + // a single snapshot restore repaints from main's authoritative buffer. + markHiddenOutputRestoreNeeded() + }, HIDDEN_OUTPUT_RESTORE_FLOOD_SUPPRESS_MS) + } + + // Why: main reports dropped renderer-bound bytes (hidden gate / pending + // cap) out-of-band — routed per PTY by pty-model-restore-channel.ts. + function handleModelRestoreNeededMarker(): void { + if (disposed) { + return + } + recordTerminalFreezeBreadcrumb('restore-marker', { + id: redactPtyIdForDiagnostics(transport.getPtyId() ?? '') + }) + // Why: dropped bytes invalidate every cross-chunk carry — a partial + // OSC-9999 prefix spanning the gap would corrupt the next live chunk. + transport.resetCrossChunkParserState?.() + // Why gated (rc.7.perf loop): on a visible pane these markers are the + // product of our own restore starving ACKs. Re-arming per marker kept + // the snapshot-fetch loop alive for the whole flood; defer to one + // post-flood repaint instead and let live bytes flow. + if (isForegroundRestoreBackpressureContext()) { + noteHiddenOutputRestoreFloodBackpressure() + return + } + // Why: parity with the hidden skip path — a marker landing while a + // restore is in flight means the in-flight snapshot may predate the + // drop, so a fresh snapshot must follow. Captured BEFORE the mark: on a + // visible pane the mark starts a restore synchronously, which must not + // count as "already in flight". + const restoreWasInFlight = hiddenOutputRestoreInFlight !== null + markHiddenOutputRestoreNeeded() + if (restoreWasInFlight) { + hiddenOutputRestoreFreshSnapshotNeeded = true + } + } + + function syncModelRestoreNeededSubscription(ptyId: string | null): void { + if (modelRestoreSubscribedPtyId === ptyId) { + return + } + unregisterModelRestoreNeeded?.() + unregisterModelRestoreNeeded = null + modelRestoreSubscribedPtyId = ptyId + // Why: markers exist only for PTYs whose bytes transit local main; + // remote-runtime transports are structurally unaffected. + if (!ptyId || isRemoteRuntimePtyId(ptyId)) { + return + } + unregisterModelRestoreNeeded = registerPtyModelRestoreNeededHandler( + ptyId, + handleModelRestoreNeededMarker + ) + } + + syncHiddenRendererPtyDelivery = (): void => { + const ptyId = transport.getPtyId() + syncModelRestoreNeededSubscription(ptyId) + if (hiddenDeliverySyncedPtyId !== null && hiddenDeliverySyncedPtyId !== ptyId) { + releaseHiddenDeliveryClaim?.() + releaseHiddenDeliveryClaim = null + hiddenDeliverySyncedPtyId = null + } + if (!isHiddenDeliveryGateManagedPty(ptyId) || !canUseHiddenOutputSnapshot(ptyId)) { + return + } + const shouldHide = !disposed && !shouldWritePtyOutputForeground(deps.isVisibleRef.current) + const isFirstSyncForPty = hiddenDeliverySyncedPtyId !== ptyId + hiddenDeliverySyncedPtyId = ptyId + if (shouldHide) { + if (!releaseHiddenDeliveryClaim) { + releaseHiddenDeliveryClaim = acquireHiddenRendererPtyDeliveryClaim(ptyId) + } + } else if (releaseHiddenDeliveryClaim) { + releaseHiddenDeliveryClaim() + releaseHiddenDeliveryClaim = null + } else if (isFirstSyncForPty) { + // Why: clear unconditionally on the first sync for a PTY — a stale + // main-side hidden bit can survive a renderer reload for + // daemon-backed PTYs that keep their session id. + declareRendererPtyDeliveryVisible(ptyId) + } + } + releaseHiddenRendererPtyDelivery = (): void => { + releaseHiddenDeliveryClaim?.() + releaseHiddenDeliveryClaim = null + hiddenDeliverySyncedPtyId = null + unregisterModelRestoreNeeded?.() + unregisterModelRestoreNeeded = null + modelRestoreSubscribedPtyId = null + } + function beforeTerminalOutputWrite(): void { recordTerminalOutput(pane.terminal) } @@ -4488,6 +4691,27 @@ export function connectPanePty( } } + function respondToSkippedMode2031Subscribe(data: string): void { + const scan = scanMode2031Sequences(hiddenMode2031ScanTail, data) + hiddenMode2031ScanTail = scan.tail + if (scan.finalState === 'unsubscribed') { + deps.paneMode2031Ref.current.delete(pane.id) + deps.paneLastThemeModeRef.current.delete(pane.id) + } + if (scan.finalState !== 'subscribed') { + return + } + const settings = useAppStore.getState().settings + const mode = resolveTerminalColorSchemeMode(settings, getSystemPrefersDark()) + // Why: hidden snapshot-backed panes skip xterm.write for PTY bytes. Answer + // mode 2031 out-of-band so TUIs still render the snapshot with the same + // theme-dependent styling they would have used in a visible pane. + deps.paneMode2031Ref.current.set(pane.id, true) + transport.sendInput(mode2031SequenceFor(mode)) + deps.paneLastThemeModeRef.current.set(pane.id, mode) + recordHiddenMode2031Reply() + } + function writePtyOutputToXterm( data: string, foreground: boolean, @@ -4578,6 +4802,11 @@ export function connectPanePty( writeTerminalOutput(pane.terminal, data, { foreground: foregroundOutput, beforeWrite: beforeTerminalOutputWrite, + // Why: claims the in-progress pty:data delivery's parse-deferred ACK + // (null outside a delivery, e.g. snapshot replays / synthetic writes). + // The FIRST scheduler write of a delivery carries the whole credit; + // the scheduler fires it when the bytes are consumed. + ackCredit: takeCurrentPtyDeliveryAckCredit() ?? undefined, onBackgroundBacklogDropped: markHiddenOutputRestoreNeeded, latencySensitive: !foreground || parseHiddenStartupOutput @@ -4751,6 +4980,64 @@ export function connectPanePty( recordHiddenRendererSkip(data.length) } + // Why: discarding queued flood bytes must never swallow terminal queries — + // a lost DSR/CPR (or color/DA) reply hangs the querying program (the bench + // DSR timeout). The discarded CONTENT is owned by the snapshot repaint. + // Replies are SYNTHESIZED directly (transport.sendInput) instead of + // replaying the queries into xterm: a drop always triggers a snapshot + // restore, whose replay guard swallows xterm auto-replies and whose + // discardTerminalOutput races away queued query writes — both killed the + // salvaged reply in practice. Only called for bytes being thrown away, so + // replies cannot double-fire against a later queue drain. + function salvageRendererQueriesFromDiscardedRestoreData(data: string): void { + if (!data || !data.includes('\x1b')) { + return + } + const extracted = extractHiddenStartupRendererQueryData(data, '') + if (extracted.oscColorQueryData) { + sendTerminalOscColorQueryReplies(extracted.oscColorQueryData, pane.terminal, (reply) => + transport.sendInput(reply) + ) + } + let unansweredQueryData = '' + for (const sequence of splitCsiSequences( + extracted.statefulQueryData + extracted.statelessQueryData + )) { + if (sequence === '\x1b[6n') { + // CPR from the live buffer. Position may be mid-repaint stale — in a + // drop scenario positional accuracy is already forfeit; liveness is + // the contract (a blocked reader must unblock). + const buffer = pane.terminal.buffer.active + const row = Math.min(buffer.cursorY + 1, pane.terminal.rows) + const col = Math.min(buffer.cursorX + 1, pane.terminal.cols) + transport.sendInput(`\x1b[${row};${col}R`) + } else if (sequence === '\x1b[c' || sequence === '\x1b[0c') { + transport.sendInput(DEFAULT_DA1_RESPONSE) + } else { + unansweredQueryData += sequence + } + } + if (unansweredQueryData) { + // Best-effort for the rarer queries (DECRQM, DA2, XTVERSION): replay + // into xterm and let its handlers answer when no replay is active. + writePtyOutputToXterm(unansweredQueryData, true, { hiddenStartupRendererQuery: true }) + } + } + + function splitCsiSequences(queryData: string): string[] { + const sequences: string[] = [] + let offset = queryData.indexOf('\x1b[') + while (offset !== -1) { + const finalByteIndex = findCsiFinalByteIndex(queryData, offset + 2) + if (finalByteIndex === -1) { + break + } + sequences.push(queryData.slice(offset, finalByteIndex + 1)) + offset = queryData.indexOf('\x1b[', finalByteIndex + 1) + } + return sequences + } + function queueLiveChunkDuringRestore(data: string, meta?: PtyDataMeta): void { if (!data) { return @@ -4764,10 +5051,23 @@ export function connectPanePty( } hiddenOutputRestorePtyId = ptyId hiddenOutputRestoreNeeded = true + if (hiddenOutputRestorePendingOverflow) { + // Why: the overflow latch means everything queued gets discarded at the + // next drain — queueing more only grows the discard. Salvage queries, + // drop the content. + salvageRendererQueriesFromDiscardedRestoreData(data) + armHiddenOutputRestoreForegroundDeadline() + return + } if (hiddenOutputRestorePendingChars + data.length > HIDDEN_OUTPUT_RESTORE_PENDING_CHARS) { + const discardedChunks = hiddenOutputRestorePendingChunks hiddenOutputRestorePendingChunks = [] hiddenOutputRestorePendingChars = 0 hiddenOutputRestorePendingOverflow = true + for (const chunk of discardedChunks) { + salvageRendererQueriesFromDiscardedRestoreData(chunk.data) + } + salvageRendererQueriesFromDiscardedRestoreData(data) armHiddenOutputRestoreForegroundDeadline() return } @@ -4805,6 +5105,75 @@ export function connectPanePty( return chunk.data.slice(offset) } + type RestoredSnapshotReconciliation = + | { action: 'write'; data: string; meta: PtyDataMeta | undefined } + | { action: 'drop-duplicate' } + | { action: 'force-fresh-restore' } + + // Why: same slicing rules as getChunkDataAfterSnapshot, applied to LIVE + // chunks after a restore completed — main's ACK backlog keeps draining + // chunks at or before the snapshot seq, and pending-cap trims can drop + // seq ranges silently once the one-shot overflow marker was consumed. + function reconcileChunkAgainstRestoredSnapshot( + data: string, + meta: PtyDataMeta | undefined + ): RestoredSnapshotReconciliation { + if (restoredSnapshotBaselineSeq === null) { + return { action: 'write', data, meta } + } + if (transport.getPtyId() !== restoredSnapshotBaselinePtyId) { + clearRestoredSnapshotBaseline() + return { action: 'write', data, meta } + } + if (typeof meta?.seq !== 'number') { + // Why: seq-less chunks (no runtime metering) cannot be reconciled; + // mirror getChunkDataAfterSnapshot and pass them through. + return { action: 'write', data, meta } + } + if ( + restoredSnapshotDeliveryWindowStartSeq !== null && + meta.seq <= restoredSnapshotDeliveryWindowStartSeq + ) { + // Why: every byte main could still deliver at snapshot time started + // AFTER this seq, and delivery is once-and-in-order — so this chunk + // cannot be a backlog duplicate. It is a new seq domain (restarted + // counter / synthetic source); retire the stale baseline and write + // instead of silently dropping genuinely-new live output. + clearRestoredSnapshotBaseline() + return { action: 'write', data, meta } + } + const rawLength = meta.rawLength ?? data.length + const startSeq = meta.seq - rawLength + const expectedStartSeq = restoredSnapshotExpectedStartSeq + restoredSnapshotExpectedStartSeq = Math.max(expectedStartSeq ?? meta.seq, meta.seq) + if (expectedStartSeq !== null && startSeq > expectedStartSeq) { + // Why: the chunk starts past the continuity point — bytes between + // were dropped (pending-cap trim after the marker fired). Only the + // model snapshot can heal the gap. + return { action: 'force-fresh-restore' } + } + if (meta.seq <= restoredSnapshotBaselineSeq) { + return { action: 'drop-duplicate' } + } + if (startSeq >= restoredSnapshotBaselineSeq) { + return { action: 'write', data, meta } + } + if (rawLength !== data.length) { + // Why: renderer-only OSC stripping makes raw sequence offsets + // impossible to map onto cleaned text — fetch a fresh snapshot + // instead of risking duplicate visible output. + return { action: 'force-fresh-restore' } + } + const sliced = data.slice(restoredSnapshotBaselineSeq - startSeq) + return { + action: 'write', + data: sliced, + // Why: keep seq metadata consistent with the sliced payload so a + // later restore queue drain slices against accurate offsets. + meta: { ...meta, rawLength: sliced.length } + } + } + function recordRendererOrderedSeq(meta?: Pick): void { if (typeof meta?.seq !== 'number') { return @@ -4825,6 +5194,12 @@ export function connectPanePty( // Why: an exit ends this ptyId's seq domain. A revived session can reuse // the id with a restarted main-side counter, and a stale high-water mark // would wrongly cover — and silently drop — every hidden byte it emits. + // The restored-snapshot baseline is a seq high-water mark too and must + // die at the same boundary, or reconcile drops revived chunks as + // duplicates. + if (restoredSnapshotBaselinePtyId === exitedPtyId) { + clearRestoredSnapshotBaseline() + } if (rendererOrderedPtyId === exitedPtyId) { rendererOrderedPtyId = null rendererOrderedSeq = null @@ -4878,26 +5253,39 @@ export function connectPanePty( ) } - function drainPendingLiveChunksAfterSnapshot(snapshotSeq: number | undefined): boolean { + // 'drained' painted every queued live byte; 'overflow' means the queue + // blew its cap during this restore (the stream is outrunning snapshot + // fetch+replay); 'refetch' means offsets were unmappable and only a + // fresher snapshot can realign. + function drainPendingLiveChunksAfterSnapshot( + snapshotSeq: number | undefined + ): 'drained' | 'overflow' | 'refetch' { if (hiddenOutputRestorePendingOverflow) { hiddenOutputRestorePendingOverflow = false - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - return false + discardPendingLiveChunksSalvagingQueries() + return 'overflow' } while (hiddenOutputRestorePendingChunks.length > 0) { const chunks = hiddenOutputRestorePendingChunks hiddenOutputRestorePendingChunks = [] hiddenOutputRestorePendingChars = 0 - for (const chunk of chunks) { + for (const [index, chunk] of chunks.entries()) { const data = getChunkDataAfterSnapshot(chunk, snapshotSeq) if (data === null) { // Why: renderer-only OSC stripping makes raw sequence offsets // impossible to map onto cleaned text. Fetch a fresher main // snapshot instead of risking duplicate visible output. - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - return false + for (const discarded of chunks.slice(index)) { + salvageRendererQueriesFromDiscardedRestoreData(discarded.data) + } + discardPendingLiveChunksSalvagingQueries() + return 'refetch' + } + // Why: drained chunks advance the post-restore continuity point so + // the live-chunk reconciliation neither re-drops them as duplicates + // nor misreads the next live chunk as a gap. + if (typeof chunk.seq === 'number' && restoredSnapshotExpectedStartSeq !== null) { + restoredSnapshotExpectedStartSeq = Math.max(restoredSnapshotExpectedStartSeq, chunk.seq) } if (data) { writePtyOutputToXterm(data, true) @@ -4906,12 +5294,20 @@ export function connectPanePty( } if (hiddenOutputRestorePendingOverflow) { hiddenOutputRestorePendingOverflow = false - hiddenOutputRestorePendingChunks = [] - hiddenOutputRestorePendingChars = 0 - return false + discardPendingLiveChunksSalvagingQueries() + return 'overflow' } } - return true + return 'drained' + } + + function discardPendingLiveChunksSalvagingQueries(): void { + const discarded = hiddenOutputRestorePendingChunks + hiddenOutputRestorePendingChunks = [] + hiddenOutputRestorePendingChars = 0 + for (const chunk of discarded) { + salvageRendererQueriesFromDiscardedRestoreData(chunk.data) + } } function clearPendingLiveChunksDuringRestore(): void { @@ -4975,7 +5371,10 @@ export function connectPanePty( }, HIDDEN_OUTPUT_RESTORE_FOREGROUND_TIMEOUT_MS) } - function abandonHiddenOutputRestoreAndDrainPendingForeground(expectedPtyId: string): void { + function abandonHiddenOutputRestoreAndDrainPendingForeground( + expectedPtyId: string, + opts: { quiet?: boolean } = {} + ): void { if (transport.getPtyId() !== expectedPtyId || hiddenOutputRestorePtyId !== expectedPtyId) { resetHiddenOutputRestoreIfPtyChanged() return @@ -5002,7 +5401,12 @@ export function connectPanePty( clearHiddenOutputRestoreForegroundDeadlineTimer() hiddenOutputRestoreDeferredRetryAttempts = 0 - writeRestoreUnavailableWarning() + // Why quiet exists: flood cuts abandon deliberately and schedule a + // post-flood repaint — the "restore unavailable" warning would be + // misleading noise the repaint immediately wipes. + if (!opts.quiet) { + writeRestoreUnavailableWarning() + } if (hadPendingOverflow) { return } @@ -5101,7 +5505,10 @@ export function connectPanePty( // Why: renderer backlog is tied to the old PTY stream; after reattach, // queued hidden bytes must not delay or replay before the new PTY. clearHiddenOutputRestoreState() + clearRestoredSnapshotBaseline() clearPaneMode2031State() + // Why: flood-backpressure evidence is per PTY stream too. + resetHiddenOutputRestoreFloodSuppression() discardTerminalOutput(pane.terminal) } } @@ -5122,6 +5529,7 @@ export function connectPanePty( rows: number seq?: number alternateScreen?: boolean + scrollbackAnsi?: string pendingEscapeTailAnsi?: string }): void { const scrollIntent = captureTerminalWriteScrollIntent(pane.terminal) @@ -5153,13 +5561,19 @@ export function connectPanePty( // clearing on restore loses scroll-up after a hidden->visible return. // Mirrors the attach-time guard in pty-transport.ts. writeReplayData('\x1b[2J\x1b[3J\x1b[H') + } else if (snapshot.scrollbackAnsi !== undefined) { + // Why: SerializeAddon captures normal and alternate buffers together. + // Rebuild normal while it is active, then return to a clean alt frame. + writeReplayData('\x1b[?1049l\x1b[2J\x1b[3J\x1b[H') + writeReplayData(snapshot.scrollbackAnsi) + writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') } else { // Why: the snapshot's own ?1049h is a no-op when the pane is already on // the alternate screen, and the serialized frame skips blank cells — so // without clearing the alt screen the pre-hide frame bleeds through // every cell the final frame leaves blank. \x1b[2J on the alt buffer // does not touch the normal buffer's scrollback the TUI returns to. - writeReplayData('\x1b[?1049h\x1b[2J\x1b[H') + writeReplayData('\x1b[0m\x1b[?1049h\x1b[2J\x1b[H') } writeReplayData(snapshot.data) // Why: status/title-corroborated live agents own ?25l/?1004h (a forced @@ -5171,10 +5585,11 @@ export function connectPanePty( : POST_REPLAY_LIVE_SNAPSHOT_RESET ) if (snapshot.pendingEscapeTailAnsi) { - // Why last: the snapshot was serialized while the emulator sat mid-escape; - // re-arm the dangling sequence as the FINAL replay write (any later ESC — - // including the reset above — would abort it) so the racing live tail's - // continuation completes it instead of rendering literally (#7329). + // Why last: the snapshot was taken with main's emulator mid-escape; + // re-arming the dangling sequence must be the FINAL replay write (any + // later ESC — including the reset above — aborts it) so the racing + // live tail's continuation completes it exactly as live, instead of + // rendering literally (Bug E fix / #7329). writeReplayData(snapshot.pendingEscapeTailAnsi) } hiddenRendererStateDirty = false @@ -5255,6 +5670,10 @@ export function connectPanePty( hiddenOutputRestoreRetryDeferred = false hiddenOutputRestoreInFlight = (async () => { + // Backstop for the rc.7.perf feedback loop: bound how many snapshot + // fetch+replay rounds one task may burn before it must yield to the + // live stream. + let restoreIterations = 0 while (!disposed) { const currentPtyId = hiddenOutputRestorePtyId if (currentPtyId === null) { @@ -5279,7 +5698,7 @@ export function connectPanePty( let snapshot: PtyBufferSnapshot | null = null try { snapshot = await serializeHiddenOutputSnapshot(currentPtyId, { - scrollbackRows: HIDDEN_OUTPUT_RESTORE_SCROLLBACK_ROWS + scrollbackRows: resolveHiddenRestoreScrollbackRows(pane.terminal.options.scrollback) }) } catch { snapshot = null @@ -5308,10 +5727,16 @@ export function connectPanePty( return } hiddenOutputRestoreDeferredRetryAttempts = 0 + restoreIterations += 1 applyMainBufferSnapshot(snapshot) + // Why: everything at or before snapshot.seq is now painted; chunks + // still draining from main's ACK backlog below that point are + // duplicates the dataCallback reconciliation must suppress. + setRestoredSnapshotBaseline(currentPtyId, snapshot) const needsFreshSnapshot = hiddenOutputRestoreFreshSnapshotNeeded hiddenOutputRestoreFreshSnapshotNeeded = false - if (drainPendingLiveChunksAfterSnapshot(snapshot.seq) && !needsFreshSnapshot) { + const drainOutcome = drainPendingLiveChunksAfterSnapshot(snapshot.seq) + if (drainOutcome === 'drained' && !needsFreshSnapshot) { hiddenOutputRestoreNeeded = false hiddenOutputRestorePtyId = null clearHiddenOutputRestoreForegroundDeadlineTimer() @@ -5324,6 +5749,31 @@ export function connectPanePty( hiddenOutputRestoreNeeded = true return } + if (drainOutcome === 'overflow') { + // Cut 1 of the rc.7.perf feedback loop: a FOREGROUND queue + // overflow means the live stream outruns snapshot fetch+replay. + // Re-fetching would starve ACK processing again and feed the + // drop/re-arm cycle — abandon now, let bytes write through, and + // heal with one repaint after the flood. + noteHiddenOutputRestoreFloodBackpressure() + abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true }) + return + } + if (restoreIterations >= HIDDEN_OUTPUT_RESTORE_MAX_LOOP_ITERATIONS) { + // Backstop: fresh-snapshot marks / unmappable slices re-looping + // this many times means the stream is winning the race. + warnTerminalLifecycleAnomaly('hidden output restore hit its iteration cap', { + tabId: deps.tabId, + worktreeId: deps.worktreeId, + leafId: pane.leafId, + paneId: pane.id, + ptyId: currentPtyId, + reason: drainOutcome + }) + noteHiddenOutputRestoreFloodBackpressure() + abandonHiddenOutputRestoreAndDrainPendingForeground(currentPtyId, { quiet: true }) + return + } hiddenOutputRestoreNeeded = true } })() @@ -5349,23 +5799,38 @@ export function connectPanePty( return true } - unregisterBacklogRecovery = registerTerminalBacklogRecovery( - pane.terminal, - requestHiddenOutputRestoreIfNeeded - ) + unregisterBacklogRecovery = registerTerminalBacklogRecovery(pane.terminal, () => { + // Why: clear the hidden-delivery bit BEFORE the restore snapshot + // request — bytes arriving between the unhide IPC and the snapshot + // are reconciled by the existing seq guard. + syncHiddenRendererPtyDelivery() + return requestHiddenOutputRestoreIfNeeded() + }) if ( typeof document !== 'undefined' && typeof document.addEventListener === 'function' && typeof document.removeEventListener === 'function' ) { const onDocumentVisibilityChange = (): void => { + // Why: document hide/show flips the foreground predicate without any + // pane lifecycle event — re-sync the hidden-delivery gate both ways. + syncHiddenRendererPtyDelivery() if (shouldWritePtyOutputForeground(deps.isVisibleRef.current)) { requestHiddenOutputRestoreIfNeeded() } } document.addEventListener('visibilitychange', onDocumentVisibilityChange) - unregisterDocumentVisibilityRecovery = () => + // Why: when user input proves visibilityState is wedged at 'hidden' + // (stale macOS occlusion), run the same resync — no visibilitychange + // will ever fire in that state, and the gate would drop watched bytes + // forever. + const unregisterStaleVisibilityRecovery = registerStaleDocumentVisibilityRecovery( + onDocumentVisibilityChange + ) + unregisterDocumentVisibilityRecovery = () => { document.removeEventListener('visibilitychange', onDocumentVisibilityChange) + unregisterStaleVisibilityRecovery() + } } const dataCallback = (data: string, meta?: PtyDataMeta): void => { @@ -5385,28 +5850,86 @@ export function connectPanePty( } observeStartupDraftPasteReadiness(data) resetHiddenOutputRestoreIfPtyChanged() - if (meta?.droppedBacklog === true) { - // Why: main trimmed this pty's unsent backlog (the renderer was - // background-throttled/frozen and stopped ACKing). Rebuild the dropped - // span from the main headless snapshot — same recovery the renderer's - // own 2 MB scheduler overflow uses. No-op cost when already visible+synced. - markHiddenOutputRestoreNeeded() + if (meta?.droppedOutput === true) { + // Why gated (rc.7.perf loop): a visible pane's cap-drop during its own + // restore is backpressure the restore itself caused — re-arming per + // sentinel kept the snapshot-fetch loop alive for the whole flood. + // Defer to one post-flood repaint; any carved-out query bytes riding + // the sentinel still flow through the normal write path below. + if (meta?.background !== true && isForegroundRestoreBackpressureContext()) { + noteHiddenOutputRestoreFloodBackpressure() + } else { + // Why: main dropped this PTY's buffered output at the pending cap + // (renderer was not receiving). The stream has a gap, so repaint the + // pane from the main-owned buffer snapshot instead of writing on. + markHiddenOutputRestoreNeeded() + if (data) { + // The sentinel can carry query bytes carved out of the bulk drop + // (extractDroppedPtyQueryBytes in main) — replies must still flow. + salvageRendererQueriesFromDiscardedRestoreData(data) + } + return + } } respondToTerminalPixelSizeQueries(data) observeTerminalBracketedPasteModeOutput(pane.terminal, data) - for (const link of observeTerminalGitHubPRLink(data)) { - useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link) + // Why: with main side-effect authority, command-finished, pr-link, and + // the Command Code scrape arrive as pty:sideEffect facts — + // byte-scanning here too would double-fire the same policy. + // Remote-runtime PTYs (and the kill switch off) keep this byte path as + // their only parser. + if (!mainSideEffectAuthority) { + for (const link of observeTerminalGitHubPRLink(data)) { + useAppStore.getState().observeTerminalGitHubPullRequestLink(deps.worktreeId, link) + } + commandLifecycle.handlePtyData(data) } - commandCodeOutputStatusDetector.observe(data) - commandLifecycle.handlePtyData(data) + commandCodeOutputStatusDetector?.observe(data) // Why: split-pane layouts have multiple visible-but-inactive panes whose // output the user is watching. Throttle only when the pane or whole // Electron document is hidden. const foreground = shouldWritePtyOutputForeground(deps.isVisibleRef.current) && meta?.background !== true + // Why: latch the hidden-delivery gate from the byte path too — covers a + // PTY id arriving after the initial sync. No-op when state is current. + if (!foreground) { + syncHiddenRendererPtyDelivery() + } if (foreground && hiddenMode2031ScanTail) { respondToSkippedMode2031Subscribe(data) } + // Why: post-restore reconciliation — drop/slice backlog chunks the + // restored snapshot already covers, and force a fresh restore for seq + // gaps or overlaps whose offsets cannot be mapped. Runs after the byte + // observers above (those bytes were never delivered before; their side + // effects are still real) but before any xterm write decision. + const reconciliation = reconcileChunkAgainstRestoredSnapshot(data, meta) + if (reconciliation.action === 'drop-duplicate') { + return + } + if (reconciliation.action === 'force-fresh-restore') { + // Why gated (rc.7.perf loop): during a foreground flood the seq gaps + // come from our own backpressure drops — fetching a snapshot per gap + // IS the feedback loop. Retire the stale baseline, write the post-gap + // bytes through, and heal with one repaint after the flood. + if (foreground && isForegroundRestoreBackpressureContext()) { + noteHiddenOutputRestoreFloodBackpressure() + clearRestoredSnapshotBaseline() + // fall through with the ORIGINAL data/meta — post-gap bytes are new + } else { + // Why: in-flight captured BEFORE the mark — on a visible pane the + // mark starts the restore synchronously and must not flag itself. + const restoreWasInFlight = hiddenOutputRestoreInFlight !== null + markHiddenOutputRestoreNeeded() + if (restoreWasInFlight) { + hiddenOutputRestoreFreshSnapshotNeeded = true + } + return + } + } else { + data = reconciliation.data + meta = reconciliation.meta + } // Why: a hidden Codex query can be split just before visibility changes; // xterm needs the completed query, while other bytes still follow restore. const pendingForegroundQuery = foreground @@ -5472,7 +5995,15 @@ export function connectPanePty( hiddenOutputRestoreNeeded = true hiddenOutputRestoreFreshSnapshotNeeded = true } + // Why: hidden chunks with a restore already latched are dropped here — + // the model snapshot fetched on reveal covers their bytes. } else { + // Why: gate-managed hidden panes normally receive no bytes (main + // drops after model ingestion). Any hidden chunk that still arrives + // (kill switch off, interest-held delivery) rides the bounded + // background scheduler queue; on overflow the scheduler latches the + // model restore. The kill-switch-off startup-query grammar above is + // the byte-identical fallback. if (pendingForegroundQuery?.statefulQueryData) { writePtyOutputToXterm(pendingForegroundQuery.statefulQueryData, true, { hiddenStartupRendererQuery: true @@ -5551,6 +6082,8 @@ export function connectPanePty( } setPanePtyFitBinding(ptyId) reportPanePtyVisibility(ptyId, deps.isVisibleRef.current) + registerSideEffectFactConsumerForPty(ptyId) + syncHiddenRendererPtyDelivery() deps.syncPanePtyLayoutBinding(pane.id, ptyId) deps.updateTabPtyId(deps.tabId, ptyId) agentCompletionCoordinator.startProcessTracking() @@ -5905,6 +6438,7 @@ export function connectPanePty( ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), + ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { onData: dataCallback, onReplayData: replayDataCallback, @@ -6088,6 +6622,7 @@ export function connectPanePty( : {}), ...(coldRestoreStartup?.launchToken ? { launchToken: coldRestoreStartup.launchToken } : {}), ...(coldRestoreStartup?.agent ? { launchAgent: coldRestoreStartup.agent } : {}), + ...(shouldDeclareHiddenAtSpawn() ? { initiallyHidden: true } : {}), callbacks: { onData: dataCallback, onReplayData: replayDataCallback, @@ -6348,6 +6883,12 @@ export function connectPanePty( return { syncProcessTracking() { agentCompletionCoordinator.startProcessTracking() + // Why: the lifecycle hook calls this on every pane visibility flip — + // the hidden-delivery gate must follow the same transitions. + syncHiddenRendererPtyDelivery() + }, + isHiddenDeliveryGateManagedPty() { + return isHiddenDeliveryGateManagedPty(transport.getPtyId()) }, // Why: called from the lifecycle visibility effect so the visible-resume // size readback can repair dropped hidden resizes without refitting against @@ -6416,6 +6957,9 @@ export function connectPanePty( cancelAnimationFrame(pendingForegroundGridDriftCheckRaf) pendingForegroundGridDriftCheckRaf = null } + // Why: a pane unmount (tab move, parking teardown) must never leave its + // PTY gated — the parked watcher or the remounted pane re-decides. + releaseHiddenRendererPtyDelivery() if (terminalKeyTargetSupportsEvents) { terminalKeyTarget.removeEventListener('keydown', onTerminalKeyDown, { capture: true }) } @@ -6451,11 +6995,15 @@ export function connectPanePty( } cleanupHiddenOutputRestoreDeferredRetry() cleanupHiddenOutputRestoreForegroundDeadline() + cleanupHiddenOutputRestoreFloodRepaint() unregisterBacklogRecovery?.() unregisterBacklogRecovery = null unregisterDocumentVisibilityRecovery?.() unregisterDocumentVisibilityRecovery = null - reportPanePtyVisibility(activePanePtyBinding ?? transport.getPtyId(), false) + releaseRendererPtyVisibilityClaim(transport) + // Why: a parked-tab watcher may take over this PTY's facts in the same + // effect flush; the pane's consumer must be gone before that handoff. + dropSideEffectFactConsumer() clearPanePtyFitBinding() discardTerminalOutput(pane.terminal) unregisterE2ePtyDataInjection() diff --git a/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts b/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts index af06e1b4a26..3625a8e6007 100644 --- a/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts +++ b/src/renderer/src/components/terminal-pane/pty-data-sidecar-subscriptions.ts @@ -1,9 +1,14 @@ +import { acquirePtyDeliveryInterest } from './pty-delivery-interest' import { ensurePtyDispatcher, ptyDataSidecars } from './pty-dispatcher' /** Register a side-channel data watcher for a PTY without taking ownership * of the primary handler. Returns an unsubscribe fn. */ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => void): () => void { ensurePtyDispatcher() + // Why: a sidecar is, by definition, a raw-byte consumer — its registration + // doubles as the delivery-interest signal that suppresses main's + // hidden-delivery gate (terminal-side-effect-authority.md, Open Items). + const releaseDeliveryInterest = acquirePtyDeliveryInterest(ptyId) let set = ptyDataSidecars.get(ptyId) if (!set) { set = new Set() @@ -11,6 +16,7 @@ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => voi } set.add(watcher) return () => { + releaseDeliveryInterest() const current = ptyDataSidecars.get(ptyId) if (!current) { return diff --git a/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts b/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts new file mode 100644 index 00000000000..a695b77d82f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-delivery-interest.ts @@ -0,0 +1,42 @@ +/** + * Renderer-side delivery-interest registry for the Phase-4 hidden-delivery + * gate (docs/reference/terminal-side-effect-authority.md, Open Items). + * + * Why: main only drops hidden PTY byte delivery while NO renderer party needs + * raw bytes. Dispatcher sidecars and eager pre-mount buffers register + * interest here; ref-counted so main sees only the 0↔1 transitions. + */ +const ptyDeliveryInterestRefCounts = new Map() + +function sendPtyDeliveryInterest(ptyId: string, interested: boolean): void { + ;(globalThis as { window?: Window }).window?.api?.pty?.setPtyDeliveryInterest?.(ptyId, interested) +} + +/** Acquire a delivery-interest hold for a PTY. Returns a release fn that is + * safe to call more than once (only the first call decrements). */ +export function acquirePtyDeliveryInterest(ptyId: string): () => void { + const next = (ptyDeliveryInterestRefCounts.get(ptyId) ?? 0) + 1 + ptyDeliveryInterestRefCounts.set(ptyId, next) + if (next === 1) { + sendPtyDeliveryInterest(ptyId, true) + } + let released = false + return () => { + if (released) { + return + } + released = true + const current = ptyDeliveryInterestRefCounts.get(ptyId) ?? 0 + if (current <= 1) { + ptyDeliveryInterestRefCounts.delete(ptyId) + sendPtyDeliveryInterest(ptyId, false) + } else { + ptyDeliveryInterestRefCounts.set(ptyId, current - 1) + } + } +} + +/** Test seam: drop ref counts between tests (no IPC is sent). */ +export function _resetPtyDeliveryInterestForTest(): void { + ptyDeliveryInterestRefCounts.clear() +} diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts new file mode 100644 index 00000000000..c8f84865fed --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-interest.test.ts @@ -0,0 +1,107 @@ +// Why: the Phase-4 hidden-delivery gate only drops bytes while NO renderer +// party needs them. These tests pin the dispatcher-side interest signal: every +// subscribeToPtyData sidecar must surface a ref-counted delivery-interest hold +// to main. Eager buffers are model-recoverable and must not defeat hidden gating. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('pty dispatcher delivery interest', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let setPtyDeliveryInterest: ReturnType + let exitCallback: ((payload: { id: string; code: number }) => void) | null = null + + beforeEach(() => { + vi.resetModules() + exitCallback = null + setPtyDeliveryInterest = vi.fn() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + setPtyDeliveryInterest, + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((cb: (payload: { id: string; code: number }) => void) => { + exitCallback ??= cb + return () => {} + }), + ackData: vi.fn() + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('registers interest on the first sidecar and releases on the last unsubscribe', async () => { + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + + const unsubscribeFirst = subscribeToPtyData('pty-1', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + expect(setPtyDeliveryInterest).toHaveBeenCalledWith('pty-1', true) + + // Why: ref-counted — main only sees the 0↔1 transitions. + const unsubscribeSecond = subscribeToPtyData('pty-1', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + + unsubscribeFirst() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + unsubscribeSecond() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2) + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false) + }) + + it('releases sidecar interest only once for repeated unsubscribes', async () => { + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + + const unsubscribe = subscribeToPtyData('pty-1', vi.fn()) + unsubscribe() + unsubscribe() + + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2) + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false) + }) + + it('does not let an eager pre-mount buffer defeat hidden delivery gating', async () => { + const { registerEagerPtyBuffer } = await import('./pty-dispatcher') + + const handle = registerEagerPtyBuffer('pty-eager', vi.fn()) + expect(setPtyDeliveryInterest).not.toHaveBeenCalled() + + handle.dispose() + expect(setPtyDeliveryInterest).not.toHaveBeenCalled() + }) + + it('keeps eager buffers outside delivery interest when the PTY exits before mount', async () => { + const { registerEagerPtyBuffer } = await import('./pty-dispatcher') + + registerEagerPtyBuffer('pty-eager', vi.fn()) + expect(setPtyDeliveryInterest).not.toHaveBeenCalled() + + exitCallback?.({ id: 'pty-eager', code: 0 }) + expect(setPtyDeliveryInterest).not.toHaveBeenCalled() + }) + + it('lets a sidecar exclusively own interest while an eager buffer overlaps', async () => { + const { registerEagerPtyBuffer } = await import('./pty-dispatcher') + const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions') + + const handle = registerEagerPtyBuffer('pty-1', vi.fn()) + const unsubscribe = subscribeToPtyData('pty-1', vi.fn()) + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + + handle.dispose() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1) + + unsubscribe() + expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2) + expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts new file mode 100644 index 00000000000..7febefe5126 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-delivery-resync.test.ts @@ -0,0 +1,92 @@ +// Why: pins the renderer half of the delivery-resync protocol — the singleton +// dispatcher must answer main's probe with the cumulative processed totals +// that back its ACKs, and must drop a PTY's total once it exits. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('pty dispatcher delivery resync', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + let dataCallback: + | ((payload: { id: string; data: string; rawLength?: number; background?: boolean }) => void) + | null = null + let exitCallback: ((payload: { id: string; code: number }) => void) | null = null + let resyncRequestCallback: ((payload: { requestId: number }) => void) | null = null + const ackDataMock = vi.fn() + const respondDeliveryResyncMock = vi.fn() + + beforeEach(() => { + vi.resetModules() + dataCallback = null + exitCallback = null + resyncRequestCallback = null + ackDataMock.mockClear() + respondDeliveryResyncMock.mockClear() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + ackData: ackDataMock, + onData: vi.fn( + ( + cb: (payload: { + id: string + data: string + rawLength?: number + background?: boolean + }) => void + ) => { + dataCallback ??= cb + return () => {} + } + ), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn((cb: (payload: { id: string; code: number }) => void) => { + exitCallback ??= cb + return () => {} + }), + onDeliveryResyncRequest: vi.fn((cb: (payload: { requestId: number }) => void) => { + resyncRequestCallback ??= cb + return () => {} + }), + respondDeliveryResync: respondDeliveryResyncMock + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('answers delivery resync probes with cumulative totals and drops exited PTYs', async () => { + const { ensurePtyDispatcher } = await import('./pty-dispatcher') + ensurePtyDispatcher() + + dataCallback?.({ id: 'pty-1', data: 'hello' }) + dataCallback?.({ id: 'pty-1', data: 'world!!', rawLength: 7 }) + dataCallback?.({ id: 'pty-2', data: 'abc' }) + + expect(ackDataMock).toHaveBeenNthCalledWith(1, 'pty-1', 5, 5) + expect(ackDataMock).toHaveBeenNthCalledWith(2, 'pty-1', 7, 12) + expect(ackDataMock).toHaveBeenNthCalledWith(3, 'pty-2', 3, 3) + + resyncRequestCallback?.({ requestId: 7 }) + expect(respondDeliveryResyncMock).toHaveBeenCalledWith({ + requestId: 7, + processedCharsByPty: { 'pty-1': 12, 'pty-2': 3 } + }) + + exitCallback?.({ id: 'pty-1', code: 0 }) + resyncRequestCallback?.({ requestId: 8 }) + expect(respondDeliveryResyncMock).toHaveBeenLastCalledWith({ + requestId: 8, + processedCharsByPty: { 'pty-2': 3 } + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts index bce9767bd85..2df88ac2973 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-pi-routing.test.ts @@ -92,7 +92,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { dispatcherCallback?.({ id: 'pty-pi', data: 'chunk', rawLength: 10, background: true } as never) expect(handler).toHaveBeenCalledWith('chunk', { rawLength: 10, background: true }) - expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-pi', 10) + expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-pi', 10, 10) ptyDataHandlers.delete('pty-pi') }) @@ -299,7 +299,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => { dispatcherCallback?.({ id: 'pty-early', data: 'setup starts here\r\n', rawLength: 19 }) expect(onData).not.toHaveBeenCalled() - expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-early', 19) + expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-early', 19, 19) resolveSpawn({ id: 'pty-early' }) await connectPromise diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts new file mode 100644 index 00000000000..7430231bf04 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher-push-reattach.test.ts @@ -0,0 +1,111 @@ +// Why: unit-level repro of the field wedge (v1.4.121-rc.0) — push events +// vanish before the dispatcher sees them, so no handler runs and no ACK is +// ever produced — plus the recovery seam: reattachPtyDispatcherPushListeners +// must drop the stale subscriptions and bind fresh ones. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: true } })) + +type DataCallback = (payload: { id: string; data: string; rawLength?: number }) => void + +describe('pty dispatcher push-listener reattach and delivery blackhole', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + let dataCallbacks: DataCallback[] = [] + let dataUnsubscribes: ReturnType[] = [] + const ackDataMock = vi.fn() + const reportMock = vi.fn(() => + Promise.resolve({ + inFlightTotalChars: 0, + inFlightPtyCount: 0, + msSinceLastAck: null + }) + ) + + beforeEach(() => { + vi.resetModules() + dataCallbacks = [] + dataUnsubscribes = [] + ackDataMock.mockClear() + reportMock.mockClear() + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + ackData: ackDataMock, + reportRendererDeliveryState: reportMock, + getPtyDataListenerCount: () => dataCallbacks.length, + onData: vi.fn((cb: DataCallback) => { + dataCallbacks.push(cb) + const unsubscribe = vi.fn() + dataUnsubscribes.push(unsubscribe) + return unsubscribe + }), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + onDeliveryResyncRequest: vi.fn(() => () => {}), + respondDeliveryResync: vi.fn() + } + } + } as unknown as typeof window + }) + + afterEach(async () => { + // Why: ensurePtyDispatcher started the watchdog's real-timer interval; + // stop it so no tick outlives this file's mocked window. + const { stopTerminalDeliveryWatchdog } = await import('./terminal-delivery-watchdog') + stopTerminalDeliveryWatchdog() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('blackholed delivery reproduces the wedge: no handler dispatch, no ACK ever', async () => { + const { ensurePtyDispatcher, ptyDataHandlers } = await import('./pty-dispatcher') + ensurePtyDispatcher() + const received: string[] = [] + ptyDataHandlers.set('pty-1', (data) => received.push(data)) + + dataCallbacks[0]?.({ id: 'pty-1', data: 'before-wedge' }) + expect(received).toEqual(['before-wedge']) + expect(ackDataMock).toHaveBeenCalledTimes(1) + + const blackhole = ( + window as Window & { + __terminalDeliveryWatchdog?: { blackhole: (on: boolean) => void } + } + ).__terminalDeliveryWatchdog + expect(blackhole).toBeDefined() + blackhole!.blackhole(true) + + // The field failure in miniature: the chunk vanishes with no receive + // count and no ACK — main's in-flight debt for it can never be repaid. + dataCallbacks[0]?.({ id: 'pty-1', data: 'lost-in-wedge' }) + expect(received).toEqual(['before-wedge']) + expect(ackDataMock).toHaveBeenCalledTimes(1) + + blackhole!.blackhole(false) + }) + + it('reattach drops stale push subscriptions and binds fresh ones', async () => { + const { ensurePtyDispatcher, ptyDataHandlers, reattachPtyDispatcherPushListeners } = + await import('./pty-dispatcher') + ensurePtyDispatcher() + const received: string[] = [] + ptyDataHandlers.set('pty-1', (data) => received.push(data)) + expect(dataCallbacks).toHaveLength(1) + + reattachPtyDispatcherPushListeners() + + expect(dataUnsubscribes[0]).toHaveBeenCalledTimes(1) + expect(dataCallbacks).toHaveLength(2) + + dataCallbacks[1]?.({ id: 'pty-1', data: 'after-reattach' }) + expect(received).toEqual(['after-reattach']) + expect(ackDataMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts index 4c3857d9a09..b563443d1ee 100644 --- a/src/renderer/src/components/terminal-pane/pty-dispatcher.ts +++ b/src/renderer/src/components/terminal-pane/pty-dispatcher.ts @@ -6,7 +6,12 @@ * and the eager-buffer reconnection logic share. */ import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' -import { ackPtyData, exposeE2eTerminalPtyAckGate } from './terminal-pty-ack-gate' +import { + clearProcessedPtyCharTotal, + deliverPtyDataWithDeferredAck, + exposeE2eTerminalPtyAckGate, + getProcessedPtyCharTotals +} from './terminal-pty-ack-gate' import { clampUtf8Tail, type EagerBufferChunk } from './pty-eager-buffer-clamp' import { bufferPreHandlerPtyData, @@ -15,6 +20,14 @@ import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handler-buffer' +import { + clearReceivedPtyCharTotal, + isPtyPushDeliveryBlackholed, + recordPtyDataReceived, + startTerminalDeliveryWatchdog +} from './terminal-delivery-watchdog' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' +import { installTerminalFreezeReport } from './terminal-freeze-report' // ── Singleton PTY event dispatcher ─────────────────────────────────── // One global IPC listener per channel, routes events to transports by @@ -25,9 +38,9 @@ export type PtyDataMeta = { seq?: number rawLength?: number background?: boolean - /** Main trimmed this pty's unsent backlog past its cap; the handler should - * rebuild the dropped span from the main headless snapshot. */ - droppedBacklog?: boolean + /** Main dropped this PTY's buffered output at the pending cap; the pane + * must repaint from the main-owned snapshot instead of the live stream. */ + droppedOutput?: boolean } export const ptyDataHandlers = new Map void>() @@ -102,78 +115,151 @@ export function restorePtyDataHandlersAfterFailedShutdown( } } +let pushListenerUnsubscribes: (() => void)[] = [] + +/** Detach and freshly re-subscribe every push-channel listener. Called by the + * delivery watchdog on a confirmed wedge: if the listener was somehow + * detached this restores delivery outright; if the channel itself is dead + * it is a safe no-op (removeListener on a gone listener does nothing). */ +export function reattachPtyDispatcherPushListeners(): void { + recordTerminalFreezeBreadcrumb('push-listeners-reattach', { + staleListenerCount: pushListenerUnsubscribes.length + }) + const stale = pushListenerUnsubscribes + pushListenerUnsubscribes = [] + for (const unsubscribe of stale) { + unsubscribe() + } + attachPtyPushListeners() +} + export function ensurePtyDispatcher(): void { if (ptyDispatcherAttached) { return } ptyDispatcherAttached = true exposeE2eTerminalPtyAckGate() - window.api.pty.onData((payload) => { - try { - let meta: PtyDataMeta | undefined - if (typeof payload.seq === 'number') { - meta ??= {} - meta.seq = payload.seq + installTerminalFreezeReport() + attachPtyPushListeners() + startTerminalDeliveryWatchdog({ + reattachPushListeners: reattachPtyDispatcherPushListeners, + hasAttachedPtys: () => ptyDataHandlers.size > 0 || eagerPtyHandles.size > 0 + }) +} + +function attachPtyPushListeners(): void { + const unsubscribes = pushListenerUnsubscribes + unsubscribes.push( + window.api.pty.onData((payload) => { + // Why: e2e-only wedge simulation — the chunk vanishes exactly as in the + // field failure: no receive count, no ACK credit, no handler dispatch. + if (isPtyPushDeliveryBlackholed()) { + return } - if (typeof payload.rawLength === 'number') { - meta ??= {} - meta.rawLength = payload.rawLength + handleDispatchedPtyData(payload) + }) + ) + attachPtySecondaryPushListeners(unsubscribes) +} + +function handleDispatchedPtyData(payload: { + id: string + data: string + seq?: number + rawLength?: number + background?: boolean + droppedOutput?: boolean +}): void { + let meta: PtyDataMeta | undefined + if (typeof payload.seq === 'number') { + meta ??= {} + meta.seq = payload.seq + } + if (typeof payload.rawLength === 'number') { + meta ??= {} + meta.rawLength = payload.rawLength + } + if (payload.background === true) { + meta ??= {} + meta.background = true + } + if (payload.droppedOutput === true) { + meta ??= {} + meta.droppedOutput = true + } + const chars = payload.rawLength ?? payload.data.length + const dispatch = (): void => { + const handler = ptyDataHandlers.get(payload.id) + if (handler) { + handler(payload.data, meta) + } else { + bufferPreHandlerPtyData(payload.id, payload.data, meta) + } + const sidecars = ptyDataSidecars.get(payload.id) + if (sidecars && sidecars.size > 0) { + // Why: snapshot the Set before iterating because watchers commonly + // unsubscribe themselves on the very chunk that satisfies them + // (e.g. agent-paste-draft resolves on DECSET 2004 and immediately + // tears down). Iterating the live Set in that case can skip a + // watcher or — if a watcher synchronously subscribes a sibling — + // double-fire. The Set is never large (one watcher per active + // ready-wait), so the array allocation is cheap. + const snapshot = Array.from(sidecars) + for (const watcher of snapshot) { + watcher(payload.data) } - if (payload.background === true) { - meta ??= {} - meta.background = true - } - if (payload.droppedBacklog === true) { - meta ??= {} - meta.droppedBacklog = true - } - const handler = ptyDataHandlers.get(payload.id) + } + } + recordPtyDataReceived(payload.id, chars) + // Why deferred: main budgets renderer-bound output by bytes PARSED, not + // bytes received. The handler's scheduler write claims this delivery's + // credit and fires it when xterm consumes the bytes; deliveries that never + // reach the scheduler (dropped, pre-mount eager buffer) settle at return — + // a bad sidecar still cannot leave a PTY permanently backpressured. + deliverPtyDataWithDeferredAck(payload.id, chars, dispatch) +} + +function attachPtySecondaryPushListeners(unsubscribes: (() => void)[]): void { + unsubscribes.push( + window.api.pty.onReplay((payload) => { + ptyReplayHandlers.get(payload.id)?.(payload.data) + }) + ) + unsubscribes.push( + window.api.pty.onExit((payload) => { + // Why: main drops its delivery accounting for this pty on exit; drop the + // cumulative totals too so a reused id restarts at zero on both sides. + clearProcessedPtyCharTotal(payload.id) + clearReceivedPtyCharTotal(payload.id) + const handler = ptyExitHandlers.get(payload.id) if (handler) { - handler(payload.data, meta) + clearPreHandlerPtyState(payload.id) + handler(payload.code) } else { - bufferPreHandlerPtyData(payload.id, payload.data, meta) + bufferPreHandlerPtyExit(payload.id, payload.code) } - const sidecars = ptyDataSidecars.get(payload.id) + const sidecars = ptyExitSidecars.get(payload.id) if (sidecars && sidecars.size > 0) { - // Why: snapshot the Set before iterating because watchers commonly - // unsubscribe themselves on the very chunk that satisfies them - // (e.g. agent-paste-draft resolves on DECSET 2004 and immediately - // tears down). Iterating the live Set in that case can skip a - // watcher or — if a watcher synchronously subscribes a sibling — - // double-fire. The Set is never large (one watcher per active - // ready-wait), so the array allocation is cheap. const snapshot = Array.from(sidecars) - for (const watcher of snapshot) { - watcher(payload.data) + ptyExitSidecars.delete(payload.id) + for (const sidecar of snapshot) { + sidecar(payload.code) } } - } finally { - // Why: main budgets renderer-bound terminal output by bytes accepted - // into this dispatcher. ACK in finally so a bad sidecar cannot leave - // a PTY permanently backpressured. - ackPtyData(payload.id, payload.rawLength ?? payload.data.length) - } - }) - window.api.pty.onReplay((payload) => { - ptyReplayHandlers.get(payload.id)?.(payload.data) - }) - window.api.pty.onExit((payload) => { - const handler = ptyExitHandlers.get(payload.id) - if (handler) { - clearPreHandlerPtyState(payload.id) - handler(payload.code) - } else { - bufferPreHandlerPtyExit(payload.id, payload.code) - } - const sidecars = ptyExitSidecars.get(payload.id) - if (sidecars && sidecars.size > 0) { - const snapshot = Array.from(sidecars) - ptyExitSidecars.delete(payload.id) - for (const sidecar of snapshot) { - sidecar(payload.code) - } - } + }) + ) + // Why: main probes when delivery looks stuck on lost ACKs (data arriving + // for a fully gated PTY). Replying with the cumulative processed totals + // lets main reconcile verified state instead of resetting blindly. + const unsubscribeResync = window.api.pty.onDeliveryResyncRequest?.((payload) => { + window.api.pty.respondDeliveryResync?.({ + requestId: payload.requestId, + processedCharsByPty: getProcessedPtyCharTotals() + }) }) + if (unsubscribeResync) { + unsubscribes.push(unsubscribeResync) + } // Why: tell main the pty:data listener is live now. Before this fires (fresh // load or post-reload boot window) main holds all sends — bytes sent into a // listener-less page are silently dropped yet counted in-flight, which @@ -223,7 +309,6 @@ export function registerEagerPtyBuffer( onExit: (ptyId: string, code: number) => void ): EagerPtyHandle { ensurePtyDispatcher() - // Why: a head index instead of Array.shift() — shift() is O(n), making // pre-attach buffering quadratic under many small chunks. Compaction is deferred. const chunks: EagerBufferChunk[] = [] @@ -252,7 +337,7 @@ export function registerEagerPtyBuffer( // Shell died before TerminalPane attached — clean up and notify the store // so the tab's ptyId is cleared and connectPanePty falls through to connect(). // Identity-guarded like dispose(): never delete a handler a transport has - // since registered for this id. + // since registered for this id (#7894 detach/attach remount race). if (ptyDataHandlers.get(ptyId) === dataHandler) { ptyDataHandlers.delete(ptyId) ptyReplayHandlers.delete(ptyId) @@ -277,6 +362,8 @@ export function registerEagerPtyBuffer( return data }, dispose() { + // Why: dispose runs at pane attach (mount completed) — the pane's own + // visibility sync now owns the hidden-delivery decision for this PTY. // Only remove if the current handler is still the temp one (compare by // reference). After attach() replaces the handler this becomes a no-op. if (ptyDataHandlers.get(ptyId) === dataHandler) { diff --git a/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts new file mode 100644 index 00000000000..289192d13d2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.test.ts @@ -0,0 +1,86 @@ +// Why: the out-of-band pty:modelRestoreNeeded channel replaces the in-band +// empty-chunk sentinel (ambiguous with chunks fully consumed by OSC-9999 +// stripping). These tests pin the channel routing: one channel subscription, +// handlers keyed by PTY id, replace-on-reregister semantics. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +describe('pty model-restore channel routing', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + let onModelRestoreNeeded: ReturnType + let channelCallback: ((event: { id: string; reason: string; markerSeq?: number }) => void) | null + + beforeEach(() => { + vi.resetModules() + channelCallback = null + onModelRestoreNeeded = vi.fn( + (callback: (event: { id: string; reason: string; markerSeq?: number }) => void) => { + channelCallback ??= callback + return () => {} + } + ) + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + onModelRestoreNeeded + } + } + } as unknown as typeof window + }) + + afterEach(() => { + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('attaches the channel once and routes markers to the registered PTY handler', async () => { + const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel') + const handlerA = vi.fn() + const handlerB = vi.fn() + + registerPtyModelRestoreNeededHandler('pty-a', handlerA) + registerPtyModelRestoreNeededHandler('pty-b', handlerB) + expect(onModelRestoreNeeded).toHaveBeenCalledTimes(1) + + channelCallback?.({ id: 'pty-a', reason: 'hidden-drop', markerSeq: 42 }) + expect(handlerA).toHaveBeenCalledWith({ id: 'pty-a', reason: 'hidden-drop', markerSeq: 42 }) + expect(handlerB).not.toHaveBeenCalled() + + // Markers for PTYs without a registered handler are dropped silently. + channelCallback?.({ id: 'pty-unknown', reason: 'pending-cap' }) + expect(handlerA).toHaveBeenCalledTimes(1) + expect(handlerB).not.toHaveBeenCalled() + }) + + it('lets a new registration replace a stale one without the stale unregister clobbering it', async () => { + const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel') + const staleHandler = vi.fn() + const liveHandler = vi.fn() + + const unregisterStale = registerPtyModelRestoreNeededHandler('pty-a', staleHandler) + registerPtyModelRestoreNeededHandler('pty-a', liveHandler) + // Why: a reattaching pane can re-register before the old connection's + // teardown runs — the stale unregister must not remove the live handler. + unregisterStale() + + channelCallback?.({ id: 'pty-a', reason: 'unhide' }) + expect(staleHandler).not.toHaveBeenCalled() + expect(liveHandler).toHaveBeenCalledTimes(1) + }) + + it('stops routing after the live handler unregisters', async () => { + const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel') + const handler = vi.fn() + + const unregister = registerPtyModelRestoreNeededHandler('pty-a', handler) + unregister() + + channelCallback?.({ id: 'pty-a', reason: 'hidden-drop' }) + expect(handler).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts new file mode 100644 index 00000000000..350deb25422 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-model-restore-channel.ts @@ -0,0 +1,66 @@ +/** + * Singleton router for the out-of-band `pty:modelRestoreNeeded` channel + * (sibling of the pty-dispatcher's data/exit routing — split out to keep the + * dispatcher under the line limit). + * + * Why a dedicated channel + registry: the marker means "main dropped + * renderer-bound bytes (hidden gate / pending cap); restore from the model + * snapshot". It must NOT ride the transport data path — an in-band empty + * chunk is ambiguous with chunks fully consumed by OSC-9999 stripping, and + * remote-runtime transports (which never see main's gate) must stay + * structurally unaffected. + */ +import type { PtyModelRestoreNeededEvent } from '../../../../shared/pty-model-restore-marker' + +const ptyModelRestoreNeededHandlers = new Map void>() +let modelRestoreNeededChannelAttached = false + +function dispatchPtyModelRestoreNeeded(event: PtyModelRestoreNeededEvent): void { + ptyModelRestoreNeededHandlers.get(event.id)?.(event) +} + +function ensureModelRestoreNeededChannel(): void { + if (modelRestoreNeededChannelAttached) { + return + } + // Why optional-chained: unit tests and the web remote client expose a + // partial pty API; missing channel means "no markers", never a throw. + const onModelRestoreNeeded = (globalThis as { window?: Window }).window?.api?.pty + ?.onModelRestoreNeeded + if (typeof onModelRestoreNeeded !== 'function') { + return + } + modelRestoreNeededChannelAttached = true + onModelRestoreNeeded(dispatchPtyModelRestoreNeeded) +} + +/** Register the single model-restore-needed handler for a PTY (the pane + * connection that owns its view). A new registration replaces a stale one. */ +export function registerPtyModelRestoreNeededHandler( + ptyId: string, + handler: (event: PtyModelRestoreNeededEvent) => void +): () => void { + ensureModelRestoreNeededChannel() + ptyModelRestoreNeededHandlers.set(ptyId, handler) + return () => { + if (ptyModelRestoreNeededHandlers.get(ptyId) === handler) { + ptyModelRestoreNeededHandlers.delete(ptyId) + } + } +} + +/** Deliver markers fetched over invoke by the delivery watchdog. Same routing + * as the push channel — needed because a delivery-heal fires precisely when + * `pty:modelRestoreNeeded` push events cannot reach this renderer. */ +export function deliverPulledPtyModelRestoreMarkers( + events: readonly PtyModelRestoreNeededEvent[] +): void { + for (const event of events) { + dispatchPtyModelRestoreNeeded(event) + } +} + +/** Test seam: deliver a marker as if it arrived on the channel. */ +export function _dispatchPtyModelRestoreNeededForTest(event: PtyModelRestoreNeededEvent): void { + dispatchPtyModelRestoreNeeded(event) +} diff --git a/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts new file mode 100644 index 00000000000..5da2177f1b2 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + _resetPtyRendererDeliveryClaimsForTest, + acquireHiddenRendererPtyDeliveryClaim, + declareRendererPtyDeliveryVisible, + releaseRendererPtyVisibilityClaim, + setRendererPtyVisibilityClaim +} from './pty-renderer-delivery-claims' + +const PTY_ID = 'workspace@@pty-1' + +describe('renderer PTY delivery claims', () => { + const setHiddenRendererPty = vi.fn() + const setRendererPtyVisible = vi.fn() + + beforeEach(() => { + _resetPtyRendererDeliveryClaimsForTest() + setHiddenRendererPty.mockReset() + setRendererPtyVisible.mockReset() + ;(globalThis as { window: Window }).window = { + api: { pty: { setHiddenRendererPty, setRendererPtyVisible } } + } as unknown as Window + }) + + it('keeps a PTY hidden across an overlapping pane-to-watcher handoff', () => { + const releasePane = acquireHiddenRendererPtyDeliveryClaim(PTY_ID) + const releaseWatcher = acquireHiddenRendererPtyDeliveryClaim(PTY_ID) + + expect(setHiddenRendererPty).toHaveBeenCalledTimes(1) + expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, true) + + releasePane() + expect(setHiddenRendererPty).toHaveBeenCalledTimes(1) + + declareRendererPtyDeliveryVisible(PTY_ID) + expect(setHiddenRendererPty).toHaveBeenCalledTimes(1) + + releaseWatcher() + expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false) + expect(setHiddenRendererPty).toHaveBeenCalledTimes(2) + }) + + it('does not let a retiring visible pane hide its replacement', () => { + const oldPane = {} + const newPane = {} + setRendererPtyVisibilityClaim(oldPane, PTY_ID, true) + setRendererPtyVisibilityClaim(newPane, PTY_ID, true) + + expect(setRendererPtyVisible).toHaveBeenCalledTimes(1) + releaseRendererPtyVisibilityClaim(oldPane) + expect(setRendererPtyVisible).toHaveBeenCalledTimes(1) + + releaseRendererPtyVisibilityClaim(newPane) + expect(setRendererPtyVisible).toHaveBeenLastCalledWith(PTY_ID, false) + expect(setRendererPtyVisible).toHaveBeenCalledTimes(2) + }) + + it('reports a never-visible mounted pane as known hidden', () => { + setRendererPtyVisibilityClaim({}, PTY_ID, false) + expect(setRendererPtyVisible).toHaveBeenCalledWith(PTY_ID, false) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts new file mode 100644 index 00000000000..57644da8a83 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/pty-renderer-delivery-claims.ts @@ -0,0 +1,122 @@ +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' + +const hiddenClaimCounts = new Map() + +type VisibilityClaim = { ptyId: string; visible: boolean } + +const visibilityClaimsByOwner = new Map() +const visibleClaimCounts = new Map() + +function sendHiddenState(ptyId: string, hidden: boolean): void { + recordTerminalFreezeBreadcrumb(hidden ? 'renderer-gate-mark' : 'renderer-gate-unmark', { + id: redactPtyIdForDiagnostics(ptyId) + }) + ;(globalThis as { window?: Window }).window?.api?.pty?.setHiddenRendererPty?.(ptyId, hidden) +} + +function sendVisibility(ptyId: string, visible: boolean): void { + ;(globalThis as { window?: Window }).window?.api?.pty?.setRendererPtyVisible?.(ptyId, visible) +} + +/** + * Holds a hidden-delivery claim until the returned release function runs. + * Main sees only the first-acquire/last-release transitions for each PTY. + */ +export function acquireHiddenRendererPtyDeliveryClaim(ptyId: string): () => void { + const nextCount = (hiddenClaimCounts.get(ptyId) ?? 0) + 1 + hiddenClaimCounts.set(ptyId, nextCount) + if (nextCount === 1) { + sendHiddenState(ptyId, true) + } + + let released = false + return () => { + if (released) { + return + } + released = true + const currentCount = hiddenClaimCounts.get(ptyId) ?? 0 + if (currentCount <= 1) { + hiddenClaimCounts.delete(ptyId) + sendHiddenState(ptyId, false) + return + } + hiddenClaimCounts.set(ptyId, currentCount - 1) + } +} + +/** Clears stale main state for a visible PTY without overriding another live + * hidden owner that is still completing a pane-to-watcher handoff. */ +export function declareRendererPtyDeliveryVisible(ptyId: string): void { + if (!hiddenClaimCounts.has(ptyId)) { + sendHiddenState(ptyId, false) + } +} + +function removeVisibleClaim(claim: VisibilityClaim): boolean { + if (!claim.visible) { + return false + } + const currentCount = visibleClaimCounts.get(claim.ptyId) ?? 0 + if (currentCount <= 1) { + visibleClaimCounts.delete(claim.ptyId) + return true + } + visibleClaimCounts.set(claim.ptyId, currentCount - 1) + return false +} + +/** + * Reports one mounted transport's visibility. Ref-counting by owner prevents + * a retiring pane from hiding a PTY after its replacement has already bound. + */ +export function setRendererPtyVisibilityClaim( + owner: object, + ptyId: string, + visible: boolean +): void { + const previous = visibilityClaimsByOwner.get(owner) + if (previous?.ptyId === ptyId && previous.visible === visible) { + return + } + + if (previous) { + const becameHidden = removeVisibleClaim(previous) + if (becameHidden && previous.ptyId !== ptyId) { + sendVisibility(previous.ptyId, false) + } + } + + visibilityClaimsByOwner.set(owner, { ptyId, visible }) + if (visible) { + const nextCount = (visibleClaimCounts.get(ptyId) ?? 0) + 1 + visibleClaimCounts.set(ptyId, nextCount) + if (nextCount === 1) { + sendVisibility(ptyId, true) + } + return + } + + if (!visibleClaimCounts.has(ptyId)) { + sendVisibility(ptyId, false) + } +} + +export function releaseRendererPtyVisibilityClaim(owner: object): void { + const previous = visibilityClaimsByOwner.get(owner) + if (!previous) { + return + } + visibilityClaimsByOwner.delete(owner) + if (removeVisibleClaim(previous)) { + sendVisibility(previous.ptyId, false) + } +} + +/** Test seam: renderer reload naturally clears these module-scoped claims. */ +export function _resetPtyRendererDeliveryClaimsForTest(): void { + hiddenClaimCounts.clear() + visibilityClaimsByOwner.clear() + visibleClaimCounts.clear() +} diff --git a/src/renderer/src/components/terminal-pane/pty-transport-types.ts b/src/renderer/src/components/terminal-pane/pty-transport-types.ts index 61320e60f56..e5ab8c38cf3 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport-types.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport-types.ts @@ -12,16 +12,23 @@ export type PtyBufferSnapshot = { cols: number rows: number seq?: number + /** Lowest seq main could still deliver when the snapshot was taken (start + * of its pending renderer-delivery queue; equals `seq` when empty). Bytes + * are delivered once and in order, so a post-restore chunk at or below + * this seq can never be a duplicate the snapshot already covers. */ + pendingDeliveryStartSeq?: number source?: 'headless' | 'renderer' /** True when the snapshot captures an alternate-screen TUI (Claude Code, * vim). Restore must NOT clear xterm's buffer in that case — the TUI's * scrollback lives in xterm and a clear destroys scroll-up after a tab * return. Mirrors the attach-time guard in pty-transport.ts. */ alternateScreen?: boolean - /** Trailing partial escape sequence the source emulator held mid-parse when - * the snapshot was taken. The restorer writes it LAST (after the reset) so a - * racing live continuation completes it instead of rendering literally - * (#7329). */ + /** Authoritative normal buffer paired with an alternate-screen frame. */ + scrollbackAnsi?: string + /** Trailing incomplete escape sequence main's emulator ingested (a PTY read + * ended mid-escape). Must be written LAST — after post-replay resets, right + * before post-snapshot live chunks — so the continuation completes it + * exactly as live instead of rendering literal (Bug E / #7329). */ pendingEscapeTailAnsi?: string } @@ -63,6 +70,11 @@ export type PtyTransport = { cols?: number rows?: number sessionId?: string + /** Hidden-at-spawn declaration (terminal-query-authority.md): no visible + * view will consume this PTY's bytes, so main marks it hidden BEFORE the + * first byte and the gate + model responder own spawn-time queries. + * Ignored by remote-runtime transports (not gate-markable). */ + initiallyHidden?: boolean command?: string env?: Record launchConfig?: SleepingAgentLaunchConfig @@ -97,6 +109,10 @@ export type PtyTransport = { getPtyId: () => string | null getConnectionId?: () => string | null | undefined getLocalSessionMetadata?: () => LocalPtySessionMetadata | null + /** Drop cross-chunk parser carries (partial OSC-9999 prefix). Called when a + * model-restore marker reports dropped bytes — a carry spanning the gap + * would corrupt the next live chunk. IPC transports only. */ + resetCrossChunkParserState?: () => void serializeBuffer?: (opts?: { scrollbackRows?: number }) => Promise preserve?: () => void detach?: () => void diff --git a/src/renderer/src/components/terminal-pane/pty-transport.test.ts b/src/renderer/src/components/terminal-pane/pty-transport.test.ts index e68a5f9a2db..b676fd2fdfe 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.test.ts @@ -82,6 +82,37 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('leaves the transport silently unbound after a failed connect — sendInput drops with no write IPC (frozen-terminal repro)', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const spawn = window.api.pty.spawn as unknown as ReturnType + const write = window.api.pty.write as unknown as ReturnType + const transport = createIpcPtyTransport({}) + + // Generic spawn failure (e.g. daemon not ready during a startup restore): + // the error IS surfaced via onError, but the transport stays unbound and + // every later keystroke is dropped with no further signal. + spawn.mockRejectedValueOnce(new Error('daemon socket not ready')) + const onError = vi.fn() + await transport.connect({ url: '', callbacks: { onError } }) + expect(onError).toHaveBeenCalled() + expect(transport.isConnected()).toBe(false) + expect(transport.sendInput('echo hello\r')).toBe(false) + await flushPtySideEffects() + expect(write).not.toHaveBeenCalled() + + // The tombstoned-session rejection is swallowed with NO callback at all — + // a restored pane that hits it renders persisted content while eating + // keystrokes with zero user-visible signal (Discord #performance / #2836). + spawn.mockRejectedValueOnce(new Error('TerminalKilledError: session xyz was explicitly killed')) + const onErrorKilled = vi.fn() + await transport.connect({ url: '', callbacks: { onError: onErrorKilled } }) + expect(onErrorKilled).not.toHaveBeenCalled() + expect(transport.isConnected()).toBe(false) + expect(transport.sendInput('echo hello\r')).toBe(false) + await flushPtySideEffects() + expect(write).not.toHaveBeenCalled() + }) + it('ignores a stale exit for a previous PTY after reconnecting the same transport', async () => { const { createIpcPtyTransport } = await import('./pty-transport') const spawn = window.api.pty.spawn as unknown as ReturnType @@ -293,6 +324,42 @@ describe('createIpcPtyTransport', () => { transport.disconnect() }) + it('runs title side effects even when the data callback does not render the chunk', async () => { + const { createIpcPtyTransport } = await import('./pty-transport') + const onTitleChange = vi.fn() + const onDataCallback = vi.fn() + const transport = createIpcPtyTransport({ onTitleChange }) + + await transport.connect({ url: '', callbacks: { onData: onDataCallback } }) + + onData?.({ id: 'pty-1', data: '\u001b]0;hidden-title\u0007' }) + + expect(onDataCallback).toHaveBeenCalledWith('\u001b]0;hidden-title\u0007') + expect(onTitleChange).not.toHaveBeenCalled() + + await flushPtySideEffects() + + expect(onTitleChange).toHaveBeenCalledWith('hidden-title', 'hidden-title') + transport.disconnect() + }) + + it('drops the OSC-9999 cross-chunk carry on resetAgentStatusCarry', async () => { + // Why: a model-restore marker means bytes were dropped between chunks — + // a partial OSC-9999 prefix carried across that gap would swallow the + // next live chunk's head as bogus status payload. + const { createPtyOutputProcessor } = await import('./pty-transport') + const processor = createPtyOutputProcessor({}) + const callbacks = { onData: vi.fn() } + + processor.processData('\x1b]9999;', callbacks) + expect(callbacks.onData).toHaveBeenLastCalledWith('') + + processor.resetAgentStatusCarry() + processor.processData('plain output after the gap', callbacks) + + expect(callbacks.onData).toHaveBeenLastCalledWith('plain output after the gap') + }) + it('does not schedule PTY side-effect drains for ordinary output with no working title', async () => { vi.useFakeTimers() try { diff --git a/src/renderer/src/components/terminal-pane/pty-transport.ts b/src/renderer/src/components/terminal-pane/pty-transport.ts index 7df168d7611..f2c107e441b 100644 --- a/src/renderer/src/components/terminal-pane/pty-transport.ts +++ b/src/renderer/src/components/terminal-pane/pty-transport.ts @@ -26,7 +26,7 @@ import { drainPreHandlerPtyData, drainPreHandlerPtyExit } from './pty-pre-handle import { createPtyInputWriteQueue } from './pty-input-write-queue' import type { PtyDataMeta } from './pty-dispatcher' import type { IpcPtyTransportOptions, PtyConnectResult, PtyTransport } from './pty-transport-types' -import { createBellDetector } from './bell-detector' +import { createBellDetector } from '../../../../shared/terminal-bell-detector' import { hasTerminalDisplayContent, trimIncompleteTerminalControlTail @@ -79,7 +79,12 @@ type PtyOutputProcessorOptions = Pick< | 'onAgentBecameWorking' | 'onAgentExited' | 'onAgentStatus' -> +> & { + /** Seed for processors that start mid-session (parked-tab byte watchers): + * the pane's last known title, so a working agent that finishes while the + * processor owns the stream still yields a working→idle transition. */ + initialAgentTitle?: string +} type ProcessPtyOutputOptions = { replayingBufferedData?: boolean @@ -105,7 +110,8 @@ export function createPtyOutputProcessor({ onAgentBecameIdle, onAgentBecameWorking, onAgentExited, - onAgentStatus + onAgentStatus, + initialAgentTitle }: PtyOutputProcessorOptions): { processData: ( data: string, @@ -117,10 +123,18 @@ export function createPtyOutputProcessor({ clearStaleTitleTimer: () => void flushPendingSideEffects: () => void resetBellDetector: () => void + resetAgentStatusCarry: () => void } { const bellDetector = createBellDetector() - const processAgentStatusChunk = createAgentStatusOscProcessor() - let lastEmittedTitle: string | null = null + // Why `let`: a model-restore marker means bytes were dropped between + // chunks; a partial OSC-9999 prefix carried across that gap would swallow + // the next live chunk's head as bogus payload. Reset recreates the parser. + let processAgentStatusChunk = createAgentStatusOscProcessor() + // Why: seed both the emitted-title memory (stale-title probe) and the agent + // tracker so a mid-session processor behaves as if it had observed the + // pane's last live title — full parity with the live path it replaces. + let lastEmittedTitle: string | null = + initialAgentTitle !== undefined ? normalizeTerminalTitle(initialAgentTitle) : null let staleTitleTimer: ReturnType | null = null let sideEffectDrainTimer: ReturnType | null = null let pendingSideEffects: PendingPtySideEffect[] = [] @@ -133,7 +147,8 @@ export function createPtyOutputProcessor({ onAgentBecameIdle?.(title) }, onAgentBecameWorking, - onAgentExited + onAgentExited, + initialAgentTitle ) : null @@ -444,7 +459,10 @@ export function createPtyOutputProcessor({ clearAccumulatedState, clearStaleTitleTimer, flushPendingSideEffects, - resetBellDetector: () => bellDetector.reset() + resetBellDetector: () => bellDetector.reset(), + resetAgentStatusCarry: () => { + processAgentStatusChunk = createAgentStatusOscProcessor() + } } } @@ -673,6 +691,10 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra : {}), ...(connectionId ? { connectionId } : {}), ...(options.sessionId ? { sessionId: options.sessionId } : {}), + // Why: hidden-at-spawn mark must land in main before the PTY's + // first byte, so it rides the spawn IPC instead of the pane's + // first visibility sync (terminal-query-authority.md). + ...(options.initiallyHidden ? { initiallyHidden: true } : {}), worktreeId, ...(tabId ? { tabId } : {}), ...(leafId ? { leafId } : {}), @@ -959,6 +981,13 @@ export function createIpcPtyTransport(opts: IpcPtyTransportOptions = {}): PtyTra } }, + resetCrossChunkParserState() { + // Why: only the OSC-9999 carry spans the dropped-byte gap a + // model-restore marker reports; title/bell trackers re-sync from the + // snapshot's side-effect replay and must not be reset here. + outputProcessor.resetAgentStatusCarry() + }, + destroy() { destroyed = true this.disconnect() diff --git a/src/renderer/src/components/terminal-pane/replay-guard.test.ts b/src/renderer/src/components/terminal-pane/replay-guard.test.ts index f870f518c0a..c26acbfca7c 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { isPaneReplaying, @@ -7,6 +7,22 @@ import { type ReplayingPanesRef } from './replay-guard' +const mocks = vi.hoisted(() => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb +})) + +beforeEach(() => { + mocks.recordRendererCrashBreadcrumb.mockClear() +}) + +afterEach(() => { + vi.useRealTimers() +}) + function makeRef(): ReplayingPanesRef { return { current: new Map() } as ReplayingPanesRef } @@ -142,21 +158,23 @@ describe('replay-guard', () => { }) it('auto-releases the guard when xterm never fires the parse callback', () => { - // Repro of the cold-restore reattach lockout: handleReattachResult replays - // three chunks into a just-mounted / offscreen pane whose terminal never - // flushes, so xterm's parse callback never runs and the counter would stay - // pinned at 3 — isPaneReplaying() stuck true drops EVERY keystroke. The - // fallback must release the guard so input is not swallowed forever. + // Repro of the cold-restore reattach lockout (main #7661): + // handleReattachResult replays three chunks into a just-mounted / + // offscreen pane whose terminal never flushes, so xterm's parse callback + // never runs and the counter would stay pinned at 3 — isPaneReplaying() + // stuck true drops EVERY keystroke. The probe-certified stall path (probe + // never parses either => wedged release) must free the guard. vi.useFakeTimers() try { const ref = makeRef() const { pane } = makeFakePane(1) - replayIntoTerminal(pane, ref, '\x1b[2J\x1b[3J\x1b[H') - replayIntoTerminal(pane, ref, 'scrollback bytes') - replayIntoTerminal(pane, ref, '--- session restored ---') + replayIntoTerminal(pane, ref, '\x1b[2J\x1b[3J\x1b[H', 400) + replayIntoTerminal(pane, ref, 'scrollback bytes', 400) + replayIntoTerminal(pane, ref, '--- session restored ---', 400) expect(isPaneReplaying(ref, 1)).toBe(true) - // Never flush — simulate the missing parse callback for an unflushed pane. + // Never flush — the probe write never parses; the wedged release fires + // one stall window after the probe (400 + 400). vi.advanceTimersByTime(1000) expect(isPaneReplaying(ref, 1)).toBe(false) @@ -166,18 +184,18 @@ describe('replay-guard', () => { } }) - it('parse completion cancels the fallback without over-releasing', () => { + it('parse completion cancels the stall probe without over-releasing', () => { vi.useFakeTimers() try { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'a') - replayIntoTerminal(pane, ref, 'b') + replayIntoTerminal(pane, ref, 'a', 400) + replayIntoTerminal(pane, ref, 'b', 400) terminal.flush() expect(isPaneReplaying(ref, 1)).toBe(false) - // The already-cancelled fallback must not fire and underflow the counter. + // The already-cancelled stall timer must not fire and underflow the counter. vi.advanceTimersByTime(1000) expect(isPaneReplaying(ref, 1)).toBe(false) expect(ref.current.has(1)).toBe(false) @@ -192,7 +210,7 @@ describe('replay-guard', () => { const ref = makeRef() const { pane } = makeFakePane(1) let resolved = false - const promise = replayIntoTerminalAsync(pane, ref, 'x').then(() => { + const promise = replayIntoTerminalAsync(pane, ref, 'x', 400).then(() => { resolved = true }) expect(isPaneReplaying(ref, 1)).toBe(true) @@ -240,3 +258,164 @@ describe('replay-guard', () => { } }) }) + +describe('replay-guard stall handling (probe-certified release)', () => { + it('HOLDS the guard while a slow replay is still parsing — a probe is queued, never a blind release', () => { + // Why this is the load-bearing safety test: a time-based release here + // would leak xterm auto-replies into the shell (and a leaked ESC into an + // agent TUI reads as the user pressing Escape). The guard must only + // release when the pipeline itself proves the replay parsed. + vi.useFakeTimers() + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'slow but alive', 1_000) + expect(isPaneReplaying(ref, 1)).toBe(true) + + // Stall check fires: an empty probe write is enqueued behind the replay. + vi.advanceTimersByTime(1_000) + expect(terminal.lastData).toEqual(['slow but alive', '']) + // Probe is pending → replay genuinely still parsing → guard holds. + expect(isPaneReplaying(ref, 1)).toBe(true) + vi.advanceTimersByTime(999) + expect(isPaneReplaying(ref, 1)).toBe(true) + + // Parsing finishes: FIFO runs the replay completion first (normal + // release), then the probe completion as a no-op. + terminal.flush() + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(ref.current.has(1)).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + + vi.advanceTimersByTime(120_000) + expect(ref.current.has(1)).toBe(false) + }) + + it('releases when the probe parses but the replay completion was lost, and reports it', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + terminal.pendingCallbacks.shift() // xterm lost the replay's completion + vi.advanceTimersByTime(1_000) // stall check → probe enqueued + + // The probe's completion firing certifies every earlier replay byte + // parsed — releasing now cannot leak auto-replies. + terminal.flush() + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_replay_guard_lost_completion', + { paneId: 1 } + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('releases after the probe itself never parses (wedged pipeline) and reports it', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + vi.advanceTimersByTime(1_000) // stall check → probe enqueued + expect(isPaneReplaying(ref, 1)).toBe(true) + + // A wedged parser will never run the probe callback — and can never + // emit auto-replies either, so this bounded release cannot leak input. + vi.advanceTimersByTime(1_000) + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_replay_guard_wedged_release', + { paneId: 1 } + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('releases immediately when the probe write throws (terminal disposed mid-replay)', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + terminal.write = () => { + throw new Error('terminal disposed') + } + vi.advanceTimersByTime(1_000) + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_replay_guard_wedged_release', + { paneId: 1 } + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('keeps overlapping engagements independent through a lost completion', () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'lost completion', 1_000) + replayIntoTerminal(pane, ref, 'healthy completion', 60_000) + terminal.pendingCallbacks.shift() // drop only the first completion + vi.advanceTimersByTime(1_000) // first engagement's probe enqueued + + terminal.flush() // healthy completion + probe both parse + expect(isPaneReplaying(ref, 1)).toBe(false) + expect(ref.current.has(1)).toBe(false) + + vi.advanceTimersByTime(120_000) + expect(ref.current.has(1)).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) + + it('never probes after a normal completion', () => { + vi.useFakeTimers() + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + + replayIntoTerminal(pane, ref, 'healthy', 1_000) + terminal.flush() + expect(isPaneReplaying(ref, 1)).toBe(false) + + vi.advanceTimersByTime(60_000) + expect(terminal.lastData).toEqual(['healthy']) + expect(mocks.recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + }) + + it('resolves replayIntoTerminalAsync via the wedged path so restore chains cannot hang', async () => { + vi.useFakeTimers() + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const ref = makeRef() + const { pane } = makeFakePane(1) + + const replayDone = replayIntoTerminalAsync(pane, ref, 'restored bytes', 1_000) + let resolved = false + void replayDone.then(() => { + resolved = true + }) + + await vi.advanceTimersByTimeAsync(2_000) + expect(resolved).toBe(true) + expect(isPaneReplaying(ref, 1)).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts index e6c733d668c..6bf821ea62d 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -1,5 +1,6 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { writeForegroundTerminalChunk } from '@/lib/pane-manager/pane-terminal-foreground-render-settle' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' // Why: xterm.js auto-responds to terminal query sequences (DA1 `CSI c`, // DECRQM `CSI ? Ps $ p`, OSC 10/11 color queries, focus events, CPR) by @@ -27,55 +28,98 @@ import { writeForegroundTerminalChunk } from '@/lib/pane-manager/pane-terminal-f export type ReplayingPanesRef = React.RefObject> -// Why: the guard normally releases in xterm's write-completion callback, but -// that callback never fires for a pane whose terminal has not been flushed — -// e.g. a cold-restore reattach that replays into a just-mounted / offscreen -// pane. Without a ceiling the counter leaks, isPaneReplaying() stays true, and -// the onData handler silently drops EVERY keystroke (the pane looks alive but -// ignores input). Release deterministically after this bound so the guard -// always clears; in the normal path onParsed fires within milliseconds and -// cancels it first. Tradeoff: if the callback is lost and the pane parses the -// replayed buffer only after this bound elapses (e.g. rendering resumes long -// after restore), xterm's auto-replies to any device queries in that buffer can -// leak to the shell as input. Accepted as strictly preferable to a permanent -// input lockout, and bounded by the ~100 KB replay cap. -const REPLAY_GUARD_RELEASE_FALLBACK_MS = 1000 +// Why stall handling exists: the decrement above only runs when xterm +// completes the write. A wedged WriteBuffer (sync throw escaping a parse +// handler or a write-completion callback — see +// xterm-write-buffer-stall.repro.test.ts) or a disposed-terminal race can +// drop that completion forever, leaving the guard latched on a live pane — +// which silently eats every keystroke (Discord #performance / issue #2836). +// +// Why release is probe-certified, never time-based: a blind timeout release +// while a slow replay is still parsing would let xterm's auto-replies leak +// into the shell — and into agent TUIs, where a leaked ESC reads as the user +// pressing Escape. Instead, when a completion looks overdue we enqueue an +// empty probe write. xterm parses writes in order, so only three states are +// possible, and release is provably safe in every state that releases: +// 1. probe completes, replay callback already ran → normal release won. +// 2. probe completes, replay callback never ran → every replay byte has +// parsed (FIFO), so no further auto-replies can exist; the completion +// was genuinely lost. Release. +// 3. probe never completes → the pipeline is +// wedged; a dead parser can never emit auto-replies, so releasing after +// a bounded wait cannot leak anything — and the pane needs recovery, +// which the breadcrumb reports. +// While the probe is pending (slow-but-alive replay), the guard HOLDS. +const REPLAY_GUARD_STALL_CHECK_MS = 10_000 export function isPaneReplaying(ref: ReplayingPanesRef, paneId: number): boolean { return (ref.current.get(paneId) ?? 0) > 0 } -/** Engage the per-pane replay guard and return a `finish` callback that - * releases it exactly once. The guard also auto-releases after - * REPLAY_GUARD_RELEASE_FALLBACK_MS so a missing xterm parse callback can never - * strand it engaged. `onReleased` runs once when the guard actually releases - * (via `finish` or the fallback), so async callers can settle either way. */ +type ReplayGuardWriteTarget = Pick + +/** + * Engage the replay counter for one write and return the release function. + * Release runs exactly once — from xterm's write completion or, failing + * that, from the probe-certified stall path — so a lost completion cannot + * latch the guard. + */ function engageReplayGuard( - replayingPanesRef: ReplayingPanesRef, + map: Map, paneId: number, - onReleased?: () => void + terminal: ReplayGuardWriteTarget, + stallCheckMs: number, + onRelease?: () => void ): () => void { - const map = replayingPanesRef.current map.set(paneId, (map.get(paneId) ?? 0) + 1) let released = false - const release = (): void => { + let timer: ReturnType | null = null + const release = (reason: 'parsed' | 'lost-completion' | 'wedged'): void => { if (released) { return } released = true + if (timer !== null) { + clearTimeout(timer) + timer = null + } const remaining = (map.get(paneId) ?? 1) - 1 if (remaining <= 0) { map.delete(paneId) } else { map.set(paneId, remaining) } - onReleased?.() + if (reason === 'lost-completion') { + console.error( + `[terminal] replay guard released for pane ${paneId} — the probe write parsed but the replay completion never arrived (lost write callback)` + ) + recordRendererCrashBreadcrumb('terminal_replay_guard_lost_completion', { paneId }) + } else if (reason === 'wedged') { + console.error( + `[terminal] replay guard released for pane ${paneId} — the probe write never parsed (wedged xterm write pipeline; pane likely needs recovery)` + ) + recordRendererCrashBreadcrumb('terminal_replay_guard_wedged_release', { paneId }) + } + onRelease?.() } - const fallback = setTimeout(release, REPLAY_GUARD_RELEASE_FALLBACK_MS) - return () => { - clearTimeout(fallback) - release() + const probeForStall = (): void => { + if (released) { + return + } + try { + // FIFO certification: this callback can only run after every replay + // byte queued before it has parsed (state 2 above). + terminal.write('', () => release('lost-completion')) + } catch { + // write threw (terminal disposed mid-replay): nothing will ever parse, + // so no auto-replies can leak. + release('wedged') + return + } + timer = setTimeout(() => release('wedged'), stallCheckMs) } + timer = setTimeout(probeForStall, stallCheckMs) + return () => release('parsed') } /** Writes `data` into the pane's terminal with the replay guard engaged, @@ -85,38 +129,50 @@ function engageReplayGuard( export function replayIntoTerminal( pane: ManagedPane, replayingPanesRef: ReplayingPanesRef, - data: string + data: string, + stallCheckMs: number = REPLAY_GUARD_STALL_CHECK_MS ): void { if (!data) { return } - const finishReplay = engageReplayGuard(replayingPanesRef, pane.id) + const releaseParsed = engageReplayGuard( + replayingPanesRef.current, + pane.id, + pane.terminal, + stallCheckMs + ) // Why: hidden/snapshot replay bypasses the live foreground write path, but // WebGL/canvas renderers still need a post-parse repaint to drop stale cells. writeForegroundTerminalChunk(pane.terminal, data, { forceViewportRefresh: true, followupViewportRefresh: true, - onParsed: finishReplay + onParsed: releaseParsed }) } export function replayIntoTerminalAsync( pane: ManagedPane, replayingPanesRef: ReplayingPanesRef, - data: string + data: string, + stallCheckMs: number = REPLAY_GUARD_STALL_CHECK_MS ): Promise { if (!data) { return Promise.resolve() } return new Promise((resolve) => { - // Why: settle the promise when the guard releases — via parse completion or - // the fallback — so an awaiting caller never hangs if xterm's callback for a - // just-mounted/offscreen pane never arrives. - const finishReplay = engageReplayGuard(replayingPanesRef, pane.id, resolve) + // Why resolve on either release path: callers await this to sequence + // restore steps; a lost write completion must not hang the restore chain. + const releaseParsed = engageReplayGuard( + replayingPanesRef.current, + pane.id, + pane.terminal, + stallCheckMs, + resolve + ) writeForegroundTerminalChunk(pane.terminal, data, { forceViewportRefresh: true, followupViewportRefresh: true, - onParsed: finishReplay + onParsed: releaseParsed }) }) } diff --git a/src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts b/src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts new file mode 100644 index 00000000000..7007995bd0d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/stale-document-visibility.test.ts @@ -0,0 +1,157 @@ +// Why: pins the stale-occlusion proof — user input while +// document.visibilityState claims 'hidden' must latch the override and run +// each pane's recovery exactly once, and a genuine visibilitychange must hand +// authority back to the occlusion tracker. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type * as StaleDocumentVisibility from './stale-document-visibility' + +type Handler = () => void + +type EventTargetStub = { + addEventListener: (type: string, handler: Handler, options?: unknown) => void + removeEventListener: (type: string, handler: Handler, options?: unknown) => void + emit: (type: string) => void + listenerCount: (type: string) => number +} + +function createEventTargetStub(): EventTargetStub { + const listeners = new Map>() + return { + addEventListener(type, handler) { + const set = listeners.get(type) ?? new Set() + set.add(handler) + listeners.set(type, set) + }, + removeEventListener(type, handler) { + listeners.get(type)?.delete(handler) + }, + emit(type) { + for (const handler of listeners.get(type) ?? []) { + handler() + } + }, + listenerCount(type) { + return listeners.get(type)?.size ?? 0 + } + } +} + +type DocumentStub = EventTargetStub & { visibilityState: string } + +describe('stale document visibility', () => { + const originalDocument = (globalThis as { document?: unknown }).document + const originalWindow = (globalThis as { window?: unknown }).window + let documentStub: DocumentStub + let windowStub: EventTargetStub + let warnSpy: ReturnType + let mod: typeof StaleDocumentVisibility + + beforeEach(async () => { + vi.resetModules() + documentStub = { ...createEventTargetStub(), visibilityState: 'visible' } + windowStub = createEventTargetStub() + ;(globalThis as { document: unknown }).document = documentStub + ;(globalThis as { window: unknown }).window = windowStub + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + mod = await import('./stale-document-visibility') + }) + + afterEach(() => { + mod.resetStaleDocumentVisibilityForTesting() + warnSpy.mockRestore() + ;(globalThis as { document: unknown }).document = originalDocument + ;(globalThis as { window: unknown }).window = originalWindow + }) + + it('latches the override and runs recovery once when input arrives while hidden', () => { + const recovery = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(recovery) + documentStub.visibilityState = 'hidden' + + documentStub.emit('keydown') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + expect(recovery).toHaveBeenCalledTimes(1) + expect(warnSpy).toHaveBeenCalledTimes(1) + + // Further input during the same stuck episode must not re-run recovery. + documentStub.emit('keydown') + documentStub.emit('pointerdown') + expect(recovery).toHaveBeenCalledTimes(1) + }) + + it('does not latch while the document is genuinely visible', () => { + const recovery = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(recovery) + + documentStub.emit('keydown') + documentStub.emit('pointerdown') + windowStub.emit('focus') + expect(mod.isDocumentVisibilityProvenStale()).toBe(false) + expect(recovery).not.toHaveBeenCalled() + }) + + it('treats pointerdown and window focus as staleness proof too', () => { + const recovery = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(recovery) + documentStub.visibilityState = 'hidden' + + documentStub.emit('pointerdown') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + + mod.resetStaleDocumentVisibilityForTesting() + mod.registerStaleDocumentVisibilityRecovery(recovery) + documentStub.visibilityState = 'hidden' + windowStub.emit('focus') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + }) + + it('hands authority back to the occlusion tracker on a genuine visibilitychange', () => { + const recovery = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(recovery) + documentStub.visibilityState = 'hidden' + documentStub.emit('keydown') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + + documentStub.visibilityState = 'visible' + documentStub.emit('visibilitychange') + expect(mod.isDocumentVisibilityProvenStale()).toBe(false) + + // A fresh wedge after trust was restored must latch (and recover) again. + documentStub.visibilityState = 'hidden' + documentStub.emit('keydown') + expect(mod.isDocumentVisibilityProvenStale()).toBe(true) + expect(recovery).toHaveBeenCalledTimes(2) + }) + + it('keeps running other panes when one recovery listener throws', () => { + const throwing = vi.fn(() => { + throw new Error('pane exploded') + }) + const healthy = vi.fn() + mod.registerStaleDocumentVisibilityRecovery(throwing) + mod.registerStaleDocumentVisibilityRecovery(healthy) + documentStub.visibilityState = 'hidden' + + documentStub.emit('keydown') + expect(throwing).toHaveBeenCalledTimes(1) + expect(healthy).toHaveBeenCalledTimes(1) + }) + + it('removes global listeners when the last pane unregisters', () => { + const unregisterA = mod.registerStaleDocumentVisibilityRecovery(vi.fn()) + const unregisterB = mod.registerStaleDocumentVisibilityRecovery(vi.fn()) + expect(documentStub.listenerCount('keydown')).toBe(1) + + unregisterA() + expect(documentStub.listenerCount('keydown')).toBe(1) + unregisterB() + expect(documentStub.listenerCount('keydown')).toBe(0) + expect(documentStub.listenerCount('pointerdown')).toBe(0) + expect(documentStub.listenerCount('visibilitychange')).toBe(0) + expect(windowStub.listenerCount('focus')).toBe(0) + + // Re-registering must reinstall for a later pane. + mod.registerStaleDocumentVisibilityRecovery(vi.fn()) + expect(documentStub.listenerCount('keydown')).toBe(1) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/stale-document-visibility.ts b/src/renderer/src/components/terminal-pane/stale-document-visibility.ts new file mode 100644 index 00000000000..8d54cbe94c6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/stale-document-visibility.ts @@ -0,0 +1,109 @@ +// Chromium's macOS window-occlusion tracker can wedge document.visibilityState +// at 'hidden' after display sleep, never firing another visibilitychange. Real +// user input reaching this window while the document claims hidden is a +// physical contradiction — keystrokes and clicks only reach a focused, +// on-screen window. This module latches that proof so terminal delivery can +// treat the document as visible until the next genuine visibilitychange +// restores trust in the occlusion tracker. Without it, the hidden-delivery +// gate stays latched for panes the user is looking at and main drops their +// bytes indefinitely (field: 78MB dropped on 2 visible ptys, v1.4.124-rc.2.perf). + +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' + +type StaleVisibilityRecoveryListener = () => void + +const recoveryListeners = new Set() +let visibilityProvenStale = false +let globalListenersInstalled = false + +export function isDocumentVisibilityProvenStale(): boolean { + return visibilityProvenStale +} + +function onUserInteractionWithDocument(): void { + if (visibilityProvenStale || document.visibilityState !== 'hidden') { + return + } + visibilityProvenStale = true + recordTerminalFreezeBreadcrumb('stale-visibility-latch', { + recoveryListenerCount: recoveryListeners.size + }) + console.warn( + '[terminal] user input arrived while document.visibilityState is hidden — treating occlusion state as stale and re-syncing terminal delivery', + { recoveryListenerCount: recoveryListeners.size } + ) + for (const listener of recoveryListeners) { + try { + listener() + } catch { + // Why: one pane's recovery failure must not starve the other panes'. + } + } +} + +function onDocumentVisibilityChange(): void { + recordTerminalFreezeBreadcrumb('visibilitychange', { + state: document.visibilityState, + clearedStaleOverride: visibilityProvenStale + }) + // A genuine visibilitychange means the occlusion tracker is reporting + // again — hand authority back to document.visibilityState. + visibilityProvenStale = false +} + +function installGlobalListeners(): void { + if ( + globalListenersInstalled || + typeof document === 'undefined' || + typeof window === 'undefined' || + typeof document.addEventListener !== 'function' + ) { + return + } + globalListenersInstalled = true + // Capture phase so no stopPropagation in the app can hide the proof; the + // handler is a single property read when visibility is healthy. + document.addEventListener('keydown', onUserInteractionWithDocument, { + capture: true, + passive: true + }) + document.addEventListener('pointerdown', onUserInteractionWithDocument, { + capture: true, + passive: true + }) + window.addEventListener('focus', onUserInteractionWithDocument) + document.addEventListener('visibilitychange', onDocumentVisibilityChange) +} + +function removeGlobalListeners(): void { + if (!globalListenersInstalled) { + return + } + globalListenersInstalled = false + document.removeEventListener('keydown', onUserInteractionWithDocument, { capture: true }) + document.removeEventListener('pointerdown', onUserInteractionWithDocument, { capture: true }) + window.removeEventListener('focus', onUserInteractionWithDocument) + document.removeEventListener('visibilitychange', onDocumentVisibilityChange) +} + +// The listener runs when staleness is first proven; register the same handler +// used for document visibilitychange so recovery reuses the pane's existing +// gate-resync + hidden-output-restore path. +export function registerStaleDocumentVisibilityRecovery( + listener: StaleVisibilityRecoveryListener +): () => void { + installGlobalListeners() + recoveryListeners.add(listener) + return () => { + recoveryListeners.delete(listener) + if (recoveryListeners.size === 0) { + removeGlobalListeners() + } + } +} + +export function resetStaleDocumentVisibilityForTesting(): void { + visibilityProvenStale = false + recoveryListeners.clear() + removeGlobalListeners() +} diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts index 863d4cc7725..b7e088ff4aa 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.test.ts @@ -1,13 +1,18 @@ import { describe, expect, it, vi } from 'vitest' import { Terminal } from '@xterm/headless' -import type { ManagedPane } from '@/lib/pane-manager/pane-manager' +import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' +import { getDefaultSettings } from '../../../../shared/constants' import { + applyTerminalAppearance, hexToRgba, installMode2031Handlers, maybePushMode2031Flip, - mode2031SequenceFor + mode2031SequenceFor, + publishTerminalViewAttributesAtAppStart } from './terminal-appearance' import { replayIntoTerminal, type ReplayingPanesRef } from './replay-guard' +import { _resetTerminalViewAttributesPublisherForTest } from './terminal-view-attributes-publisher' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' function fakeTransport(overrides?: { connected?: boolean; sendOk?: boolean }): { isConnected: () => boolean @@ -373,6 +378,133 @@ describe('installMode2031Handlers', () => { }) }) +describe('applyTerminalAppearance theme assignment', () => { + // xterm's OptionsService fires the theme change on object IDENTITY, and + // ThemeService._setTheme then rebuilds the palette, discarding OSC + // 4/10/11/12 SET mutations. Attribute-neutral applies (font size, padding, + // zoom) compose a fresh-but-value-identical theme; assigning it anyway + // wipes TUI color mutations on visible panes while the deduped publisher + // keeps hidden overlays — so the assignment must be value-gated. + function makePane(id: number): ManagedPane { + return { id, terminal: { options: {}, cols: 80, rows: 24 } } as unknown as ManagedPane + } + + function makeManager(panes: ManagedPane[]): PaneManager { + return { + getPanes: () => panes, + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + } + + function apply(pane: ManagedPane, settings: ReturnType): void { + applyTerminalAppearance( + makeManager([pane]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + } + + it('keeps options.theme identity across attribute-neutral applies (font size tweak)', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, settings) + const firstTheme = pane.terminal.options.theme + expect(firstTheme).toBeDefined() + + apply(pane, { ...settings, terminalFontSize: settings.terminalFontSize + 2 }) + + // Identity-stable theme means xterm never re-runs _setTheme, so a TUI's + // modifyColors mutation survives the font tweak. + expect(pane.terminal.options.theme).toBe(firstTheme) + expect(pane.terminal.options.fontSize).toBe(settings.terminalFontSize + 2) + }) + + it('still assigns a fresh theme when composed values actually change', () => { + const pane = makePane(1) + const settings = getDefaultSettings('/tmp') + + apply(pane, settings) + const firstTheme = pane.terminal.options.theme + + apply(pane, { ...settings, terminalColorOverrides: { background: '#102030' } }) + + expect(pane.terminal.options.theme).not.toBe(firstTheme) + expect(pane.terminal.options.theme?.background).toBe('#102030') + }) +}) + +describe('publishTerminalViewAttributesAtAppStart', () => { + // Phase 6 prerequisite (terminal-query-authority.md): hidden-at-launch + // PTYs can query OSC 10/11 before any terminal pane mounts; the app-start + // publication must go out with no pane manager involved at all. + it('publishes composed attributes without any pane mount and dedupes repeats', () => { + _resetTerminalViewAttributesPublisherForTest() + const sent: TerminalViewAttributes[] = [] + const send = (attributes: TerminalViewAttributes): boolean => { + sent.push(attributes) + return true + } + const settings = getDefaultSettings('/tmp') + + expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(true) + expect(sent).toHaveLength(1) + expect(sent[0]!.ansi).toHaveLength(256) + expect(sent[0]!.cursorStyle).toBe(settings.terminalCursorStyle ?? 'block') + + expect(publishTerminalViewAttributesAtAppStart(settings, true, send)).toBe(false) + expect(sent).toHaveLength(1) + }) + + it('makes the later pane-mount applyTerminalAppearance a deduped no-op re-push', () => { + _resetTerminalViewAttributesPublisherForTest() + const publishMock = vi.fn() + ;(globalThis as unknown as { window: unknown }).window = { + api: { pty: { publishTerminalViewAttributes: publishMock } } + } + try { + const settings = getDefaultSettings('/tmp') + publishTerminalViewAttributesAtAppStart(settings, true) + expect(publishMock).toHaveBeenCalledTimes(1) + + // The first pane mount composes the identical app-global snapshot, so + // the publisher dedupe keeps it a single push. + const manager = { + getPanes: () => [], + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + applyTerminalAppearance( + manager, + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publishMock).toHaveBeenCalledTimes(1) + } finally { + delete (globalThis as { window?: unknown }).window + _resetTerminalViewAttributesPublisherForTest() + } + }) + + it('publishes nothing before settings are loaded', () => { + _resetTerminalViewAttributesPublisherForTest() + const send = vi.fn(() => true) + expect(publishTerminalViewAttributesAtAppStart(null, true, send)).toBe(false) + expect(send).not.toHaveBeenCalled() + }) +}) + describe('hexToRgba', () => { it('converts 6-char hex to rgba', () => { expect(hexToRgba('#1a1a1a', 0.72)).toBe('rgba(26, 26, 26, 0.72)') diff --git a/src/renderer/src/components/terminal-pane/terminal-appearance.ts b/src/renderer/src/components/terminal-pane/terminal-appearance.ts index 2df2f261506..a34ac215e66 100644 --- a/src/renderer/src/components/terminal-pane/terminal-appearance.ts +++ b/src/renderer/src/components/terminal-pane/terminal-appearance.ts @@ -10,6 +10,7 @@ import { resolveEffectiveTerminalAppearance } from '@/lib/terminal-theme' import { buildFontFamily } from './layout-serialization' +import { guardParserHandler } from './terminal-parser-handler-guard' import { captureScrollState, restoreScrollState, safeFit } from '@/lib/pane-manager/pane-tree-ops' import { normalizeTerminalFastScrollSensitivity, @@ -20,6 +21,8 @@ import { getFitOverrideForPty } from '@/lib/pane-manager/mobile-fit-overrides' import type { PtyTransport } from './pty-transport' import type { EffectiveMacOptionAsAlt } from '@/lib/keyboard-layout/detect-option-as-alt' import { HEX_COLOR_RE } from '../../../../shared/color-validation' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { publishTerminalViewAttributes } from './terminal-view-attributes-publisher' import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' export { mode2031SequenceFor } @@ -56,50 +59,56 @@ export function installMode2031Handlers(deps: Mode2031HandlerDeps): IDisposable[ // continue processing the same sequence, so compound sequences like // `CSI ?25;2031h` still update cursor visibility correctly. return [ - deps.parser.registerCsiHandler({ prefix: '?', final: 'h' }, (params) => { - if (hasMode2031(params)) { - // Why: a restored xterm buffer may contain `CSI ?2031h` emitted by - // the previous session's TUI (e.g. Claude Code). Replaying that - // buffer runs this handler, and without the guard we'd push - // `CSI ?997;1n` via transport.sendInput into a fresh shell that has - // no TUI consuming it — zsh then echoes the literal escape sequence - // onto the prompt. The replay guard in pty-connection.ts only covers - // xterm's own onData auto-replies, not handler-triggered sends, so - // gate explicitly here. We also skip recording the subscribe bit: - // the fresh shell is not actually subscribed, so a later theme flip - // must not push either. A real TUI that starts up after restore will - // re-emit `?2031h` itself and register normally. - // - // Why this broad guard is safe across all replay sources: the only - // replay path that can carry raw `?2031h` is cold-restore scrollback - // (pty-connection.ts), which is disk-replayed PTY output against a - // fresh shell — the case this guard targets. Daemon snapshot payloads - // (`rehydrateSequences + SerializeAddon.serialize()`) and persisted - // scrollback (`SerializeAddon.serialize()`) never contain `?2031`: - // SerializeAddon's _serializeModes whitelists only ?1h/?66h/?2004h/ - // [4h/?6h/?45h/?1004h/?7l/mouse modes/?25l, and buildRehydrateSequences - // emits only ?1049h/?2004h/?1h/mouse reporting modes. If xterm ever - // adds ?2031 to that whitelist, this guard would start suppressing - // legitimate subscribes during snapshot reattach — revisit then. - if (deps.isReplaying()) { - return false + deps.parser.registerCsiHandler( + { prefix: '?', final: 'h' }, + guardParserHandler('csi-mode2031-subscribe', (params) => { + if (hasMode2031(params)) { + // Why: a restored xterm buffer may contain `CSI ?2031h` emitted by + // the previous session's TUI (e.g. Claude Code). Replaying that + // buffer runs this handler, and without the guard we'd push + // `CSI ?997;1n` via transport.sendInput into a fresh shell that has + // no TUI consuming it — zsh then echoes the literal escape sequence + // onto the prompt. The replay guard in pty-connection.ts only covers + // xterm's own onData auto-replies, not handler-triggered sends, so + // gate explicitly here. We also skip recording the subscribe bit: + // the fresh shell is not actually subscribed, so a later theme flip + // must not push either. A real TUI that starts up after restore will + // re-emit `?2031h` itself and register normally. + // + // Why this broad guard is safe across all replay sources: the only + // replay path that can carry raw `?2031h` is cold-restore scrollback + // (pty-connection.ts), which is disk-replayed PTY output against a + // fresh shell — the case this guard targets. Daemon snapshot payloads + // (`rehydrateSequences + SerializeAddon.serialize()`) and persisted + // scrollback (`SerializeAddon.serialize()`) never contain `?2031`: + // SerializeAddon's _serializeModes whitelists only ?1h/?66h/?2004h/ + // [4h/?6h/?45h/?1004h/?7l/mouse modes/?25l, and buildRehydrateSequences + // emits only ?1049h/?2004h/?1h/mouse reporting modes. If xterm ever + // adds ?2031 to that whitelist, this guard would start suppressing + // legitimate subscribes during snapshot reattach — revisit then. + if (deps.isReplaying()) { + return false + } + deps.paneMode2031.set(deps.paneId, true) + deps.onSubscribe() } - deps.paneMode2031.set(deps.paneId, true) - deps.onSubscribe() - } - return false - }), + return false + }) + ), // Why no replay guard on the unsubscribe branch: clearing stale bookkeeping // is harmless. We only push CSI 997 on subscribe, never on unsubscribe, so // even if a cold-restore replay carries `?2031l`, this handler just deletes // map entries that a later real `?2031h` will re-populate normally. - deps.parser.registerCsiHandler({ prefix: '?', final: 'l' }, (params) => { - if (hasMode2031(params)) { - deps.paneMode2031.delete(deps.paneId) - deps.paneLastThemeMode.delete(deps.paneId) - } - return false - }) + deps.parser.registerCsiHandler( + { prefix: '?', final: 'l' }, + guardParserHandler('csi-mode2031-unsubscribe', (params) => { + if (hasMode2031(params)) { + deps.paneMode2031.delete(deps.paneId) + deps.paneLastThemeMode.delete(deps.paneId) + } + return false + }) + ) ] } @@ -200,6 +209,54 @@ export function composeActiveTerminalTheme( return theme } +/** App-start publication (terminal-query-authority.md §Phase 6 + * prerequisites): hidden-at-launch PTYs can query OSC 10/11 before any + * terminal pane mounts, and main's responder is silent-until-first-push. + * Composes the same theme applyTerminalAppearance would and publishes it + * through the same deduped publisher, so the later pane-mount apply is a + * no-op re-push. Returns whether a publish actually went out. */ +export function publishTerminalViewAttributesAtAppStart( + settings: GlobalSettings | null | undefined, + systemPrefersDark: boolean, + send?: (attributes: TerminalViewAttributes) => boolean +): boolean { + if (!settings) { + return false + } + const appearance = resolveEffectiveTerminalAppearance(settings, systemPrefersDark) + const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName) + const theme = composeActiveTerminalTheme(baseTheme, settings) + return send !== undefined + ? publishTerminalViewAttributes(theme, appearance.mode, settings, send) + : publishTerminalViewAttributes(theme, appearance.mode, settings) +} + +// Value equality over composed ITheme objects (flat string slots plus the +// extendedAnsi string array), used to gate the per-pane options.theme write. +function composedTerminalThemesEqual(a: ITheme | undefined, b: ITheme): boolean { + if (!a) { + return false + } + if (a === b) { + return true + } + const keys = new Set([...Object.keys(a), ...Object.keys(b)]) + for (const key of keys) { + if (key === 'extendedAnsi') { + continue + } + if (a[key as keyof ITheme] !== b[key as keyof ITheme]) { + return false + } + } + const extA = a.extendedAnsi + const extB = b.extendedAnsi + if (!extA || !extB) { + return extA === extB + } + return extA.length === extB.length && extA.every((value, i) => value === extB[i]) +} + export function applyTerminalAppearance( manager: PaneManager, settings: GlobalSettings, @@ -214,6 +271,11 @@ export function applyTerminalAppearance( const paneStyles = resolvePaneStyleOptions(settings) const baseTheme: ITheme | null = appearance.theme ?? getBuiltinTheme(appearance.themeName) const theme = composeActiveTerminalTheme(baseTheme, settings) + // View-attribute bridge (Phase 5 slice 2): this is the single point where + // the composed app-global terminal appearance exists, so publish it to + // main's hidden-PTY query responder here. Deduped inside the publisher — + // per-pane re-applies and attribute-neutral tweaks do not re-push. + publishTerminalViewAttributes(theme, appearance.mode, settings) const paneBackground = theme?.background ?? '#000000' const terminalFontWeights = resolveTerminalFontWeights(settings.terminalFontWeight) @@ -223,7 +285,14 @@ export function applyTerminalAppearance( ) for (const pane of manager.getPanes()) { - if (theme) { + // Why value-gated: xterm's OptionsService fires on object identity, and + // ThemeService._setTheme rebuilds the palette, discarding TUI OSC + // 4/10/11/12 SET mutations. Attribute-neutral applies (font size/family, + // line height, padding, per-pane zoom) compose a fresh-but-identical + // theme; skipping the write keeps visible-pane mutations alive (a + // pre-existing loss this also fixes) and matches the hidden responder's + // deduped overlay behavior, so hidden and visible no longer drift. + if (theme && !composedTerminalThemesEqual(pane.terminal.options.theme, theme)) { pane.terminal.options.theme = theme } // Why: xterm's allowTransparency has measurable rendering cost, so clear diff --git a/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts b/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts index 8308fc8b210..37353c7727c 100644 --- a/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts +++ b/src/renderer/src/components/terminal-pane/terminal-capability-replies.ts @@ -5,6 +5,7 @@ import { terminalOscColorQuerySlotsForBody, type TerminalOscColorQuerySlot } from '../../../../shared/terminal-osc-color-reply' +import { guardParserHandler } from './terminal-parser-handler-guard' export const DEFAULT_DA1_RESPONSE = '\x1b[?1;2c' export const CONPTY_DA1_RESPONSE = '\x1b[?61;4c' @@ -118,37 +119,46 @@ export function installTerminalCapabilityReplyHandlers( deps: TerminalCapabilityRepliesDeps ): IDisposable { const disposables = [ - deps.parser.registerCsiHandler({ final: 'c' }, (params) => { - if (!isPrimaryDeviceAttributesQuery(params)) { - return false - } - // Why: restored scrollback may contain old DA1 queries; answering those - // into the fresh shell recreates the stray-input leak this handler fixes. - if (!deps.isReplaying()) { - deps.sendInput(deps.da1Response ?? DEFAULT_DA1_RESPONSE) - } - return true - }), - deps.parser.registerOscHandler(10, (data) => { - const slots = terminalOscColorQuerySlotsForBody(10, data.trim()) - if (!slots) { - return false - } - if (deps.isReplaying()) { + deps.parser.registerCsiHandler( + { final: 'c' }, + guardParserHandler('csi-da1', (params) => { + if (!isPrimaryDeviceAttributesQuery(params)) { + return false + } + // Why: restored scrollback may contain old DA1 queries; answering those + // into the fresh shell recreates the stray-input leak this handler fixes. + if (!deps.isReplaying()) { + deps.sendInput(deps.da1Response ?? DEFAULT_DA1_RESPONSE) + } return true - } - return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) - }), - deps.parser.registerOscHandler(11, (data) => { - const slots = terminalOscColorQuerySlotsForBody(11, data.trim()) - if (!slots) { - return false - } - if (deps.isReplaying()) { - return true - } - return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) - }) + }) + ), + deps.parser.registerOscHandler( + 10, + guardParserHandler('osc-10-color-query', (data) => { + const slots = terminalOscColorQuerySlotsForBody(10, data.trim()) + if (!slots) { + return false + } + if (deps.isReplaying()) { + return true + } + return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) + }) + ), + deps.parser.registerOscHandler( + 11, + guardParserHandler('osc-11-color-query', (data) => { + const slots = terminalOscColorQuerySlotsForBody(11, data.trim()) + if (!slots) { + return false + } + if (deps.isReplaying()) { + return true + } + return sendTerminalOscColorQueryRepliesForSlots(slots, deps.terminal, deps.sendInput) + }) + ) ] return { diff --git a/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts index 41873fa4fd9..61ad0891fa6 100644 --- a/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/terminal-command-lifecycle.ts @@ -1,4 +1,5 @@ import type { Terminal, IDisposable } from '@xterm/xterm' +import { createOsc133CommandFinishedScanner } from '../../../../shared/terminal-osc133-command-finished' type TerminalCommandLifecycleOptions = { onCommandFinished: (bestEffortExitCode: number | null) => void @@ -6,100 +7,31 @@ type TerminalCommandLifecycleOptions = { onCommandStarted?: () => void } -type OscTerminator = { - index: number - length: number -} - -const OSC_133_PREFIX = '\x1b]133;' -const MAX_OSC_CARRY_LENGTH = 4096 - -function findOscTerminator(data: string, startIndex: number): OscTerminator | null { - const bel = data.indexOf('\x07', startIndex) - const st = data.indexOf('\x1b\\', startIndex) - - if (bel === -1 && st === -1) { - return null - } - if (bel !== -1 && (st === -1 || bel < st)) { - return { index: bel, length: 1 } - } - return { index: st, length: 2 } -} - -function parseBestEffortExitCode(value: string | undefined): number | null { - if (!value) { - return null - } - const parsed = Number.parseInt(value, 10) - return Number.isNaN(parsed) ? null : parsed -} - -function findPrefixCarry(data: string): string { - const maxCarryLength = Math.min(data.length, OSC_133_PREFIX.length - 1) - for (let length = maxCarryLength; length > 0; length -= 1) { - const suffix = data.slice(data.length - length) - if (OSC_133_PREFIX.startsWith(suffix)) { - return suffix - } - } - return '' -} - export function createTerminalCommandLifecycle(options: TerminalCommandLifecycleOptions): { handlePtyData: (data: string) => void attachXtermConsumer: (terminal: Terminal) => IDisposable dispose: () => void } { - let carry = '' + // Why: the byte parsing lives in shared so main's side-effect tracker emits + // identical command-finished facts for local/SSH PTYs; this renderer wrapper + // remains the byte path for remote-runtime PTYs and the kill-switch-off mode. + const scanner = createOsc133CommandFinishedScanner( + options.onCommandFinished, + options.onCommandStarted + ) const disposables: IDisposable[] = [] - const handleOsc133 = (payload: string): void => { - const [sequence, exitCode] = payload.split(';') - if (sequence === 'C') { - options.onCommandStarted?.() - return - } - if (sequence === 'D') { - options.onCommandFinished(parseBestEffortExitCode(exitCode)) - } - } - - const handlePtyData = (data: string): void => { - let combined = carry + data - carry = '' - - while (combined.length > 0) { - const start = combined.indexOf(OSC_133_PREFIX) - if (start === -1) { - carry = findPrefixCarry(combined) - return - } - - 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) - } - return - } - - handleOsc133(combined.slice(payloadStart, terminator.index)) - combined = combined.slice(terminator.index + terminator.length) - } - } - return { - handlePtyData, + handlePtyData: scanner.scan, attachXtermConsumer(terminal) { + // Why: swallow OSC 133 so shell-integration markers never paint — + // rendering hygiene that applies regardless of side-effect authority. const disposable = terminal.parser.registerOscHandler(133, () => true) disposables.push(disposable) return disposable }, dispose() { - carry = '' + scanner.reset() for (const disposable of disposables.splice(0)) { disposable.dispose() } diff --git a/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts b/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts new file mode 100644 index 00000000000..8db2051fa35 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.test.ts @@ -0,0 +1,201 @@ +// Why: pins the renderer half of the dead-push-delivery recovery — the +// watchdog must cost nothing while output flows, confirm a wedge across two +// silent ticks against main's invoke-reported state, then heal exactly once +// per cooldown: re-attach push listeners, request the write-off, and route +// the pulled restore markers to pane handlers locally. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { PtyRendererDeliveryHealthReply } from '../../../../shared/pty-renderer-delivery-health' + +vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: false } })) + +const INTERVAL_MS = 15_000 + +const HEALTHY: PtyRendererDeliveryHealthReply = { + inFlightTotalChars: 0, + inFlightPtyCount: 0, + msSinceLastAck: 1_000 +} + +const STALLED: PtyRendererDeliveryHealthReply = { + inFlightTotalChars: 512 * 1024, + inFlightPtyCount: 2, + msSinceLastAck: null +} + +describe('terminal delivery watchdog', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + const reportMock = vi.fn<(args: unknown) => Promise>() + const listenerCountMock = vi.fn(() => 1) + const reattachMock = vi.fn() + let warnSpy: ReturnType + + beforeEach(() => { + vi.resetModules() + vi.useFakeTimers() + reportMock.mockReset() + listenerCountMock.mockClear() + reattachMock.mockClear() + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) + ;(globalThis as { window: typeof window }).window = { + ...originalWindow, + api: { + ...originalWindow?.api, + pty: { + ...originalWindow?.api?.pty, + reportRendererDeliveryState: reportMock, + getPtyDataListenerCount: listenerCountMock + } + } + } as unknown as typeof window + }) + + afterEach(() => { + warnSpy.mockRestore() + vi.useRealTimers() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + async function startWatchdog(): Promise<{ + recordPtyDataReceived: (ptyId: string, chars: number) => void + registerRestoreHandler: ( + ptyId: string, + handler: (event: { id: string; reason: string; markerSeq?: number }) => void + ) => void + }> { + const watchdog = await import('./terminal-delivery-watchdog') + const restoreChannel = await import('./pty-model-restore-channel') + watchdog.startTerminalDeliveryWatchdog({ + reattachPushListeners: reattachMock, + hasAttachedPtys: () => true + }) + return { + recordPtyDataReceived: watchdog.recordPtyDataReceived, + registerRestoreHandler: (ptyId, handler) => { + restoreChannel.registerPtyModelRestoreNeededHandler(ptyId, handler) + } + } + } + + it('does zero IPC while pty output is flowing', async () => { + const { recordPtyDataReceived } = await startWatchdog() + + for (let tick = 0; tick < 8; tick++) { + recordPtyDataReceived('pty-1', 64) + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + } + + expect(reportMock).not.toHaveBeenCalled() + }) + + it('reports during silence but never heals a healthy-idle main', async () => { + reportMock.mockResolvedValue(HEALTHY) + await startWatchdog() + + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 4) + + expect(reportMock).toHaveBeenCalledTimes(4) + for (const call of reportMock.mock.calls) { + expect((call[0] as { heal?: boolean }).heal).toBeUndefined() + } + expect(reattachMock).not.toHaveBeenCalled() + }) + + it('confirms a wedge across two silent ticks, re-attaches, and routes pulled restore markers', async () => { + reportMock.mockImplementation((args) => + Promise.resolve( + (args as { heal?: boolean }).heal + ? { + ...STALLED, + inFlightTotalChars: 0, + inFlightPtyCount: 0, + writtenOff: [{ id: 'pty-1', markerSeq: 42, writtenOffChars: 512 * 1024 }] + } + : STALLED + ) + ) + const { recordPtyDataReceived, registerRestoreHandler } = await startWatchdog() + const restoreEvents: { id: string; reason: string; markerSeq?: number }[] = [] + registerRestoreHandler('pty-1', (event) => restoreEvents.push(event)) + + // Bytes flowed once, then the push channel died: the field shape. + recordPtyDataReceived('pty-1', 128) + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(reportMock).not.toHaveBeenCalled() + + // First silent tick: report only, no heal yet. + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(reportMock).toHaveBeenCalledTimes(1) + expect(reattachMock).not.toHaveBeenCalled() + + // Second silent tick confirms: re-attach precedes the heal report, the + // listener count is captured for field discrimination, and the pulled + // marker reaches the pane handler without any push event. + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(reattachMock).toHaveBeenCalledTimes(1) + const healCalls = reportMock.mock.calls.filter( + (call) => (call[0] as { heal?: boolean }).heal === true + ) + expect(healCalls).toHaveLength(1) + expect(healCalls[0]![0]).toMatchObject({ + heal: true, + rendererPtyDataListenerCount: 1, + receivedCharsByPty: { 'pty-1': 128 } + }) + expect(restoreEvents).toEqual([{ id: 'pty-1', reason: 'delivery-heal', markerSeq: 42 }]) + }) + + it('rate-limits heals to the cooldown while the wedge persists', async () => { + reportMock.mockImplementation((args) => + Promise.resolve((args as { heal?: boolean }).heal ? { ...STALLED, writtenOff: [] } : STALLED) + ) + await startWatchdog() + + const countHeals = (): number => + reportMock.mock.calls.filter((call) => (call[0] as { heal?: boolean }).heal === true).length + + // Ticks at 15s/30s: confirm + first heal. + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 2) + expect(countHeals()).toBe(1) + + // 45s/60s/75s: streak rebuilds but the 60s cooldown holds. + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 3) + expect(countHeals()).toBe(1) + + // 90s: cooldown elapsed — one more heal, not one per tick. + await vi.advanceTimersByTimeAsync(INTERVAL_MS) + expect(countHeals()).toBe(2) + expect(reattachMock).toHaveBeenCalledTimes(2) + }) + + it('stays off when no PTY expects push delivery', async () => { + reportMock.mockResolvedValue(STALLED) + const watchdog = await import('./terminal-delivery-watchdog') + watchdog.startTerminalDeliveryWatchdog({ + reattachPushListeners: reattachMock, + hasAttachedPtys: () => false + }) + + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 3) + + expect(reportMock).not.toHaveBeenCalled() + }) + + it('never starts without the invoke heal lane (web client, partial mocks)', async () => { + ;(window.api.pty as { reportRendererDeliveryState?: unknown }).reportRendererDeliveryState = + undefined + const watchdog = await import('./terminal-delivery-watchdog') + watchdog.startTerminalDeliveryWatchdog({ + reattachPushListeners: reattachMock, + hasAttachedPtys: () => true + }) + + await vi.advanceTimersByTimeAsync(INTERVAL_MS * 3) + + expect(reattachMock).not.toHaveBeenCalled() + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.ts b/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.ts new file mode 100644 index 00000000000..c9b0d8c29e5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-delivery-watchdog.ts @@ -0,0 +1,278 @@ +/** + * Renderer-initiated watchdog for dead main→renderer push delivery. + * + * Field evidence (v1.4.121-rc.0 snapshot, 2026-07-06): all `pty:data` push + * events stop reaching the renderer (a 245-char shell prompt sat un-ACKed; + * every terminal blank) while invoke IPC keeps working — the wedged window + * answered `getRendererDeliveryDebugSnapshot` live. The prior recovery layers + * cannot reach that state: the xterm write-pipeline guards and replay-guard + * release (#7150) run only after bytes arrive, and the cumulative-ACK + + * solicited-resync protocol heals lost ACKs but probes over the same push + * channel that is dead (upstream precedent: electron#37067, one-directional + * Mojo IPC death). This watchdog is the missing lane: it detects the wedge + * and heals over invoke — the direction proven alive — with zero cost on the + * data hot path (one Map upsert per received chunk; a tick does no IPC while + * output flows or while no PTY delivery is expected). + */ +import { e2eConfig } from '@/lib/e2e-config' +import type { PtyRendererDeliveryHealthReply } from '../../../../shared/pty-renderer-delivery-health' +import { redactPtyIdForDiagnostics } from '../../../../shared/pty-delivery-diagnostics' +import { deliverPulledPtyModelRestoreMarkers } from './pty-model-restore-channel' +import { getProcessedPtyCharTotals } from './terminal-pty-ack-gate' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' + +const WATCHDOG_INTERVAL_MS = 15_000 +// Why 2 ticks: one silent interval can be a probe racing an in-transit chunk; +// two full intervals with zero received events while main reports ACK-starved +// in-flight bytes only occurs in the wedged state. +const WATCHDOG_STALL_TICKS_TO_HEAL = 2 +// Why: a heal that could not revive the push channel must not repaint-storm; +// pull-restores repeat at most once per cooldown while the wedge persists. +const WATCHDOG_HEAL_COOLDOWN_MS = 60_000 + +type TerminalDeliveryWatchdogConfig = { + intervalMs: number + stallTicksToHeal: number + healCooldownMs: number +} + +type TerminalDeliveryWatchdogDeps = { + /** Detach and re-subscribe the dispatcher's push-channel listeners. */ + reattachPushListeners: () => void + /** True while any PTY handler or eager buffer expects push delivery. */ + hasAttachedPtys: () => boolean +} + +const receivedPtyCharTotals = new Map() +let receivedPtyDataEventCount = 0 +let blackholePtyPushDelivery = false + +let watchdogDeps: TerminalDeliveryWatchdogDeps | null = null +let watchdogTimer: ReturnType | null = null +let watchdogConfig: TerminalDeliveryWatchdogConfig = { + intervalMs: WATCHDOG_INTERVAL_MS, + stallTicksToHeal: WATCHDOG_STALL_TICKS_TO_HEAL, + healCooldownMs: WATCHDOG_HEAL_COOLDOWN_MS +} +let eventCountAtLastTick = 0 +let stallStreakTicks = 0 +let lastHealAtMs: number | null = null +let healCount = 0 +let tickInFlight = false + +/** One Map upsert per received chunk — the watchdog's only hot-path cost. + * Counted at dispatcher enqueue, BEFORE parse-deferred ACK crediting, so + * main can tell "lost in the channel" from "received, parse-pending". */ +export function recordPtyDataReceived(ptyId: string, chars: number): void { + receivedPtyDataEventCount += 1 + receivedPtyCharTotals.set(ptyId, (receivedPtyCharTotals.get(ptyId) ?? 0) + chars) +} + +export function clearReceivedPtyCharTotal(ptyId: string): void { + receivedPtyCharTotals.delete(ptyId) +} + +/** E2e blackhole: simulates the field wedge (push events vanish before the + * dispatcher sees them, no receive count, no ACK). Never true in prod. */ +export function isPtyPushDeliveryBlackholed(): boolean { + return blackholePtyPushDelivery +} + +function isMainDeliveryStalled(health: PtyRendererDeliveryHealthReply): boolean { + // Why msSinceLastAck may be null: a wedged-from-first-byte session (the + // field case's brand-new terminal) never ACKs; in-flight debt alone is the + // signal then. A recent ACK means some pty still round-trips — not a wedge. + return ( + health.inFlightTotalChars > 0 && + (health.msSinceLastAck === null || health.msSinceLastAck >= watchdogConfig.intervalMs) + ) +} + +async function runWatchdogTick(): Promise { + const deps = watchdogDeps + const report = window.api?.pty?.reportRendererDeliveryState + if (!deps || typeof report !== 'function') { + stopTerminalDeliveryWatchdog() + return + } + if (receivedPtyDataEventCount !== eventCountAtLastTick) { + eventCountAtLastTick = receivedPtyDataEventCount + stallStreakTicks = 0 + return + } + if (!deps.hasAttachedPtys()) { + stallStreakTicks = 0 + return + } + const health = await report({ + receivedCharsByPty: Object.fromEntries(receivedPtyCharTotals), + processedCharsByPty: getProcessedPtyCharTotals() + }) + if (!health || !isMainDeliveryStalled(health)) { + stallStreakTicks = 0 + return + } + stallStreakTicks += 1 + recordTerminalFreezeBreadcrumb('watchdog-stall', { + stallStreakTicks, + inFlightTotalChars: health.inFlightTotalChars, + msSinceLastAck: health.msSinceLastAck + }) + if (stallStreakTicks < watchdogConfig.stallTicksToHeal) { + return + } + if (lastHealAtMs !== null && Date.now() - lastHealAtMs < watchdogConfig.healCooldownMs) { + return + } + await healDeadPushDelivery(deps, report, health) +} + +async function healDeadPushDelivery( + deps: TerminalDeliveryWatchdogDeps, + report: NonNullable, + stalled: PtyRendererDeliveryHealthReply +): Promise { + lastHealAtMs = Date.now() + stallStreakTicks = 0 + healCount += 1 + // Why read BEFORE re-attach: 0 here = the listener was detached (app-level + // bug to hunt); ≥1 = events are being dropped below the emitter (channel + // dead, platform-level). The single most valuable field discriminator. + const listenerCountBeforeReattach = window.api?.pty?.getPtyDataListenerCount?.() ?? null + deps.reattachPushListeners() + const healed = await report({ + receivedCharsByPty: Object.fromEntries(receivedPtyCharTotals), + processedCharsByPty: getProcessedPtyCharTotals(), + heal: true, + rendererPtyDataListenerCount: listenerCountBeforeReattach + }) + const writtenOff = healed?.writtenOff ?? [] + if (writtenOff.length > 0) { + deliverPulledPtyModelRestoreMarkers( + writtenOff.map((entry) => ({ + id: entry.id, + reason: 'delivery-heal' as const, + ...(typeof entry.markerSeq === 'number' ? { markerSeq: entry.markerSeq } : {}) + })) + ) + } + recordTerminalFreezeBreadcrumb('watchdog-heal', { + listenerCountBeforeReattach, + writtenOffPtyCount: writtenOff.length, + writtenOffChars: writtenOff.reduce((sum, entry) => sum + entry.writtenOffChars, 0) + }) + console.warn('[terminal] delivery watchdog healed dead push delivery', { + listenerCountBeforeReattach, + stalledInFlightChars: stalled.inFlightTotalChars, + stalledPtyCount: stalled.inFlightPtyCount, + msSinceLastAck: stalled.msSinceLastAck, + writtenOffPtyCount: writtenOff.length, + writtenOffChars: writtenOff.reduce((sum, entry) => sum + entry.writtenOffChars, 0), + healCount + }) +} + +function scheduleWatchdogTimer(): void { + if (watchdogTimer) { + clearInterval(watchdogTimer) + } + watchdogTimer = setInterval(() => { + // Why serialized: a heal awaits two invokes; overlapping ticks could + // double-heal inside one cooldown window. + if (tickInFlight) { + return + } + tickInFlight = true + void runWatchdogTick().finally(() => { + tickInFlight = false + }) + }, watchdogConfig.intervalMs) +} + +export function startTerminalDeliveryWatchdog(deps: TerminalDeliveryWatchdogDeps): void { + if (watchdogDeps) { + return + } + // Why gated on the invoke fn: the web remote client and unit tests expose a + // partial pty API; without the report lane the watchdog has no safe heal + // path and must stay off. + if (typeof window.api?.pty?.reportRendererDeliveryState !== 'function') { + return + } + watchdogDeps = deps + eventCountAtLastTick = receivedPtyDataEventCount + scheduleWatchdogTimer() + exposeE2eTerminalDeliveryWatchdog() +} + +export function stopTerminalDeliveryWatchdog(): void { + watchdogDeps = null + if (watchdogTimer) { + clearInterval(watchdogTimer) + watchdogTimer = null + } +} + +/** Prod-reachable state for the one-paste freeze report (ids redacted). */ +export function getTerminalDeliveryWatchdogDiagnostics(): { + running: boolean + receivedPtyDataEventCount: number + receivedCharsByPty: Record + stallStreakTicks: number + healCount: number + msSinceLastHeal: number | null +} { + const receivedCharsByPty: Record = {} + for (const [id, chars] of receivedPtyCharTotals) { + receivedCharsByPty[redactPtyIdForDiagnostics(id)] = chars + } + return { + running: watchdogTimer !== null, + receivedPtyDataEventCount, + receivedCharsByPty, + stallStreakTicks, + healCount, + msSinceLastHeal: lastHealAtMs === null ? null : Date.now() - lastHealAtMs + } +} + +// ─── E2e control surface ───────────────────────────────────────────── + +type E2eTerminalDeliveryWatchdogApi = { + blackhole: (on: boolean) => void + configure: (config: Partial) => void + snapshot: () => { + receivedPtyDataEventCount: number + stallStreakTicks: number + healCount: number + blackholed: boolean + } +} + +type E2eTerminalDeliveryWatchdogWindow = Window & { + __terminalDeliveryWatchdog?: E2eTerminalDeliveryWatchdogApi +} + +function exposeE2eTerminalDeliveryWatchdog(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + const target = window as E2eTerminalDeliveryWatchdogWindow + target.__terminalDeliveryWatchdog ??= { + blackhole: (on) => { + blackholePtyPushDelivery = on + }, + configure: (config) => { + watchdogConfig = { ...watchdogConfig, ...config } + if (watchdogDeps) { + scheduleWatchdogTimer() + } + }, + snapshot: () => ({ + receivedPtyDataEventCount, + stallStreakTicks, + healCount, + blackholed: blackholePtyPushDelivery + }) + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts b/src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts new file mode 100644 index 00000000000..042d261450f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-freeze-breadcrumbs.ts @@ -0,0 +1,34 @@ +// Renderer half of the one-paste freeze report: a bounded ring of the +// delivery-affecting transitions (gate marks, visibility trust changes, +// watchdog heals, restore markers) so a field report carries the history +// that led to the frozen state, not just a point-in-time counter snapshot. +import { + type PtyDeliveryBreadcrumb, + createPtyDeliveryBreadcrumbRing +} from '../../../../shared/pty-delivery-diagnostics' +import { setTerminalWebglDiagnosticRecorder } from '../../../../shared/terminal-webgl-diagnostics' + +const rendererDeliveryBreadcrumbs = createPtyDeliveryBreadcrumbRing() + +export function recordTerminalFreezeBreadcrumb( + kind: string, + detail?: PtyDeliveryBreadcrumb['detail'] +): void { + rendererDeliveryBreadcrumbs.record(kind, detail) +} + +// Why: lib-layer WebGL code (pane-webgl-renderer, the atlas registry) can't +// import this components-layer ring directly, so it records through a shared +// sink. Point that sink at the same ring here so context-loss and atlas-reset +// crumbs land in the one-paste report alongside delivery/visibility history. +setTerminalWebglDiagnosticRecorder((kind, detail) => + rendererDeliveryBreadcrumbs.record(kind, detail) +) + +export function getTerminalFreezeBreadcrumbs(): PtyDeliveryBreadcrumb[] { + return rendererDeliveryBreadcrumbs.snapshot() +} + +export function resetTerminalFreezeBreadcrumbsForTesting(): void { + rendererDeliveryBreadcrumbs.reset() +} diff --git a/src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts b/src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts new file mode 100644 index 00000000000..dd0cbc98c0d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-freeze-report.test.ts @@ -0,0 +1,79 @@ +// Why: pins the one-paste freeze report contract — a single console call must +// return renderer state + main snapshot + breadcrumbs, and a dead/throwing +// invoke channel must be captured as data instead of sinking the report. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: false } })) + +describe('terminal freeze report', () => { + const originalWindow = (globalThis as { window?: unknown }).window + const originalDocument = (globalThis as { document?: unknown }).document + const snapshotMock = vi.fn() + const listenerCountMock = vi.fn(() => 1) + + beforeEach(() => { + vi.resetModules() + snapshotMock.mockReset() + listenerCountMock.mockClear() + ;(globalThis as { document: unknown }).document = { + visibilityState: 'hidden', + hasFocus: () => true, + addEventListener: vi.fn(), + removeEventListener: vi.fn() + } + ;(globalThis as { window: unknown }).window = { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + api: { + pty: { + getRendererDeliveryDebugSnapshot: snapshotMock, + getPtyDataListenerCount: listenerCountMock + } + } + } + }) + + afterEach(() => { + ;(globalThis as { window: unknown }).window = originalWindow + ;(globalThis as { document: unknown }).document = originalDocument + }) + + it('assembles renderer state, breadcrumbs, and the main snapshot into one blob', async () => { + snapshotMock.mockResolvedValue({ pendingPtyCount: 0, diagnostics: { perPty: [] } }) + const breadcrumbs = await import('./terminal-freeze-breadcrumbs') + breadcrumbs.recordTerminalFreezeBreadcrumb('gate-mark', { id: '…@@abc' }) + const { buildTerminalFreezeReport } = await import('./terminal-freeze-report') + + const report = await buildTerminalFreezeReport() + + expect(report.renderer.documentVisibilityState).toBe('hidden') + expect(report.renderer.documentHasFocus).toBe(true) + expect(report.renderer.ptyDataListenerCount).toBe(1) + expect(report.renderer.breadcrumbs.map((crumb) => crumb.kind)).toContain('gate-mark') + expect(report.renderer.watchdog).toMatchObject({ running: false, healCount: 0 }) + expect(report.main).toMatchObject({ pendingPtyCount: 0 }) + expect(typeof report.capturedAt).toBe('string') + }) + + it('captures a failing invoke channel as data instead of throwing', async () => { + snapshotMock.mockRejectedValue(new Error('invoke dead')) + const { buildTerminalFreezeReport } = await import('./terminal-freeze-report') + + const report = await buildTerminalFreezeReport() + + expect(report.main).toMatchObject({ snapshotError: expect.stringContaining('invoke dead') }) + }) + + it('installs the console-callable global exactly once per renderer', async () => { + snapshotMock.mockResolvedValue({ pendingPtyCount: 0 }) + const { installTerminalFreezeReport, buildTerminalFreezeReport } = + await import('./terminal-freeze-report') + installTerminalFreezeReport() + const installed = ( + globalThis.window as unknown as { + __orcaTerminalFreezeReport?: () => Promise + } + ).__orcaTerminalFreezeReport + expect(installed).toBe(buildTerminalFreezeReport) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-freeze-report.ts b/src/renderer/src/components/terminal-pane/terminal-freeze-report.ts new file mode 100644 index 00000000000..5c087827f9f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-freeze-report.ts @@ -0,0 +1,59 @@ +// One-paste freeze report: `await window.__orcaTerminalFreezeReport()` in the +// DevTools console of a frozen window returns renderer state, main state (with +// per-pty delivery table), and both processes' breadcrumb history in a single +// JSON blob. Assembled over invoke IPC — the direction proven alive in every +// field wedge observed — and installed in PROD builds, because the whole point +// is that an affected user never has to gather logs piecemeal again. +import { getTerminalFreezeBreadcrumbs } from './terminal-freeze-breadcrumbs' +import { getTerminalDeliveryWatchdogDiagnostics } from './terminal-delivery-watchdog' +import { isDocumentVisibilityProvenStale } from './stale-document-visibility' +import { getAllPaneRenderingDiagnostics } from '@/lib/pane-manager/pane-manager-registry' + +export type TerminalFreezeReport = { + capturedAt: string + renderer: { + documentVisibilityState: string | null + documentHasFocus: boolean | null + documentVisibilityProvenStale: boolean + ptyDataListenerCount: number | null + watchdog: ReturnType + // Why: per-pane WebGL state distinguishes a stale post-wake surface from a + // context-loss fallback — the missing signal for the garble-after-sleep class. + paneRendering: ReturnType + breadcrumbs: ReturnType + } + main: unknown +} + +export async function buildTerminalFreezeReport(): Promise { + const hasDocument = typeof document !== 'undefined' + // The main section must never sink the whole report — a dead invoke channel + // is itself a diagnostic worth capturing. + const main = await window.api?.pty + ?.getRendererDeliveryDebugSnapshot?.() + .catch((error: unknown) => ({ snapshotError: String(error) })) + return { + capturedAt: new Date().toISOString(), + renderer: { + documentVisibilityState: hasDocument ? document.visibilityState : null, + documentHasFocus: hasDocument ? document.hasFocus() : null, + documentVisibilityProvenStale: isDocumentVisibilityProvenStale(), + ptyDataListenerCount: window.api?.pty?.getPtyDataListenerCount?.() ?? null, + watchdog: getTerminalDeliveryWatchdogDiagnostics(), + paneRendering: getAllPaneRenderingDiagnostics(), + breadcrumbs: getTerminalFreezeBreadcrumbs() + }, + main: main ?? null + } +} + +type TerminalFreezeReportWindow = Window & { + __orcaTerminalFreezeReport?: () => Promise +} + +export function installTerminalFreezeReport(): void { + if (typeof window === 'undefined') { + return + } + ;(window as TerminalFreezeReportWindow).__orcaTerminalFreezeReport = buildTerminalFreezeReport +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts new file mode 100644 index 00000000000..8123b83a9e1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-delivery-gate.ts @@ -0,0 +1,45 @@ +/** + * Renderer-side predicate for main's Phase-4 hidden PTY delivery gate. + * + * The gate only operates when main holds side-effect authority for the PTY + * (see isMainTerminalSideEffectAuthorityForPty) AND the gate-specific kill + * switch is on. Callers decide once at pane/watcher creation — the decision + * picks which mode-2031 responder is registered (byte sidecar vs fact reply), + * so it must never flip per chunk. + */ +import type { GlobalSettings } from '../../../../shared/types' + +// Why: cached once per session — the blocking read should only ever run on +// the pre-hydration startup path, never per pane bind. +let persistedGateFlagCache: boolean | null | undefined + +function readPersistedHiddenDeliveryGateFlagSync(): boolean | null { + if (persistedGateFlagCache === undefined) { + try { + const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync + persistedGateFlagCache = + typeof getSync === 'function' ? (getSync()?.terminalHiddenDeliveryGate ?? null) : null + } catch { + persistedGateFlagCache = null + } + } + return persistedGateFlagCache +} + +export function isRendererHiddenPtyDeliveryGateEnabled( + settings: Pick | null +): boolean { + if (settings !== null) { + return settings.terminalHiddenDeliveryGate !== false + } + // Why: settings hydrate asynchronously; a pane/watcher bound before + // hydration must honor the persisted kill switch — the responder-mode + // decision made here is never revisited (same rationale as the + // side-effect-authority sync read). + return readPersistedHiddenDeliveryGateFlagSync() !== false +} + +/** Test seam: reset the persisted-flag cache between tests. */ +export function _resetHiddenPtyDeliveryGateFlagCacheForTest(): void { + persistedGateFlagCache = undefined +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts new file mode 100644 index 00000000000..db76af7ec56 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest' +import { resolveHiddenRestoreScrollbackRows } from './terminal-hidden-restore-scrollback' + +describe('hidden terminal restore scrollback', () => { + it('preserves configured history depth through the supported maximum', () => { + expect(resolveHiddenRestoreScrollbackRows(10_000)).toBe(10_000) + expect(resolveHiddenRestoreScrollbackRows(50_000)).toBe(50_000) + }) + + it('clamps malformed or oversized values with the shared desktop policy', () => { + expect(resolveHiddenRestoreScrollbackRows(undefined)).toBe(5_000) + expect(resolveHiddenRestoreScrollbackRows(100_000)).toBe(50_000) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.ts new file mode 100644 index 00000000000..6767d3f55f3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-restore-scrollback.ts @@ -0,0 +1,13 @@ +import { + DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, + normalizeDesktopTerminalSnapshotRows +} from '../../../../shared/terminal-scrollback-policy' + +/** Hidden view rebuilds must preserve the same history depth the live xterm + * retained; otherwise switching tabs silently changes the user's scrollback. */ +export function resolveHiddenRestoreScrollbackRows(configuredScrollback: unknown): number { + return ( + normalizeDesktopTerminalSnapshotRows(configuredScrollback) ?? + DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT + ) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts new file mode 100644 index 00000000000..40ecab2f5a5 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.test.ts @@ -0,0 +1,529 @@ +import { describe, expect, it } from 'vitest' +import { + TERMINAL_TAB_HOT_RETAIN_MS, + TERMINAL_WORKTREE_HOT_RETAIN_MS, + TERMINAL_WORKTREE_PARK_DELAY_MS, + canParkTerminalTabRenderer, + canParkTerminalWorktreeRenderers, + getTerminalTabColdParkRecheckDelayMs, + getTerminalWorktreeColdParkRecheckDelayMs, + isSnapshotBackedTerminalPty, + selectColdParkedTerminalTabs, + selectColdParkedTerminalWorktrees +} from './terminal-hidden-view-parking' + +describe('isSnapshotBackedTerminalPty', () => { + it('allows local daemon sessions owned by the worktree', () => { + expect(isSnapshotBackedTerminalPty('repo::/worktree@@session-1', 'repo::/worktree')).toBe(true) + expect(isSnapshotBackedTerminalPty('wt-1@@session-1', 'wt-1')).toBe(true) + }) + + // Why: separator-less ids ('1', '2', 'pty-local-detached') come from the + // daemon-fail-open LocalPtyProvider and have no daemon session model — + // revealing a parked pane would silently respawn a fresh shell, so they + // must not count as snapshot-backed (changed from the ported prior art). + it('rejects separator-less local PTY ids that lack a daemon session model', () => { + expect(isSnapshotBackedTerminalPty('pty-local-detached', 'repo::/worktree')).toBe(false) + expect(isSnapshotBackedTerminalPty('1', 'wt-1')).toBe(false) + }) + + it('rejects tabs that do not have a PTY yet', () => { + expect(isSnapshotBackedTerminalPty(null, 'repo::/worktree')).toBe(false) + }) + + it('rejects daemon sessions owned by another worktree', () => { + expect(isSnapshotBackedTerminalPty('repo::/other@@session-1', 'repo::/worktree')).toBe(false) + expect(isSnapshotBackedTerminalPty('wt-2@@session-1', 'wt-1')).toBe(false) + }) + + it('rejects SSH and remote runtime PTY handles', () => { + expect(isSnapshotBackedTerminalPty('ssh:ssh-1@@pty-1', 'repo::/worktree')).toBe(false) + expect(isSnapshotBackedTerminalPty('remote:env-1@@terminal-1', 'repo::/worktree')).toBe(false) + }) +}) + +describe('canParkTerminalWorktreeRenderers', () => { + const hiddenSinceMs = 1_000 + const nowMs = hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS + const base = { + worktreeId: 'repo::/worktree', + terminalTabs: [{ id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }], + pendingStartupByTabId: {}, + parkingEnabled: true, + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs, + nowMs + } + + it('parks hidden local terminal renderers after the idle delay', () => { + expect(canParkTerminalWorktreeRenderers(base)).toBe(true) + }) + + it('never parks when the settings kill switch disables parking', () => { + expect(canParkTerminalWorktreeRenderers({ ...base, parkingEnabled: false })).toBe(false) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + parkingEnabled: false, + nowMs: hiddenSinceMs + TERMINAL_WORKTREE_HOT_RETAIN_MS * 10 + }) + ).toBe(false) + }) + + it('keeps renderers mounted while visible, measuring, portaled, or before the delay', () => { + expect(canParkTerminalWorktreeRenderers({ ...base, isVisible: true })).toBe(false) + expect(canParkTerminalWorktreeRenderers({ ...base, shouldMeasureHiddenWorktree: true })).toBe( + false + ) + expect(canParkTerminalWorktreeRenderers({ ...base, hasActivityTerminalPortal: true })).toBe( + false + ) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS - 1 + }) + ).toBe(false) + }) + + it('honors a per-call cold-park delay override', () => { + const shortDelayArgs = { ...base, coldParkDelayMs: 100 } + expect(canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 99 })).toBe( + false + ) + expect( + canParkTerminalWorktreeRenderers({ ...shortDelayArgs, nowMs: hiddenSinceMs + 100 }) + ).toBe(true) + }) + + it('keeps the renderer mounted when any terminal lacks snapshot-backed restore', () => { + expect( + canParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1' }, + { id: 'tab-2', ptyId: 'ssh:ssh-1@@pty-1' } + ] + }) + ).toBe(false) + }) + + it('keeps renderers mounted while a tab has startup or activation work pending', () => { + expect( + canParkTerminalWorktreeRenderers({ + ...base, + pendingStartupByTabId: { 'tab-1': { command: 'echo pending' } } + }) + ).toBe(false) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: true } + ] + }) + ).toBe(false) + expect( + canParkTerminalWorktreeRenderers({ + ...base, + terminalTabs: [ + { id: 'tab-1', ptyId: 'repo::/worktree@@session-1', pendingActivationSpawn: 2 } + ] + }) + ).toBe(false) + }) +}) + +describe('canParkTerminalTabRenderer', () => { + const hiddenSinceMs = 1_000 + const base = { + worktreeId: 'wt-1', + terminalTab: { + id: 'tab-1', + ptyId: 'wt-1@@session-1', + isVisible: false, + hasActivityTerminalPortal: false, + hiddenSinceMs + }, + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs: hiddenSinceMs + TERMINAL_WORKTREE_PARK_DELAY_MS + } + + it('parks an idle hidden local tab and honors the kill switch', () => { + expect(canParkTerminalTabRenderer(base)).toBe(true) + expect(canParkTerminalTabRenderer({ ...base, parkingEnabled: false })).toBe(false) + }) + + it('honors a per-call cold-park delay override', () => { + expect( + canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 99 }) + ).toBe(false) + expect( + canParkTerminalTabRenderer({ ...base, coldParkDelayMs: 100, nowMs: hiddenSinceMs + 100 }) + ).toBe(true) + }) +}) + +describe('selectColdParkedTerminalWorktrees', () => { + const nowMs = 500_000 + + function localCandidate(worktreeId: string, hiddenSinceMs: number) { + return { + worktreeId, + terminalTabs: [{ id: `tab-${worktreeId}`, ptyId: `${worktreeId}@@session-1` }], + isVisible: false, + shouldMeasureHiddenWorktree: false, + hasActivityTerminalPortal: false, + hiddenSinceMs + } + } + + it('keeps recent hidden local worktrees hot up to the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set()) + }) + + it('cold-parks the oldest hidden local worktrees beyond the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1), + localCandidate('wt-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set(['wt-3'])) + }) + + it('cold-parks aged local worktrees even when under the retain limit', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS)], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 4 + }) + + expect(selected).toEqual(new Set(['wt-1'])) + }) + + it('selects nothing when the settings kill switch disables parking', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-1', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + localCandidate('wt-2', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS * 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: false, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) + + it('does not cold-park terminals without local snapshot recovery', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + localCandidate('wt-local', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + { + ...localCandidate('wt-ssh', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [{ id: 'tab-ssh', ptyId: 'ssh:ssh-1@@pty-1' }] + }, + { + ...localCandidate('wt-remote', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [{ id: 'tab-remote', ptyId: 'remote:env-1@@terminal-1' }] + } + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set(['wt-local'])) + }) + + it('keeps visible, measuring, portaled, and pending terminals mounted', () => { + const selected = selectColdParkedTerminalWorktrees({ + worktrees: [ + { + ...localCandidate('wt-visible', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + isVisible: true + }, + { + ...localCandidate('wt-measuring', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + shouldMeasureHiddenWorktree: true + }, + { + ...localCandidate('wt-portal', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + hasActivityTerminalPortal: true + }, + { + ...localCandidate('wt-activation', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS), + terminalTabs: [ + { + id: 'tab-activation', + ptyId: 'wt-activation@@session-1', + pendingActivationSpawn: true + } + ] + }, + localCandidate('wt-startup', nowMs - TERMINAL_WORKTREE_HOT_RETAIN_MS) + ], + pendingStartupByTabId: { 'tab-wt-startup': { command: 'echo pending' } }, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) +}) + +describe('selectColdParkedTerminalTabs', () => { + const nowMs = 500_000 + + function localTab(id: string, hiddenSinceMs: number) { + return { + id, + ptyId: `wt-1@@session-${id}`, + pendingActivationSpawn: false, + isVisible: false, + hasActivityTerminalPortal: false, + hiddenSinceMs + } + } + + it('keeps visible and recent inactive terminal tabs mounted', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + { ...localTab('tab-visible', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), isVisible: true }, + localTab('tab-recent-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localTab('tab-recent-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set()) + }) + + it('cold-parks the oldest inactive local tabs beyond the retain limit', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + localTab('tab-1', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS), + localTab('tab-2', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 1), + localTab('tab-3', nowMs - TERMINAL_WORKTREE_PARK_DELAY_MS - 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 2 + }) + + expect(selected).toEqual(new Set(['tab-3'])) + }) + + it('cold-parks aged inactive local tabs even when under the retain limit', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS)], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 12 + }) + + expect(selected).toEqual(new Set(['tab-1'])) + }) + + it('selects nothing when the settings kill switch disables parking', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + localTab('tab-1', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + localTab('tab-2', nowMs - TERMINAL_TAB_HOT_RETAIN_MS * 2) + ], + pendingStartupByTabId: {}, + parkingEnabled: false, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) + + it('does not cold-park inactive terminal tabs without local snapshot recovery', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + localTab('tab-local', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + { + ...localTab('tab-ssh', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + ptyId: 'ssh:ssh-1@@pty-1' + }, + { + ...localTab('tab-remote', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + ptyId: 'remote:env-1@@terminal-1' + } + ], + pendingStartupByTabId: {}, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set(['tab-local'])) + }) + + it('keeps portaled, pending-startup, and pending-activation terminal tabs mounted', () => { + const selected = selectColdParkedTerminalTabs({ + worktreeId: 'wt-1', + terminalTabs: [ + { + ...localTab('tab-portal', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + hasActivityTerminalPortal: true + }, + localTab('tab-startup', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + { + ...localTab('tab-activation', nowMs - TERMINAL_TAB_HOT_RETAIN_MS), + pendingActivationSpawn: true + } + ], + pendingStartupByTabId: { 'tab-startup': { command: 'echo pending' } }, + parkingEnabled: true, + nowMs, + hotRetainLimit: 0 + }) + + expect(selected).toEqual(new Set()) + }) +}) + +describe('getTerminalWorktreeColdParkRecheckDelayMs', () => { + it('returns the next cold-park policy deadline', () => { + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: null, + nowMs: 1_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(50) + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_100, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(900) + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 2_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) + + it('schedules no recheck when the settings kill switch disables parking', () => { + expect( + getTerminalWorktreeColdParkRecheckDelayMs({ + parkingEnabled: false, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) +}) + +describe('getTerminalTabColdParkRecheckDelayMs', () => { + it('returns the next terminal-tab cold-park policy deadline', () => { + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: null, + nowMs: 1_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(50) + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 1_100, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBe(900) + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: true, + hiddenSinceMs: 1_000, + nowMs: 2_000, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) + + it('schedules no recheck when the settings kill switch disables parking', () => { + expect( + getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: false, + hiddenSinceMs: 1_000, + nowMs: 1_050, + coldParkDelayMs: 100, + hotRetainMs: 1_000 + }) + ).toBeNull() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts new file mode 100644 index 00000000000..1421e8ac53d --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-hidden-view-parking.ts @@ -0,0 +1,285 @@ +import { isRemoteRuntimePtyId } from '@/runtime/runtime-terminal-inspection' +import { PTY_SESSION_ID_SEPARATOR } from '../../../../shared/pty-session-id-format' +import { parseAppSshPtyId } from '../../../../shared/ssh-pty-id' +import type { TerminalTab } from '../../../../shared/types' + +// Why: cold-park hysteresis keeps a hidden pane mounted for 30s so quick tab +// flips never pay a re-hydrate; hot-retain keeps a bounded recently-visible +// working set warm for 5 minutes beyond that. +export const TERMINAL_WORKTREE_COLD_PARK_DELAY_MS = 30_000 +export const TERMINAL_WORKTREE_HOT_RETAIN_MS = 5 * 60_000 +export const TERMINAL_WORKTREE_HOT_RETAIN_LIMIT = 4 +export const TERMINAL_WORKTREE_PARK_DELAY_MS = TERMINAL_WORKTREE_COLD_PARK_DELAY_MS +export const TERMINAL_TAB_COLD_PARK_DELAY_MS = 30_000 +export const TERMINAL_TAB_HOT_RETAIN_MS = 5 * 60_000 +export const TERMINAL_TAB_HOT_RETAIN_LIMIT = 12 + +// Why: tests override these per call (instead of process.env reads inside the +// module) to shrink the 30s hysteresis to test-friendly durations. +export type TerminalColdParkPolicyOverrides = { + coldParkDelayMs?: number + hotRetainMs?: number + hotRetainLimit?: number +} + +export type ColdParkableTerminalTab = Pick + +export type TerminalWorktreeColdParkCandidate = { + worktreeId: string + terminalTabs: readonly ColdParkableTerminalTab[] + isVisible: boolean + shouldMeasureHiddenWorktree: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null +} + +export type TerminalTabColdParkCandidate = ColdParkableTerminalTab & { + isVisible: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null +} + +function getPendingActivationSpawnCount(value: boolean | number | undefined): number { + if (value === true) { + return 1 + } + return typeof value === 'number' && value > 0 ? value : 0 +} + +// Why: parking relies on the daemon model snapshot to re-hydrate. Remote +// runtime and SSH PTYs have no local snapshot in this phase, and a session id +// minted for another worktree reattaches through a path parking cannot replay. +export function isSnapshotBackedTerminalPty(ptyId: string | null, worktreeId: string): boolean { + if (!ptyId) { + return false + } + if (isRemoteRuntimePtyId(ptyId) || parseAppSshPtyId(ptyId)) { + return false + } + // Why: separator-less ids come from the daemon-fail-open LocalPtyProvider; + // they have no daemon session model, so revealing a parked pane would + // silently respawn a fresh shell instead of restoring the snapshot. + const separatorIdx = ptyId.lastIndexOf(PTY_SESSION_ID_SEPARATOR) + return separatorIdx !== -1 && ptyId.slice(0, separatorIdx) === worktreeId +} + +export function canParkTerminalWorktreeRenderers(args: { + worktreeId: string + terminalTabs: readonly ColdParkableTerminalTab[] + pendingStartupByTabId: Readonly> + // Why: callers pass settings.terminalHiddenViewParking !== false — the + // design-doc kill switch that disables parking entirely. + parkingEnabled: boolean + isVisible: boolean + shouldMeasureHiddenWorktree: boolean + hasActivityTerminalPortal: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number +}): boolean { + if ( + !args.parkingEnabled || + args.isVisible || + args.shouldMeasureHiddenWorktree || + args.hasActivityTerminalPortal || + args.hiddenSinceMs === null + ) { + return false + } + if ( + args.nowMs - args.hiddenSinceMs < + (args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS) + ) { + return false + } + return args.terminalTabs.every((tab) => { + if (args.pendingStartupByTabId[tab.id] !== undefined) { + return false + } + if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { + return false + } + return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId) + }) +} + +export function canParkTerminalTabRenderer(args: { + worktreeId: string + terminalTab: TerminalTabColdParkCandidate + pendingStartupByTabId: Readonly> + parkingEnabled: boolean + nowMs: number + coldParkDelayMs?: number +}): boolean { + const tab = args.terminalTab + if ( + !args.parkingEnabled || + tab.isVisible || + tab.hasActivityTerminalPortal || + tab.hiddenSinceMs === null + ) { + return false + } + if (args.nowMs - tab.hiddenSinceMs < (args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS)) { + return false + } + if (args.pendingStartupByTabId[tab.id] !== undefined) { + return false + } + if (getPendingActivationSpawnCount(tab.pendingActivationSpawn) > 0) { + return false + } + return isSnapshotBackedTerminalPty(tab.ptyId, args.worktreeId) +} + +type ColdParkRetainCandidate = { id: string; hiddenSinceMs: number } + +// Why: hot-retain keeps the most recently hidden ids warm up to the limit; +// ids hidden past hotRetainMs or beyond the limit cold-park. Ties sort by id +// so the selection is deterministic. +function selectIdsBeyondHotRetain( + candidates: ColdParkRetainCandidate[], + args: { nowMs: number; hotRetainMs: number; hotRetainLimit: number } +): Set { + const coldParkedIds = new Set() + const retainedCandidates: ColdParkRetainCandidate[] = [] + for (const candidate of candidates) { + if (args.nowMs - candidate.hiddenSinceMs >= args.hotRetainMs) { + coldParkedIds.add(candidate.id) + } else { + retainedCandidates.push(candidate) + } + } + retainedCandidates.sort((a, b) => { + const recencyDelta = b.hiddenSinceMs - a.hiddenSinceMs + return recencyDelta === 0 ? a.id.localeCompare(b.id) : recencyDelta + }) + for (const candidate of retainedCandidates.slice(Math.max(0, args.hotRetainLimit))) { + coldParkedIds.add(candidate.id) + } + return coldParkedIds +} + +export function selectColdParkedTerminalWorktrees( + args: { + worktrees: readonly TerminalWorktreeColdParkCandidate[] + pendingStartupByTabId: Readonly> + parkingEnabled: boolean + nowMs: number + } & TerminalColdParkPolicyOverrides +): Set { + if (!args.parkingEnabled) { + return new Set() + } + const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS + const candidates: ColdParkRetainCandidate[] = [] + for (const worktree of args.worktrees) { + if ( + worktree.hiddenSinceMs === null || + !canParkTerminalWorktreeRenderers({ + ...worktree, + pendingStartupByTabId: args.pendingStartupByTabId, + parkingEnabled: args.parkingEnabled, + nowMs: args.nowMs, + coldParkDelayMs + }) + ) { + continue + } + candidates.push({ id: worktree.worktreeId, hiddenSinceMs: worktree.hiddenSinceMs }) + } + return selectIdsBeyondHotRetain(candidates, { + nowMs: args.nowMs, + hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS, + hotRetainLimit: args.hotRetainLimit ?? TERMINAL_WORKTREE_HOT_RETAIN_LIMIT + }) +} + +export function selectColdParkedTerminalTabs( + args: { + worktreeId: string + terminalTabs: readonly TerminalTabColdParkCandidate[] + pendingStartupByTabId: Readonly> + parkingEnabled: boolean + nowMs: number + } & TerminalColdParkPolicyOverrides +): Set { + if (!args.parkingEnabled) { + return new Set() + } + const coldParkDelayMs = args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS + const candidates: ColdParkRetainCandidate[] = [] + for (const tab of args.terminalTabs) { + if ( + tab.hiddenSinceMs === null || + !canParkTerminalTabRenderer({ + worktreeId: args.worktreeId, + terminalTab: tab, + pendingStartupByTabId: args.pendingStartupByTabId, + parkingEnabled: args.parkingEnabled, + nowMs: args.nowMs, + coldParkDelayMs + }) + ) { + continue + } + candidates.push({ id: tab.id, hiddenSinceMs: tab.hiddenSinceMs }) + } + return selectIdsBeyondHotRetain(candidates, { + nowMs: args.nowMs, + hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS, + hotRetainLimit: args.hotRetainLimit ?? TERMINAL_TAB_HOT_RETAIN_LIMIT + }) +} + +// Why: parking decisions change only at the cold-park and hot-retain +// deadlines, so callers schedule one recheck at the next deadline instead of +// polling. +function nextColdParkDeadlineDelayMs(args: { + parkingEnabled: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs: number + hotRetainMs: number +}): number | null { + if (!args.parkingEnabled || args.hiddenSinceMs === null) { + return null + } + const pendingDeadlines = [ + args.hiddenSinceMs + args.coldParkDelayMs, + args.hiddenSinceMs + args.hotRetainMs + ].filter((deadlineMs) => deadlineMs > args.nowMs) + return pendingDeadlines.length === 0 ? null : Math.min(...pendingDeadlines) - args.nowMs +} + +export function getTerminalWorktreeColdParkRecheckDelayMs(args: { + parkingEnabled: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number + hotRetainMs?: number +}): number | null { + return nextColdParkDeadlineDelayMs({ + parkingEnabled: args.parkingEnabled, + hiddenSinceMs: args.hiddenSinceMs, + nowMs: args.nowMs, + coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_WORKTREE_COLD_PARK_DELAY_MS, + hotRetainMs: args.hotRetainMs ?? TERMINAL_WORKTREE_HOT_RETAIN_MS + }) +} + +export function getTerminalTabColdParkRecheckDelayMs(args: { + parkingEnabled: boolean + hiddenSinceMs: number | null + nowMs: number + coldParkDelayMs?: number + hotRetainMs?: number +}): number | null { + return nextColdParkDeadlineDelayMs({ + parkingEnabled: args.parkingEnabled, + hiddenSinceMs: args.hiddenSinceMs, + nowMs: args.nowMs, + coldParkDelayMs: args.coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS, + hotRetainMs: args.hotRetainMs ?? TERMINAL_TAB_HOT_RETAIN_MS + }) +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts new file mode 100644 index 00000000000..bfd6b988291 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.test.ts @@ -0,0 +1,564 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher' + +const WORKTREE_ID = 'repo::/worktree' +const OTHER_WORKTREE_ID = 'repo::/other-worktree' +const TAB_ID = 'tab-1' +const PTY_ID = `${WORKTREE_ID}@@session-1` +const SECOND_PTY_ID = `${WORKTREE_ID}@@session-2` +const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const SECOND_LEAF_ID = '22222222-2222-4222-8222-222222222222' + +type StartedWatcher = { + options: ParkedTerminalByteWatcherOptions + dispose: ReturnType +} + +const startedWatchers: StartedWatcher[] = [] +const startParkedTerminalByteWatcher = vi.fn((options: ParkedTerminalByteWatcherOptions) => { + const dispose = vi.fn() + startedWatchers.push({ options, dispose }) + return dispose +}) + +vi.mock('./parked-terminal-byte-watcher', () => ({ + startParkedTerminalByteWatcher: (options: ParkedTerminalByteWatcherOptions) => + startParkedTerminalByteWatcher(options) +})) + +type ExitSubscription = { + ptyId: string + callback: (code: number) => void + unsubscribe: ReturnType +} + +const exitSubscriptions: ExitSubscription[] = [] +const subscribeToPtyExit = vi.fn((ptyId: string, callback: (code: number) => void) => { + const unsubscribe = vi.fn() + exitSubscriptions.push({ ptyId, callback, unsubscribe }) + return unsubscribe +}) + +vi.mock('./pty-dispatcher', () => ({ + subscribeToPtyExit: (ptyId: string, callback: (code: number) => void) => + subscribeToPtyExit(ptyId, callback) +})) + +type MockStoreState = { + terminalLayoutsByTabId: Record< + string, + { + root: unknown + activeLeafId: string | null + expandedLeafId: string | null + ptyIdsByLeafId?: Record + } + > + runtimePaneTitlesByTabId: Record> + clearRuntimePaneTitle: ReturnType + setTabLayout: ReturnType +} + +let mockStoreState: MockStoreState + +vi.mock('@/store', () => ({ + useAppStore: { getState: () => mockStoreState } +})) + +import { + canWatcherCoverParkedTerminalTab, + captureParkedTerminalPaneCandidates, + disposeParkedTerminalWatchersForPtyIds, + disposeParkedTerminalWatchersForWorktree, + fallbackParkedPaneCandidates, + getParkedTerminalWatcherTabIds, + pruneParkedTerminalWatchers, + shouldDeferParkedPtyExitTabClose, + syncParkedTerminalTabWatchers +} from './terminal-parked-tab-watchers' + +const ptyWrite = vi.fn() +const originalWindow = (globalThis as { window?: unknown }).window + +function capturePanes( + panes: { ptyId: string | null; paneId: number; leafId: string; drivesTabTitle: boolean }[], + args?: { tabId?: string; worktreeId?: string } +): void { + captureParkedTerminalPaneCandidates(args?.tabId ?? TAB_ID, args?.worktreeId ?? WORKTREE_ID, panes) +} + +function syncParked(args?: { + worktreeId?: string + tabs?: { id: string; ptyId: string | null }[] + parkedTabIds?: Iterable +}): void { + syncParkedTerminalTabWatchers({ + worktreeId: args?.worktreeId ?? WORKTREE_ID, + tabs: args?.tabs ?? [{ id: TAB_ID, ptyId: PTY_ID }], + parkedTabIds: new Set(args?.parkedTabIds ?? [TAB_ID]) + }) +} + +describe('terminal-parked-tab-watchers', () => { + beforeEach(() => { + mockStoreState = { + terminalLayoutsByTabId: {}, + runtimePaneTitlesByTabId: {}, + clearRuntimePaneTitle: vi.fn(), + setTabLayout: vi.fn() + } + ;(globalThis as { window?: unknown }).window = { api: { pty: { write: ptyWrite } } } + }) + + afterEach(() => { + // Module-level registries persist across tests; clear them through the + // public prune path so each test starts from an empty parked state. + pruneParkedTerminalWatchers(new Set()) + startedWatchers.length = 0 + exitSubscriptions.length = 0 + vi.clearAllMocks() + ;(globalThis as { window?: unknown }).window = originalWindow + }) + + it('starts one watcher per captured snapshot-backed PTY with the captured pane identity', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) + expect(startedWatchers[0].options).toMatchObject({ + ptyId: PTY_ID, + tabId: TAB_ID, + worktreeId: WORKTREE_ID, + leafId: LEAF_ID, + paneId: 1, + drivesTabTitle: true + }) + expect(startedWatchers[1].options).toMatchObject({ + ptyId: SECOND_PTY_ID, + paneId: 2, + drivesTabTitle: false + }) + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + + it('routes watcher sendInput to window.api.pty.write for the watched PTY', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + startedWatchers[0].options.sendInput('\x1b[?2031;1$y') + expect(ptyWrite).toHaveBeenCalledWith(PTY_ID, '\x1b[?2031;1$y') + }) + + it('skips legacy non-UUID leaf ids instead of throwing in makePaneKey', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1) + expect(startedWatchers[0].options).toMatchObject({ ptyId: SECOND_PTY_ID }) + }) + + it('never starts watchers for remote-runtime or SSH PTYs', () => { + capturePanes([ + { ptyId: 'remote:env-1@@terminal-1', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked({ tabs: [{ id: TAB_ID, ptyId: null }] }) + + expect(startParkedTerminalByteWatcher).not.toHaveBeenCalled() + // Why: the tab is still tracked as parked so debug introspection + // (window.__terminalParkingDebug) reflects every parked tab. + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + + it('keeps existing watchers across repeated syncs of the same parked state', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + syncParked() + + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(1) + expect(startedWatchers[0].dispose).not.toHaveBeenCalled() + }) + + it('disposes the watcher and exit subscription when the tab unparks', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + syncParked({ parkedTabIds: [] }) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('disposes the watcher when the tab closes while parked', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + syncParked({ tabs: [], parkedTabIds: [TAB_ID] }) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('disposes a PTY watcher when that PTY exits while parked', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + const exited = exitSubscriptions.find((entry) => entry.ptyId === PTY_ID) + exited?.callback(0) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(startedWatchers[1].dispose).not.toHaveBeenCalled() + // The tab itself is still parked, only the exited PTY's watcher is gone. + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + }) + + it('seeds each watcher with the pane slot last known runtime title', () => { + mockStoreState.runtimePaneTitlesByTabId = { [TAB_ID]: { 1: '⠋ Build feature' } } + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + expect(startedWatchers[0].options.initialTitle).toBe('⠋ Build feature') + expect(startedWatchers[1].options.initialTitle).toBeUndefined() + }) + + it('drops the parked tab entry when the pty-exit sidecar disposes the last watcher', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + it('synchronously disposes watchers for the given PTY ids without unparking the tab', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + disposeParkedTerminalWatchersForPtyIds([PTY_ID]) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(exitSubscriptions[0].unsubscribe).toHaveBeenCalledTimes(1) + expect(startedWatchers[1].dispose).not.toHaveBeenCalled() + // Why: the entry survives so a sleeping parked tab cannot restart a + // watcher against its stale PTY ids before wake re-mints them. + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + syncParked() + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) + }) + + it('restarts watchers from store layout when the tab PTY was re-minted', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + const remintedPtyId = `${WORKTREE_ID}@@session-after-wake` + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: remintedPtyId } + } + syncParked({ tabs: [{ id: TAB_ID, ptyId: remintedPtyId }] }) + + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(startParkedTerminalByteWatcher).toHaveBeenCalledTimes(2) + expect(startedWatchers[1].options).toMatchObject({ ptyId: remintedPtyId, leafId: LEAF_ID }) + }) + + it('scopes sync disposal to the given worktree', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + const otherPtyId = `${OTHER_WORKTREE_ID}@@session-9` + capturePanes([{ ptyId: otherPtyId, paneId: 1, leafId: SECOND_LEAF_ID, drivesTabTitle: true }], { + tabId: 'tab-other', + worktreeId: OTHER_WORKTREE_ID + }) + syncParked({ + worktreeId: OTHER_WORKTREE_ID, + tabs: [{ id: 'tab-other', ptyId: otherPtyId }], + parkedTabIds: ['tab-other'] + }) + + // Unparking everything in the other worktree must not touch this one. + syncParked({ worktreeId: OTHER_WORKTREE_ID, tabs: [], parkedTabIds: [] }) + expect(getParkedTerminalWatcherTabIds()).toEqual([TAB_ID]) + expect(startedWatchers[0].dispose).not.toHaveBeenCalled() + }) + + it('disposes all of a worktree watchers on worktree teardown and prunes deleted worktrees', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + disposeParkedTerminalWatchersForWorktree(WORKTREE_ID) + expect(startedWatchers[0].dispose).toHaveBeenCalledTimes(1) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + pruneParkedTerminalWatchers(new Set([OTHER_WORKTREE_ID])) + expect(getParkedTerminalWatcherTabIds()).toEqual([]) + }) + + describe('shouldDeferParkedPtyExitTabClose', () => { + const closeTab = vi.fn() + + // Mirrors both hosts' onPtyExit wiring: the guard runs before closeTab. + function hostOnPtyExit(tabId: string, ptyId: string): void { + if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) { + return + } + closeTab(tabId) + } + + it('defers tab close on PTY exit in a parked multi-leaf tab and clears the dead slot', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(closeTab).not.toHaveBeenCalled() + // The dead leaf's runtime-title slot cannot pin worktree status. + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 1) + }) + + it('collapses the exited leaf out of the stored layout when deferring', () => { + // Why (regression, ghost/resurrected pane): a deferred parked exit that + // leaves the leaf and its binding in the stored layout reattaches on + // reveal — the daemon re-creates the exited session id as a fresh shell. + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + hostOnPtyExit(TAB_ID, SECOND_PTY_ID) + + expect(closeTab).not.toHaveBeenCalled() + expect(mockStoreState.setTabLayout).toHaveBeenCalledWith(TAB_ID, { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + }) + }) + + it('keeps exit→closeTab parity for a parked single-leaf tab', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(closeTab).toHaveBeenCalledWith(TAB_ID) + }) + + it('keeps exit→closeTab parity when the tab is not parked', () => { + hostOnPtyExit(TAB_ID, PTY_ID) + + expect(closeTab).toHaveBeenCalledWith(TAB_ID) + }) + + it('collapses the exited leaf via the watcher exit sidecar (no host handler while parked)', () => { + // Why: hosts' onPtyExit runs from a mounted TerminalPane, so an exit + // that lands while parked reaches ONLY the watcher sidecar — it must run + // the layout collapse itself or the leaf resurrects on reveal. + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'vertical', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + + exitSubscriptions.find((entry) => entry.ptyId === SECOND_PTY_ID)?.callback(0) + + expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2) + expect(mockStoreState.setTabLayout).toHaveBeenCalledWith(TAB_ID, { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + }) + }) + + it('does not touch the layout when the last parked watcher exits (tab-level close owns it)', () => { + capturePanes([{ ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + syncParked() + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + + exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0) + + expect(mockStoreState.setTabLayout).not.toHaveBeenCalled() + }) + + it('closes the tab when the last surviving leaf of a parked split exits', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + syncParked() + + // First leaf dies: deferred, then its exit sidecar drops the watcher. + hostOnPtyExit(TAB_ID, PTY_ID) + exitSubscriptions.find((entry) => entry.ptyId === PTY_ID)?.callback(0) + expect(closeTab).not.toHaveBeenCalled() + + hostOnPtyExit(TAB_ID, SECOND_PTY_ID) + expect(closeTab).toHaveBeenCalledWith(TAB_ID) + }) + }) + + describe('canWatcherCoverParkedTerminalTab', () => { + it('rejects a tab with no unmount capture and no layout snapshot', () => { + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + + it('accepts a current capture whose panes are all snapshot-backed', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + true + ) + }) + + it('rejects a capture containing a legacy non-UUID leaf id', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: 'legacy-leaf-1', drivesTabTitle: true }, + { ptyId: SECOND_PTY_ID, paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + + it('rejects a capture containing a PTY without snapshot backing', () => { + capturePanes([ + { ptyId: PTY_ID, paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }, + { ptyId: 'ssh:conn-1@@pty-1', paneId: 2, leafId: SECOND_LEAF_ID, drivesTabTitle: false } + ]) + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + + it('accepts layout-derived candidates when the capture is stale', () => { + capturePanes([{ ptyId: 'old-pty', paneId: 1, leafId: LEAF_ID, drivesTabTitle: true }]) + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + true + ) + }) + + it('rejects layout-derived candidates missing a leaf PTY binding', () => { + mockStoreState.terminalLayoutsByTabId[TAB_ID] = { + root: { + type: 'split', + direction: 'row', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID } + } + expect(canWatcherCoverParkedTerminalTab(WORKTREE_ID, { id: TAB_ID, ptyId: PTY_ID })).toBe( + false + ) + }) + }) +}) + +describe('fallbackParkedPaneCandidates', () => { + it('returns nothing without a layout snapshot', () => { + expect( + fallbackParkedPaneCandidates( + { id: TAB_ID, ptyId: PTY_ID }, + { terminalLayoutsByTabId: {}, runtimePaneTitlesByTabId: {} } + ) + ).toEqual([]) + }) + + it('reuses the single runtime-title slot for a single-pane tab', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: null } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 7: 'working title' } } + } as never) + ).toEqual([{ ptyId: PTY_ID, paneId: 7, leafId: LEAF_ID, drivesTabTitle: true }]) + }) + + it('maps split leaves to layout PTYs with collision-free negative pane ids', () => { + expect( + fallbackParkedPaneCandidates({ id: TAB_ID, ptyId: PTY_ID }, { + terminalLayoutsByTabId: { + [TAB_ID]: { + root: { + type: 'split', + direction: 'row', + first: { type: 'leaf', leafId: LEAF_ID }, + second: { type: 'leaf', leafId: SECOND_LEAF_ID } + }, + activeLeafId: SECOND_LEAF_ID, + ptyIdsByLeafId: { [LEAF_ID]: PTY_ID, [SECOND_LEAF_ID]: SECOND_PTY_ID } + } + }, + runtimePaneTitlesByTabId: { [TAB_ID]: { 1: 'a', 2: 'b' } } + } as never) + ).toEqual([ + { ptyId: PTY_ID, paneId: -1, leafId: LEAF_ID, drivesTabTitle: false }, + { ptyId: SECOND_PTY_ID, paneId: -2, leafId: SECOND_LEAF_ID, drivesTabTitle: true } + ]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts new file mode 100644 index 00000000000..8871bdf4905 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-tab-watchers.ts @@ -0,0 +1,278 @@ +/** + * Parked terminal tab watcher lifecycle. + * + * Why: parking unmounts a tab's TerminalPane, so its PTYs lose the renderer + * byte parsers. This module owns the pane-less replacement: it remembers the + * unmounted panes' identities (pane id / leaf id), starts one + * parked-terminal-byte-watcher per PTY when a tab parks, and disposes them on + * reveal, tab close, PTY exit, or worktree teardown. The bookkeeping maps + * live in terminal-parked-watcher-registry so the terminals store slice can + * dispose watchers without importing this store-coupled module. + * See docs/reference/terminal-hidden-view-parking.md. + */ +import { isTerminalLeafId } from '../../../../shared/stable-pane-id' +import type { TerminalTab } from '../../../../shared/types' +import { useAppStore } from '@/store' +import { collectLeafIdsInOrder } from './terminal-layout-leaf-ids' +import { detachTerminalLayoutLeaf } from './terminal-layout-leaf-detach' +import { subscribeToPtyExit } from './pty-dispatcher' +import { startParkedTerminalByteWatcher } from './parked-terminal-byte-watcher' +import { isSnapshotBackedTerminalPty } from './terminal-hidden-view-parking' +import { + capturedPanesByTabId, + disposeParkedTabWatchers, + parkedWatchersByTabId, + type ParkedTerminalPaneCapture +} from './terminal-parked-watcher-registry' + +// Why: re-exported so park wiring keeps one import surface; the registry +// split exists only to break the store-slice import cycle. +export { + captureParkedTerminalPaneCandidates, + disposeParkedTerminalWatchersForPtyIds, + disposeParkedTerminalWatchersForWorktree, + getParkedTerminalWatcherTabIds, + pruneParkedTerminalWatchers +} from './terminal-parked-watcher-registry' +export type { ParkedTerminalPaneCapture } from './terminal-parked-watcher-registry' + +export type ParkableTerminalTabModel = Pick + +type ParkedPaneFallbackState = { + terminalLayoutsByTabId: ReturnType['terminalLayoutsByTabId'] + runtimePaneTitlesByTabId: ReturnType['runtimePaneTitlesByTabId'] +} + +// Why: if no unmount capture exists (or it predates a PTY respawn), derive +// pane identities from the persisted layout snapshot. Numeric pane ids are +// unknown here: reuse the single existing runtime-title slot when unambiguous +// so a stale "working" title still gets overwritten, otherwise use negative +// slots that can never collide with real PaneManager ids. +export function fallbackParkedPaneCandidates( + tab: ParkableTerminalTabModel, + state: ParkedPaneFallbackState +): ParkedTerminalPaneCapture[] { + const layout = state.terminalLayoutsByTabId[tab.id] + const leafIds = collectLeafIdsInOrder(layout?.root) + if (leafIds.length === 0) { + return [] + } + const ptyIdsByLeafId = layout?.ptyIdsByLeafId ?? {} + const titleSlots = Object.keys(state.runtimePaneTitlesByTabId[tab.id] ?? {}) + const reusableSlot = + leafIds.length === 1 && titleSlots.length === 1 ? Number(titleSlots[0]) : null + return leafIds.map((leafId, index) => ({ + ptyId: ptyIdsByLeafId[leafId] ?? (leafIds.length === 1 ? tab.ptyId : null), + paneId: reusableSlot ?? -(index + 1), + leafId, + drivesTabTitle: layout?.activeLeafId ? leafId === layout.activeLeafId : index === 0 + })) +} + +// Why: unmount captures and layout fallbacks must resolve identically for the +// watcher start path and the park-eligibility coverage check, or a tab could +// pass the check and then start with different (uncoverable) candidates. +function resolveParkedTerminalPaneCandidates( + tab: ParkableTerminalTabModel, + state: ParkedPaneFallbackState +): ParkedTerminalPaneCapture[] { + const captured = capturedPanesByTabId.get(tab.id) + // Why: a capture that no longer mentions the tab's current PTY is stale + // (the PTY was re-minted since the unmount); fall back to the layout. + const capturedIsCurrent = + captured !== undefined && + captured.panes.length > 0 && + (tab.ptyId === null || captured.panes.some((pane) => pane.ptyId === tab.ptyId)) + return capturedIsCurrent ? captured.panes : fallbackParkedPaneCandidates(tab, state) +} + +/** + * Whether the parked byte watchers can fully cover this tab's PTYs (some + * candidate exists and every candidate has a snapshot-backed PTY bound to a + * valid leaf). Hosts must refuse to park a tab that fails this check — + * parking it would silently drop bell/title/completion side effects, the + * exact failure that sank the first parking attempt. + */ +export function canWatcherCoverParkedTerminalTab( + worktreeId: string, + tab: ParkableTerminalTabModel +): boolean { + const panes = resolveParkedTerminalPaneCandidates(tab, useAppStore.getState()) + return ( + panes.length > 0 && + panes.every( + (pane) => + pane.ptyId !== null && + isTerminalLeafId(pane.leafId) && + isSnapshotBackedTerminalPty(pane.ptyId, worktreeId) + ) + ) +} + +function startParkedTabWatchers(worktreeId: string, tab: ParkableTerminalTabModel): void { + const state = useAppStore.getState() + const panes = resolveParkedTerminalPaneCandidates(tab, state) + const disposersByPtyId = new Map void>() + const paneIdByPtyId = new Map() + for (const pane of panes) { + const ptyId = pane.ptyId + // Why: the park policy already excludes non-snapshot-backed PTYs, but the + // tab model can change between the park decision and this effect — guard + // again so remote-runtime/SSH PTYs never get a local watcher. Legacy + // non-UUID leaf ids are skipped because makePaneKey throws on them. + if ( + !ptyId || + disposersByPtyId.has(ptyId) || + !isTerminalLeafId(pane.leafId) || + !isSnapshotBackedTerminalPty(ptyId, worktreeId) + ) { + continue + } + const initialTitle = state.runtimePaneTitlesByTabId[tab.id]?.[pane.paneId] + const disposeWatcher = startParkedTerminalByteWatcher({ + ptyId, + tabId: tab.id, + worktreeId, + leafId: pane.leafId, + paneId: pane.paneId, + drivesTabTitle: pane.drivesTabTitle, + // Why: seed the watcher's agent tracker with the pane's last known + // title so an agent already working at park time still notifies when + // it finishes while parked. + ...(initialTitle !== undefined ? { initialTitle } : {}), + // Why: no pane transport exists while parked; write straight to the + // PTY, the same channel background agent launches use. + sendInput: (data) => window.api.pty.write(ptyId, data) + }) + // Why: a PTY that exits while parked has no pane to run exit cleanup; at + // minimum its watcher must not outlive it. + const unsubscribeExit = subscribeToPtyExit(ptyId, () => { + // Why: while parked this sidecar is the ONLY exit observer — the hosts' + // onPtyExit runs from a mounted TerminalPane. Run the observed-exit + // teardown's data half here for a multi-leaf tab, or the dead leaf's + // stale binding reattaches on reveal and the daemon re-creates the + // exited session id as a fresh shell, resurrecting the pane. + if (disposersByPtyId.size > 1) { + useAppStore.getState().clearRuntimePaneTitle(tab.id, pane.paneId) + collapseParkedExitedLeaf(tab.id, ptyId) + } + disposersByPtyId.get(ptyId)?.() + disposersByPtyId.delete(ptyId) + // Why: with the last watcher gone there is nothing left to watch or + // dispose; dropping the entry keeps the registry bounded to parked + // tabs that still hold live PTYs. + const entry = parkedWatchersByTabId.get(tab.id) + if (disposersByPtyId.size === 0 && entry?.disposersByPtyId === disposersByPtyId) { + parkedWatchersByTabId.delete(tab.id) + } + }) + paneIdByPtyId.set(ptyId, pane.paneId) + disposersByPtyId.set(ptyId, () => { + unsubscribeExit() + disposeWatcher() + }) + } + // Why: tracked even with zero watchers so parked-state introspection + // (window.__terminalParkingDebug) reflects every parked tab. + parkedWatchersByTabId.set(tab.id, { + worktreeId, + tabPtyId: tab.ptyId, + paneIdByPtyId, + disposersByPtyId + }) +} + +/** + * Hosts call this from their onPtyExit handlers before closing the tab. + * Returns true when the close must be deferred: a parked tab has no + * PaneManager to promote split siblings, so the live exit path degenerates to + * "close the whole tab" — which would kill the surviving sibling panes. The + * reveal remount handles dead PTYs per leaf instead. Single-leaf parked tabs + * return false so exit→closeTab parity is preserved. Also clears the dead + * leaf's runtime-title slot so a stale title cannot pin worktree status. + */ +export function shouldDeferParkedPtyExitTabClose(tabId: string, ptyId: string): boolean { + const entry = parkedWatchersByTabId.get(tabId) + if (!entry) { + return false + } + const paneId = entry.paneIdByPtyId.get(ptyId) + if (paneId !== undefined) { + useAppStore.getState().clearRuntimePaneTitle(tabId, paneId) + } + const remaining = entry.disposersByPtyId.size + if (remaining === 0) { + return false + } + // Why: this runs from the PTY exit handler, before the exit sidecar above + // removes the dead PTY's watcher — so the watcher count still includes the + // exiting PTY. More than one watcher (or an exit for an unwatched PTY) + // means live sibling leaves remain. + const defer = remaining > 1 || !entry.disposersByPtyId.has(ptyId) + if (defer) { + collapseParkedExitedLeaf(tabId, ptyId) + } + return defer +} + +// Why: deferring the tab close is not enough — a stale leaf binding left in +// the stored layout reattaches on reveal, and the daemon re-creates the exited +// session id as a fresh shell, resurrecting a pane whose shell already ended. +// With no PaneManager mounted, the observed-exit teardown's data half runs +// here instead: collapse the leaf out of the stored layout so the reveal +// replays only the surviving panes. +function collapseParkedExitedLeaf(tabId: string, ptyId: string): void { + const state = useAppStore.getState() + const layout = state.terminalLayoutsByTabId[tabId] + const leafId = + capturedPanesByTabId.get(tabId)?.panes.find((pane) => pane.ptyId === ptyId)?.leafId ?? + Object.entries(layout?.ptyIdsByLeafId ?? {}).find(([, boundPtyId]) => boundPtyId === ptyId)?.[0] + if (!leafId) { + return + } + const detached = detachTerminalLayoutLeaf(layout, leafId) + if (detached) { + state.setTabLayout(tabId, detached.sourceLayout) + } +} + +/** + * Reconciles watchers for one worktree against its rendered parked set. + * Callers run this from an effect keyed on the committed render state, so + * disposal lands in the same effect flush as a reveal remount (before any + * PTY data IPC can be delivered) and start lands after the park unmount. + */ +export function syncParkedTerminalTabWatchers(args: { + worktreeId: string + tabs: readonly ParkableTerminalTabModel[] + parkedTabIds: ReadonlySet +}): void { + const liveTabIds = new Set(args.tabs.map((tab) => tab.id)) + for (const [tabId, entry] of parkedWatchersByTabId) { + if (entry.worktreeId !== args.worktreeId) { + continue + } + if (!args.parkedTabIds.has(tabId) || !liveTabIds.has(tabId)) { + disposeParkedTabWatchers(tabId) + } + } + // Why: captures for closed tabs have no future park/reveal; drop them so + // the registry stays bounded by live tabs. + for (const [tabId, capture] of capturedPanesByTabId) { + if (capture.worktreeId === args.worktreeId && !liveTabIds.has(tabId)) { + capturedPanesByTabId.delete(tabId) + } + } + for (const tab of args.tabs) { + if (!args.parkedTabIds.has(tab.id)) { + continue + } + const entry = parkedWatchersByTabId.get(tab.id) + if (entry && entry.tabPtyId !== tab.ptyId) { + disposeParkedTabWatchers(tab.id) + } + if (!parkedWatchersByTabId.has(tab.id)) { + startParkedTabWatchers(args.worktreeId, tab) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts new file mode 100644 index 00000000000..d6dd76e817a --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parked-watcher-registry.ts @@ -0,0 +1,107 @@ +/** + * Parked terminal watcher registry (store-free bookkeeping). + * + * Why a separate module: shutdownWorktreeTerminals (a store slice) must + * synchronously dispose parked watchers, but the watcher lifecycle module + * imports the store — a slice importing it would re-enter store creation + * mid-evaluation. Keeping the maps and pure disposal here lets the slice + * import cycle-free, mirroring how pty-dispatcher exports its handler maps. + */ + +export type ParkedTerminalPaneCapture = { + ptyId: string | null + /** PaneManager numeric pane id the live pane used for runtime titles. */ + paneId: number + /** Stable terminal-layout leaf UUID (paneKey attribution). */ + leafId: string + drivesTabTitle: boolean +} + +export type CapturedTabPanes = { worktreeId: string; panes: ParkedTerminalPaneCapture[] } + +export const capturedPanesByTabId = new Map() + +// Why: PaneManager pane ids die with the unmounted pane, but the watcher must +// keep writing the exact runtime-title slots the live pane used — a different +// slot would strand a stale "working" title that pins worktree status. +// TerminalPane unmount records the identities here for the park wiring. +export function captureParkedTerminalPaneCandidates( + tabId: string, + worktreeId: string, + panes: ParkedTerminalPaneCapture[] +): void { + capturedPanesByTabId.set(tabId, { worktreeId, panes }) +} + +export type ParkedTabWatcherEntry = { + worktreeId: string + /** Tab-level ptyId at watcher start; a change means the PTY was re-minted + * (e.g. wake respawn) and the watchers must restart against fresh ids. */ + tabPtyId: string | null + /** Runtime-title slot each watcher writes, so parked PTY-exit handling can + * clear the dead leaf's slot (no live pane will ever overwrite it). */ + paneIdByPtyId: Map + disposersByPtyId: Map void> +} + +export const parkedWatchersByTabId = new Map() + +export function getParkedTerminalWatcherTabIds(): string[] { + return Array.from(parkedWatchersByTabId.keys()) +} + +export function disposeParkedTabWatchers(tabId: string): void { + const entry = parkedWatchersByTabId.get(tabId) + if (!entry) { + return + } + parkedWatchersByTabId.delete(tabId) + for (const dispose of entry.disposersByPtyId.values()) { + dispose() + } + entry.disposersByPtyId.clear() +} + +/** + * Synchronously disposes any parked watcher subscribed to these PTYs. + * shutdownWorktreeTerminals silences the live transports' final teardown + * flush via unregisterPtyDataHandlers, but parked watchers ride the + * dispatcher SIDECAR channel that call does not touch — without this, the + * flush still marks unread and arms notification timers for a worktree that + * is already sleeping or deleted. The tab entries are kept so a sleeping + * parked tab does not restart watchers against its stale PTY ids; wake + * re-mints the ids and the sync path restarts watchers then. + */ +export function disposeParkedTerminalWatchersForPtyIds(ptyIds: readonly string[]): void { + for (const entry of parkedWatchersByTabId.values()) { + for (const ptyId of ptyIds) { + const dispose = entry.disposersByPtyId.get(ptyId) + if (dispose) { + entry.disposersByPtyId.delete(ptyId) + dispose() + } + } + } +} + +export function disposeParkedTerminalWatchersForWorktree(worktreeId: string): void { + for (const [tabId, entry] of parkedWatchersByTabId) { + if (entry.worktreeId === worktreeId) { + disposeParkedTabWatchers(tabId) + } + } +} + +/** Drops watchers and captures for worktrees that no longer exist. */ +export function pruneParkedTerminalWatchers(liveWorktreeIds: ReadonlySet): void { + for (const [tabId, entry] of parkedWatchersByTabId) { + if (!liveWorktreeIds.has(entry.worktreeId)) { + disposeParkedTabWatchers(tabId) + } + } + for (const [tabId, capture] of capturedPanesByTabId) { + if (!liveWorktreeIds.has(capture.worktreeId)) { + capturedPanesByTabId.delete(tabId) + } + } +} diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts new file mode 100644 index 00000000000..40e226cf5df --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +type MockE2EConfig = { exposeStore: boolean; terminalParkingDelayMs: number | null } + +let mockE2EConfig: MockE2EConfig + +vi.mock('@/lib/e2e-config', () => ({ + get e2eConfig() { + return mockE2EConfig + } +})) + +vi.mock('./terminal-parked-tab-watchers', () => ({ + getParkedTerminalWatcherTabIds: () => ['tab-parked'] +})) + +const originalWindow = (globalThis as { window?: unknown }).window + +type TerminalParkingE2EOverridesModule = { + getTerminalParkingPolicyOverrides: () => { + coldParkDelayMs?: number + hotRetainMs?: number + hotRetainLimit?: number + } + registerTerminalParkingDebugHandle: () => void +} + +async function importOverridesModule(): Promise { + vi.resetModules() + return import('./terminal-parking-e2e-overrides') +} + +describe('getTerminalParkingPolicyOverrides', () => { + beforeEach(() => { + mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null } + delete (globalThis as { window?: unknown }).window + }) + + afterEach(() => { + ;(globalThis as { window?: unknown }).window = originalWindow + }) + + it('ignores the delay override outside e2e (exposeStore off)', async () => { + mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: 500 } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({}) + }) + + it('maps the e2e delay to BOTH coldParkDelayMs and hotRetainMs', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({ + coldParkDelayMs: 500, + hotRetainMs: 500 + }) + }) + + it('returns no overrides when no delay is configured', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: null } + const { getTerminalParkingPolicyOverrides } = await importOverridesModule() + expect(getTerminalParkingPolicyOverrides()).toEqual({}) + }) + + it('registers window.__terminalParkingDebug on import under exposeStore', async () => { + mockE2EConfig = { exposeStore: true, terminalParkingDelayMs: 500 } + const testWindow: { + __terminalParkingDebug?: { parkDelayMs: number; parkedTabIds: () => string[] } + } = {} + ;(globalThis as { window?: unknown }).window = testWindow + await importOverridesModule() + expect(testWindow.__terminalParkingDebug?.parkDelayMs).toBe(500) + expect(testWindow.__terminalParkingDebug?.parkedTabIds()).toEqual(['tab-parked']) + }) + + it('does not register the debug handle outside e2e', async () => { + mockE2EConfig = { exposeStore: false, terminalParkingDelayMs: null } + const testWindow: { __terminalParkingDebug?: unknown } = {} + ;(globalThis as { window?: unknown }).window = testWindow + await importOverridesModule() + expect(testWindow.__terminalParkingDebug).toBeUndefined() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts new file mode 100644 index 00000000000..1717b170bb9 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parking-e2e-overrides.ts @@ -0,0 +1,34 @@ +import { e2eConfig } from '@/lib/e2e-config' +import { + TERMINAL_TAB_COLD_PARK_DELAY_MS, + type TerminalColdParkPolicyOverrides +} from './terminal-hidden-view-parking' +import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers' + +// Why: ORCA_E2E_TERMINAL_PARKING_DELAY_MS must shrink BOTH the cold-park +// hysteresis and the hot-retain window — recently hidden tabs otherwise sit +// in the hot-retain working set for 5 minutes and never park within a test +// run. Gated on exposeStore so packaged builds ignore stray env vars. +export function getTerminalParkingPolicyOverrides(): TerminalColdParkPolicyOverrides { + const delayMs = e2eConfig.exposeStore ? e2eConfig.terminalParkingDelayMs : null + return typeof delayMs === 'number' && Number.isFinite(delayMs) && delayMs > 0 + ? { coldParkDelayMs: delayMs, hotRetainMs: delayMs } + : {} +} + +export function registerTerminalParkingDebugHandle(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + window.__terminalParkingDebug = { + parkDelayMs: + getTerminalParkingPolicyOverrides().coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS, + parkedTabIds: () => getParkedTerminalWatcherTabIds() + } +} + +// Why: the parking e2e spec gates on window.__terminalParkingDebug existing +// shortly after launch. This module is statically imported by the park +// wiring, so registering at module load makes the handle visible before any +// tab parks. +registerTerminalParkingDebugHandle() diff --git a/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts b/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts new file mode 100644 index 00000000000..9190a9bdfa6 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.test.ts @@ -0,0 +1,108 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' +import { + _resetParserHandlerReportsForTests, + guardParserHandler +} from './terminal-parser-handler-guard' + +const mocks = vi.hoisted(() => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb +})) + +beforeEach(() => { + mocks.recordRendererCrashBreadcrumb.mockClear() + _resetParserHandlerReportsForTests() +}) + +afterEach(() => { + vi.useRealTimers() +}) + +describe('guardParserHandler', () => { + it('passes arguments and return value through for healthy handlers', () => { + const handler = vi.fn((data: string) => data === 'handled') + const guarded = guardParserHandler('test-handler', handler) + expect(guarded('handled')).toBe(true) + expect(guarded('other')).toBe(false) + expect(handler).toHaveBeenCalledTimes(2) + expect(mocks.recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + }) + + it('degrades a throwing handler to "not handled" and reports a breadcrumb', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const guarded = guardParserHandler('exploding-handler', () => { + throw new TypeError('synthetic handler failure') + }) + expect(guarded()).toBe(false) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_parser_handler_error', + expect.objectContaining({ + handler: 'exploding-handler', + errorName: 'TypeError', + errorMessage: 'synthetic handler failure' + }) + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('caps repeated reports per handler', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const guarded = guardParserHandler('spammy-handler', () => { + throw new Error('always fails') + }) + for (let i = 0; i < 20; i++) { + guarded() + } + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledTimes(5) + } finally { + errorSpy.mockRestore() + } + }) + + it('keeps the real xterm write pipeline alive through a throwing handler (inverse of the wedge repro)', () => { + // Why: xterm-write-buffer-stall.repro.test.ts proves an UNguarded throwing + // handler permanently wedges the WriteBuffer. This is the fix's proof: + // the same poison sequence through a GUARDED handler keeps completing + // writes. + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + vi.useFakeTimers() + const term = new Terminal({ allowProposedApi: true }) + const completed: string[] = [] + term.parser.registerCsiHandler( + { final: 'z' }, + guardParserHandler('poisoned-csi', () => { + throw new Error('synthetic parser handler failure') + }) + ) + + term.write('\x1b[z', () => { + completed.push('poisoned') + }) + term.write('after', () => { + completed.push('after') + }) + expect(() => vi.runAllTimers()).not.toThrow() + + term.write('later', () => { + completed.push('later') + }) + vi.runAllTimers() + expect(completed).toEqual(['poisoned', 'after', 'later']) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_parser_handler_error', + expect.objectContaining({ handler: 'poisoned-csi' }) + ) + } finally { + errorSpy.mockRestore() + } + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.ts b/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.ts new file mode 100644 index 00000000000..cb6b5bc51bf --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-parser-handler-guard.ts @@ -0,0 +1,44 @@ +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' + +// Why: xterm's EscapeSequenceParser invokes custom CSI/OSC handlers +// synchronously inside WriteBuffer._innerWrite, which has no try/catch. A +// handler throw skips the loop's tail re-schedule, and write() only re-arms +// on an EMPTY buffer — one throw permanently freezes the pane (output stops; +// a pending replay guard never releases and silently eats keystrokes). +// Verified against vendored xterm 6.1.0-beta.287 in +// xterm-write-buffer-stall.repro.test.ts. Same escape class as +// terminal-link-provider-guard.ts, applied to parser handlers. +const MAX_REPORTS_PER_HANDLER = 5 +const reportCountsByHandler = new Map() + +/** + * Wrap a custom parser handler so a synchronous throw is reported and + * degraded to "not handled" (xterm falls through to the previous/default + * handler) instead of wedging the terminal's write pipeline. + */ +export function guardParserHandler( + handlerName: string, + handler: (...args: HandlerArgs) => boolean +): (...args: HandlerArgs) => boolean { + return (...args: HandlerArgs): boolean => { + try { + return handler(...args) + } catch (error: unknown) { + const reported = reportCountsByHandler.get(handlerName) ?? 0 + if (reported < MAX_REPORTS_PER_HANDLER) { + reportCountsByHandler.set(handlerName, reported + 1) + console.error(`[terminal] parser handler "${handlerName}" threw`, error) + recordRendererCrashBreadcrumb('terminal_parser_handler_error', { + handler: handlerName, + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error) + }) + } + return false + } + } +} + +export function _resetParserHandlerReportsForTests(): void { + reportCountsByHandler.clear() +} diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts new file mode 100644 index 00000000000..3280ca36dd1 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.test.ts @@ -0,0 +1,123 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: false } })) + +describe('terminal-pty-ack-gate cumulative totals', () => { + const ackDataMock = vi.fn() + + beforeEach(() => { + // Why: the cumulative totals are module state; a fresh module per test + // mirrors a fresh renderer page (renderer lifecycle reset). + vi.resetModules() + ackDataMock.mockClear() + ;(window as unknown as { api: unknown }).api = { pty: { ackData: ackDataMock } } + }) + + afterEach(() => { + delete (window as unknown as { api?: unknown }).api + }) + + async function loadAckGate() { + return await import('./terminal-pty-ack-gate') + } + + it('sends monotonic cumulative totals alongside per-chunk deltas', async () => { + const { ackPtyData, getProcessedPtyCharTotals } = await loadAckGate() + + ackPtyData('pty-a', 5) + ackPtyData('pty-a', 7) + ackPtyData('pty-b', 3) + + expect(ackDataMock).toHaveBeenNthCalledWith(1, 'pty-a', 5, 5) + expect(ackDataMock).toHaveBeenNthCalledWith(2, 'pty-a', 7, 12) + expect(ackDataMock).toHaveBeenNthCalledWith(3, 'pty-b', 3, 3) + expect(getProcessedPtyCharTotals()).toEqual({ 'pty-a': 12, 'pty-b': 3 }) + }) + + it('clears a PTY total so a reused id restarts from zero on both sides', async () => { + const { ackPtyData, clearProcessedPtyCharTotal, getProcessedPtyCharTotals } = + await loadAckGate() + + ackPtyData('pty-a', 9) + clearProcessedPtyCharTotal('pty-a') + + expect(getProcessedPtyCharTotals()).toEqual({}) + + ackPtyData('pty-a', 4) + expect(ackDataMock).toHaveBeenLastCalledWith('pty-a', 4, 4) + }) +}) + +describe('terminal-pty-ack-gate parse-deferred crediting', () => { + const ackDataMock = vi.fn() + + beforeEach(() => { + vi.resetModules() + ackDataMock.mockClear() + ;(window as unknown as { api: unknown }).api = { pty: { ackData: ackDataMock } } + }) + + afterEach(() => { + delete (window as unknown as { api?: unknown }).api + }) + + async function loadAckGate() { + return await import('./terminal-pty-ack-gate') + } + + it('settles an unclaimed delivery credit at return', async () => { + const { deliverPtyDataWithDeferredAck } = await loadAckGate() + + deliverPtyDataWithDeferredAck('pty-a', 42, () => {}) + + expect(ackDataMock).toHaveBeenCalledWith('pty-a', 42, 42) + }) + + it('defers a claimed credit to the scheduler callback and fires once', async () => { + const { deliverPtyDataWithDeferredAck, takeCurrentPtyDeliveryAckCredit } = await loadAckGate() + let credit: (() => void) | null = null + + deliverPtyDataWithDeferredAck('pty-a', 10, () => { + credit = takeCurrentPtyDeliveryAckCredit() + }) + + // Claimed: nothing credited at delivery return. + expect(ackDataMock).not.toHaveBeenCalled() + credit!() + expect(ackDataMock).toHaveBeenCalledWith('pty-a', 10, 10) + // Fire-once: split slices / discard paths may re-invoke harmlessly. + credit!() + expect(ackDataMock).toHaveBeenCalledTimes(1) + }) + + it('hands out the credit only once per delivery', async () => { + const { deliverPtyDataWithDeferredAck, takeCurrentPtyDeliveryAckCredit } = await loadAckGate() + let first: (() => void) | null = null + let second: (() => void) | null = null + + deliverPtyDataWithDeferredAck('pty-a', 5, () => { + first = takeCurrentPtyDeliveryAckCredit() + second = takeCurrentPtyDeliveryAckCredit() + }) + + expect(first).not.toBeNull() + expect(second).toBeNull() + }) + + it('returns null outside a delivery', async () => { + const { takeCurrentPtyDeliveryAckCredit } = await loadAckGate() + expect(takeCurrentPtyDeliveryAckCredit()).toBeNull() + }) + + it('settles the credit when the handler throws so the PTY never wedges', async () => { + const { deliverPtyDataWithDeferredAck } = await loadAckGate() + + expect(() => + deliverPtyDataWithDeferredAck('pty-a', 7, () => { + throw new Error('bad sidecar') + }) + ).toThrow('bad sidecar') + expect(ackDataMock).toHaveBeenCalledWith('pty-a', 7, 7) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts index bea658434be..31733aebc47 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pty-ack-gate.ts @@ -18,13 +18,26 @@ type E2eTerminalPtyAckGateWindow = Window & { const e2eTerminalAckGatePtyIds = new Set() const e2eTerminalAckGateHeldChars = new Map() +// Why: monotonic per-PTY totals of processed chars, mirrored to main as +// TCP-style cumulative ACKs so a lost ACK message never becomes permanent +// in-flight debt. Cleared on pty:exit so a reused id restarts aligned with +// main's fresh accounting; a renderer reload resets it with the page. +const processedPtyCharTotals = new Map() + +function sendPtyAck(ptyId: string, chars: number): void { + const processedChars = (processedPtyCharTotals.get(ptyId) ?? 0) + chars + processedPtyCharTotals.set(ptyId, processedChars) + // Why: keep the legacy per-chunk delta alongside the cumulative total so an + // older main (dev hot-reload mix) still credits deltas. + window.api.pty.ackData?.(ptyId, chars, processedChars) +} function releaseE2eTerminalAckGate(): void { const held = Array.from(e2eTerminalAckGateHeldChars.entries()) e2eTerminalAckGatePtyIds.clear() e2eTerminalAckGateHeldChars.clear() for (const [ptyId, chars] of held) { - window.api.pty.ackData?.(ptyId, chars) + sendPtyAck(ptyId, chars) } } @@ -58,9 +71,81 @@ export function exposeE2eTerminalPtyAckGate(): void { } export function ackPtyData(ptyId: string, chars: number): void { + // Why: held e2e-gate chars stay out of the cumulative total too, so a + // delivery-resync probe cannot leak them past the simulated backpressure. if (e2eTerminalAckGatePtyIds.has(ptyId)) { e2eTerminalAckGateHeldChars.set(ptyId, (e2eTerminalAckGateHeldChars.get(ptyId) ?? 0) + chars) return } - window.api.pty.ackData?.(ptyId, chars) + sendPtyAck(ptyId, chars) +} + +// ─── Parse-deferred ACK crediting ─────────────────────────────────── +// Why: ACKing at dispatcher enqueue made main's 512KB in-flight window mean +// "bytes RECEIVED", not "bytes PARSED" — under flood the renderer's write +// queue grew unbounded behind instant ACKs, main saw no backpressure, crossed +// its pending cap, and dropped output (rc.7.perf DSR timeouts). Crediting is +// now deferred to the output scheduler's consume point, so in-flight becomes +// true parse backpressure and main's producer flow control pauses the shell +// instead of dropping. + +type DeferredPtyAckCredit = { + ptyId: string + chars: number + claimed: boolean + credited: boolean +} + +let currentDeliveryCredit: DeferredPtyAckCredit | null = null + +function creditDeferredPtyAck(credit: DeferredPtyAckCredit): void { + // Why fire-once: split queue chunks and discard paths may both touch the + // same delivery; the invariant is exactly one credit per delivered chunk. + if (credit.credited) { + return + } + credit.credited = true + ackPtyData(credit.ptyId, credit.chars) +} + +/** Runs one pty:data delivery with a parse-deferred ACK credit. If the + * handler hands bytes to the output scheduler, the claimed credit fires when + * the scheduler consumes (writes or discards) them; any credit left + * unclaimed fires here at return, so a chunk the handler drops outright can + * never leave main's in-flight window permanently open. */ +export function deliverPtyDataWithDeferredAck( + ptyId: string, + chars: number, + deliver: () => void +): void { + const credit: DeferredPtyAckCredit = { ptyId, chars, claimed: false, credited: false } + currentDeliveryCredit = credit + try { + deliver() + } finally { + currentDeliveryCredit = null + if (!credit.claimed) { + creditDeferredPtyAck(credit) + } + } +} + +/** Claims the in-progress delivery's credit for the output scheduler. Returns + * a fire-once callback, or null when outside a delivery or already claimed + * (only the FIRST scheduler write of a delivery carries the credit). */ +export function takeCurrentPtyDeliveryAckCredit(): (() => void) | null { + const credit = currentDeliveryCredit + if (!credit || credit.claimed) { + return null + } + credit.claimed = true + return () => creditDeferredPtyAck(credit) +} + +export function getProcessedPtyCharTotals(): Record { + return Object.fromEntries(processedPtyCharTotals) +} + +export function clearProcessedPtyCharTotal(ptyId: string): void { + processedPtyCharTotals.delete(ptyId) } diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts index 07770304a26..7acbd6a5ff9 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.test.ts @@ -6,6 +6,25 @@ import { getUtf8ByteLength } from '../../../../shared/utf8-byte-limits' const LEAF_ID = '11111111-1111-4111-8111-111111111111' as const const LEAF_ID_2 = '22222222-2222-4222-8222-222222222222' as const +// Why: capture now appends an absolute cursor restore (see +// terminal-serialize-absolute-cursor.ts), so pane mocks must expose the +// cursor fields and content expectations carry the home-position CUP suffix. +const CURSOR_HOME = '\x1b[1;1H' + +function mockTerminal(scrollback: number): { + options: { scrollback: number } + cols: number + rows: number + buffer: { active: { cursorX: number; cursorY: number } } +} { + return { + options: { scrollback }, + cols: 80, + rows: 24, + buffer: { active: { cursorX: 0, cursorY: 0 } } + } +} + const mocks = vi.hoisted(() => ({ flushTerminalOutput: vi.fn() })) @@ -76,7 +95,7 @@ describe('captureTerminalShutdownLayout', () => { const { captureTerminalShutdownLayout } = await import('./terminal-shutdown-layout-capture') const order: string[] = [] const terminal = { - options: { scrollback: 1_000 }, + ...mockTerminal(1_000), pendingOutput: '' } const pane = { @@ -115,7 +134,7 @@ describe('captureTerminalShutdownLayout', () => { root: { type: 'leaf', leafId: LEAF_ID }, activeLeafId: LEAF_ID, expandedLeafId: null, - buffersByLeafId: { [LEAF_ID]: 'snapshot:queued-before-quit' }, + buffersByLeafId: { [LEAF_ID]: `snapshot:queued-before-quit${CURSOR_HOME}` }, ptyIdsByLeafId: { [LEAF_ID]: 'pty-1' }, titlesByLeafId: { [LEAF_ID]: 'build logs' } }) @@ -127,7 +146,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 50_000 } }, + terminal: mockTerminal(50_000), serializeAddon: { serialize: vi.fn(() => 'x'.repeat(512 * 1024)) } @@ -166,7 +185,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 512 } }, + terminal: mockTerminal(512), serializeAddon: { serialize: vi.fn((options?: { scrollback?: number }) => multibyteRow.repeat(options?.scrollback ?? 0) @@ -192,7 +211,12 @@ describe('captureTerminalShutdownLayout', () => { expect(getUtf8ByteLength(buffer)).toBeLessThanOrEqual( TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT ) - expect(buffer).toHaveLength(TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT / 2) + // Each 'e-acute' row is 2048 UTF-8 bytes; the CUP suffix joins the byte + // accounting, so one fewer row fits than the bare limit would allow. + const fittingRows = Math.floor( + (TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT - CURSOR_HOME.length) / 2048 + ) + expect(buffer).toHaveLength(fittingRows * 1024 + CURSOR_HOME.length) }) it('does not preserve prior scrollback buffers or refs for a cleared leaf', async () => { @@ -201,7 +225,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } @@ -239,7 +263,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => 'dead scrollback') } @@ -248,7 +272,7 @@ describe('captureTerminalShutdownLayout', () => { id: 2, leafId: LEAF_ID_2, stablePaneId: LEAF_ID_2, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => 'live scrollback') } @@ -275,8 +299,8 @@ describe('captureTerminalShutdownLayout', () => { expect(layout.activeLeafId).toBe(LEAF_ID_2) expect(layout.ptyIdsByLeafId).toEqual({ [LEAF_ID_2]: 'pty-live' }) expect(layout.buffersByLeafId).toEqual({ - [LEAF_ID]: 'dead scrollback', - [LEAF_ID_2]: 'live scrollback' + [LEAF_ID]: `dead scrollback${CURSOR_HOME}`, + [LEAF_ID_2]: `live scrollback${CURSOR_HOME}` }) }) @@ -286,7 +310,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => 'first scrollback') } @@ -295,7 +319,7 @@ describe('captureTerminalShutdownLayout', () => { id: 2, leafId: LEAF_ID_2, stablePaneId: LEAF_ID_2, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => 'second scrollback') } @@ -335,7 +359,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } @@ -344,7 +368,7 @@ describe('captureTerminalShutdownLayout', () => { id: 2, leafId: LEAF_ID_2, stablePaneId: LEAF_ID_2, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } @@ -373,7 +397,7 @@ describe('captureTerminalShutdownLayout', () => { id: 1, leafId: LEAF_ID, stablePaneId: LEAF_ID, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } @@ -382,7 +406,7 @@ describe('captureTerminalShutdownLayout', () => { id: 2, leafId: LEAF_ID_2, stablePaneId: LEAF_ID_2, - terminal: { options: { scrollback: 1_000 } }, + terminal: mockTerminal(1_000), serializeAddon: { serialize: vi.fn(() => '') } diff --git a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts index c0ee1de1713..4e6d1698787 100644 --- a/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts +++ b/src/renderer/src/components/terminal-pane/terminal-shutdown-layout-capture.ts @@ -6,6 +6,7 @@ import { serializeTerminalLayout } from './layout-serialization' import { mergeCapturedLeafState } from './merge-captured-leaf-state' import { resolveTerminalLayoutActiveLeafId } from './terminal-layout-leaf-ids' import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits' +import { serializeWithAbsoluteCursor } from '../../../../shared/terminal-serialize-absolute-cursor' import { measureUtf8ByteLength } from '../../../../shared/utf8-byte-limits' const MAX_BUFFER_BYTES = TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT @@ -66,7 +67,12 @@ export function captureTerminalShutdownLayout({ flushTerminalOutput(pane.terminal) const leafId = pane.leafId let scrollback = pane.terminal.options.scrollback ?? 10_000 - let serialized = pane.serializeAddon.serialize({ scrollback }) + // Why serializeWithAbsoluteCursor: these buffers replay into fresh + // xterms on session restore, and SerializeAddon's relative cursor + // restore lands one column short after a wrap-pending final row. + let serialized = serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { + scrollback + }) // Why: SSH sleep keeps this string in session JSON; cap by UTF-8 // bytes so non-ASCII scrollback cannot bypass the intended bound. if (!fitsSessionScrollbackByteLimit(serialized) && scrollback > 1) { @@ -75,7 +81,9 @@ export function captureTerminalShutdownLayout({ let best = '' while (lo <= hi) { const mid = Math.floor((lo + hi) / 2) - const attempt = pane.serializeAddon.serialize({ scrollback: mid }) + const attempt = serializeWithAbsoluteCursor(pane.serializeAddon, pane.terminal, { + scrollback: mid + }) if (fitsSessionScrollbackByteLimit(attempt)) { best = attempt lo = mid + 1 diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts new file mode 100644 index 00000000000..84a91c2e7c4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.test.ts @@ -0,0 +1,509 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { TerminalSideEffectBatch } from '../../../../shared/terminal-side-effect-facts' +import { + _dispatchTerminalSideEffectBatchForTest, + _resetTerminalSideEffectFactConsumersForTest, + isMainTerminalSideEffectAuthorityForPty, + registerTerminalSideEffectFactConsumer, + type TerminalSideEffectFactConsumerCallbacks +} from './terminal-side-effect-facts-handler' + +const PTY_ID = 'wt-1#1' + +function createCallbackRecorder(): { + callbacks: TerminalSideEffectFactConsumerCallbacks + events: unknown[][] +} { + const events: unknown[][] = [] + return { + events, + callbacks: { + onTitleChange: (normalizedTitle, rawTitle) => + events.push(['title', normalizedTitle, rawTitle]), + onBell: () => events.push(['bell']), + onAgentBecameIdle: (title) => events.push(['idle', title]), + onAgentBecameWorking: () => events.push(['working']), + onAgentExited: () => events.push(['exited']) + } + } +} + +function batch( + facts: TerminalSideEffectBatch['facts'], + options: Partial = {} +): TerminalSideEffectBatch { + return { ptyId: PTY_ID, seq: 0, facts, ...options } +} + +describe('isMainTerminalSideEffectAuthorityForPty', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + beforeEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + delete (globalThis as { window?: unknown }).window + }) + + afterEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + function setPersistedSettingsSync(settings: unknown): void { + ;(globalThis as { window: unknown }).window = { + api: { settings: { getSync: () => settings } } + } + } + + it('is on by default for PTYs whose bytes transit local main', () => { + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: {}, runtimeEnvironmentId: null }) + ).toBe(true) + // Why: settings hydrate asynchronously; the default-on switch must not + // flip authority off during the null-settings startup window. + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(true) + }) + + it('is off for remote-runtime PTYs regardless of the setting', () => { + expect( + isMainTerminalSideEffectAuthorityForPty({ + settings: { terminalMainSideEffectAuthority: true }, + runtimeEnvironmentId: 'env-1' + }) + ).toBe(false) + }) + + it('is off when the kill switch is disabled', () => { + expect( + isMainTerminalSideEffectAuthorityForPty({ + settings: { terminalMainSideEffectAuthority: false }, + runtimeEnvironmentId: null + }) + ).toBe(false) + }) + + it('honors the persisted kill switch before settings hydrate', () => { + // Why: the authority decision is made once at transport creation; a pane + // bound during startup must not pick main authority when the user + // persisted the switch off. + setPersistedSettingsSync({ terminalMainSideEffectAuthority: false }) + + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(false) + }) + + it('stays on pre-hydration when the persisted switch is on or unset', () => { + setPersistedSettingsSync({ terminalMainSideEffectAuthority: true }) + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(true) + + _resetTerminalSideEffectFactConsumersForTest() + setPersistedSettingsSync({}) + expect( + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + ).toBe(true) + }) + + it('prefers hydrated settings over the persisted sync read', () => { + setPersistedSettingsSync({ terminalMainSideEffectAuthority: false }) + + expect( + isMainTerminalSideEffectAuthorityForPty({ + settings: { terminalMainSideEffectAuthority: true }, + runtimeEnvironmentId: null + }) + ).toBe(true) + }) + + it('caches the sync read so panes do not re-block per bind', () => { + const getSync = vi.fn(() => ({ terminalMainSideEffectAuthority: false })) + ;(globalThis as { window: unknown }).window = { api: { settings: { getSync } } } + + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + isMainTerminalSideEffectAuthorityForPty({ settings: null, runtimeEnvironmentId: null }) + + expect(getSync).toHaveBeenCalledTimes(1) + }) +}) + +describe('registerTerminalSideEffectFactConsumer', () => { + const originalWindow = (globalThis as { window?: typeof window }).window + + beforeEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + delete (globalThis as { window?: unknown }).window + }) + + afterEach(() => { + _resetTerminalSideEffectFactConsumersForTest() + if (originalWindow) { + ;(globalThis as { window: typeof window }).window = originalWindow + } else { + delete (globalThis as { window?: typeof window }).window + } + }) + + it('routes live facts to the registered consumer in batch order', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'title', normalizedTitle: '⠋ Claude', rawTitle: '⠋ Claude' }, + { kind: 'agent-working' }, + { kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' }, + { kind: 'agent-idle', title: '✳ Claude' }, + { kind: 'bell' } + ]) + ) + + expect(events).toEqual([ + ['title', '⠋ Claude', '⠋ Claude'], + ['working'], + ['title', '✳ Claude', '✳ Claude'], + ['idle', '✳ Claude'], + ['bell'] + ]) + }) + + it('routes command-finished and pr-link facts to the registered consumer', () => { + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onCommandFinished: (exitCode) => events.push(['finished', exitCode]), + onPrLink: (link) => events.push(['pr', link.url, link.number]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'command-finished', exitCode: 130 }, + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + }, + { kind: 'command-finished', exitCode: null } + ]) + ) + + expect(events).toEqual([ + ['finished', 130], + ['pr', 'https://github.com/acme/orca/pull/42', 42], + ['finished', null] + ]) + }) + + it('routes command-code scrape facts to the registered consumer', () => { + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]), + onCommandCodeDone: (prompt) => events.push(['cc-done', prompt]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'command-code-working', prompt: 'Fix the spinner' }, + { kind: 'command-code-done', prompt: 'Fix the spinner' } + ]) + ) + + expect(events).toEqual([ + ['cc-working', 'Fix the spinner'], + ['cc-done', 'Fix the spinner'] + ]) + }) + + it('never replays command-code scrape facts', () => { + // Why: a replayed working/done seed would resurrect a finished turn's + // status row — replay batches restore title state only. + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]), + onCommandCodeWorking: (prompt) => events.push(['cc-working', prompt]), + onCommandCodeDone: (prompt) => events.push(['cc-done', prompt]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }, + { kind: 'command-code-working', prompt: 'Fix the spinner' }, + { kind: 'command-code-done', prompt: 'Fix the spinner' } + ], + { replay: true, seq: 5 } + ) + ) + + expect(events).toEqual([['title', 'restored']]) + }) + + it('routes 2031-subscribe facts to the registered consumer but never replays them', () => { + // Why: the fact lets hidden-delivery-gated views answer the color-scheme + // query without byte access; a replayed subscribe would re-answer a query + // the snapshot already satisfied. + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]), + onMode2031Subscribe: () => events.push(['2031-subscribe']) + } + }) + + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: '2031-subscribe' }])) + expect(events).toEqual([['2031-subscribe']]) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }, + { kind: '2031-subscribe' } + ], + { replay: true, seq: 5 } + ) + ) + expect(events).toEqual([['2031-subscribe'], ['title', 'restored']]) + }) + + it('never replays command-finished or pr-link facts', () => { + // Why: like bells and agent transitions, command/PR facts are attention + // signals — replay snapshots restore title state only. + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle) => events.push(['title', normalizedTitle]), + onCommandFinished: (exitCode) => events.push(['finished', exitCode]), + onPrLink: (link) => events.push(['pr', link.url]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }, + { kind: 'command-finished', exitCode: 0 }, + { + kind: 'pr-link', + link: { + url: 'https://github.com/acme/orca/pull/42', + slug: { owner: 'acme', repo: 'orca' }, + number: 42 + } + } + ], + { replay: true, seq: 5 } + ) + ) + + expect(events).toEqual([['title', 'restored']]) + }) + + it('passes stale-clear provenance through to the title and idle callbacks', () => { + const events: unknown[][] = [] + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: { + onTitleChange: (normalizedTitle, _rawTitle, meta) => + events.push(['title', normalizedTitle, meta]), + onAgentBecameIdle: (title, meta) => events.push(['idle', title, meta]) + } + }) + + _dispatchTerminalSideEffectBatchForTest( + batch([ + { kind: 'agent-idle', title: 'Codex done' }, + { + kind: 'title', + normalizedTitle: 'Codex', + rawTitle: 'Codex', + staleWorkingTitleClear: true + }, + { kind: 'agent-idle', title: 'Codex', staleWorkingTitleClear: true } + ]) + ) + + expect(events).toEqual([ + ['idle', 'Codex done', undefined], + ['title', 'Codex', { staleWorkingTitleClear: true }], + ['idle', 'Codex', { staleWorkingTitleClear: true }] + ]) + }) + + it('drops batches for PTYs without a registered consumer', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }], { ptyId: 'other-pty' })) + + expect(events).toEqual([]) + }) + + it('applies only title facts from replay batches — no attention replay', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest( + batch( + [ + { kind: 'title', normalizedTitle: '✳ Claude', rawTitle: '✳ Claude' }, + { kind: 'bell' }, + { kind: 'agent-idle', title: '✳ Claude' } + ], + { replay: true, seq: 10 } + ) + ) + + expect(events).toEqual([['title', '✳ Claude', '✳ Claude']]) + }) + + it('drops a replay title not newer than the last applied live title', () => { + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'title', normalizedTitle: 'live', rawTitle: 'live' }], { seq: 20 }) + ) + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'title', normalizedTitle: 'stale', rawTitle: 'stale' }], { + replay: true, + seq: 20 + }) + ) + _dispatchTerminalSideEffectBatchForTest( + batch([{ kind: 'title', normalizedTitle: 'newer', rawTitle: 'newer' }], { + replay: true, + seq: 21 + }) + ) + + expect(events).toEqual([ + ['title', 'live', 'live'], + ['title', 'newer', 'newer'] + ]) + }) + + it('keeps exactly one consumer per PTY: a new registration replaces the old', () => { + const first = createCallbackRecorder() + const second = createCallbackRecorder() + const disposeFirst = registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: first.callbacks + }) + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks }) + + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) + // A stale registration's dispose must not evict the live consumer. + disposeFirst() + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) + + expect(first.events).toEqual([]) + expect(second.events).toEqual([['bell'], ['bell']]) + }) + + it('stops routing after the consumer unregisters', () => { + const { callbacks, events } = createCallbackRecorder() + const dispose = registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks }) + + dispose() + _dispatchTerminalSideEffectBatchForTest(batch([{ kind: 'bell' }])) + + expect(events).toEqual([]) + }) + + it('subscribes to the channel once and routes IPC batches', () => { + let channelCallback: ((batch: TerminalSideEffectBatch) => void) | null = null + const onSideEffect = vi.fn((callback: (batch: TerminalSideEffectBatch) => void) => { + channelCallback = callback + return () => { + channelCallback = null + } + }) + ;(globalThis as { window: unknown }).window = { + api: { pty: { onSideEffect } } + } + const first = createCallbackRecorder() + const second = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: first.callbacks }) + registerTerminalSideEffectFactConsumer({ ptyId: 'pty-2', callbacks: second.callbacks }) + + expect(onSideEffect).toHaveBeenCalledTimes(1) + channelCallback!(batch([{ kind: 'bell' }], { ptyId: 'pty-2' })) + expect(second.events).toEqual([['bell']]) + }) + + it('applies the title snapshot on register unless the registration was replaced', async () => { + let resolveSnapshot: (value: TerminalSideEffectBatch | null) => void = () => {} + const getSideEffectSnapshot = vi.fn( + () => + new Promise((resolve) => { + resolveSnapshot = resolve + }) + ) + ;(globalThis as { window: unknown }).window = { + api: { pty: { getSideEffectSnapshot } } + } + + const first = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks: first.callbacks, + restoreTitleOnRegister: true + }) + expect(getSideEffectSnapshot).toHaveBeenCalledWith(PTY_ID) + + // Replace before the snapshot resolves: the slow snapshot must not fire + // into the superseded registration. + const second = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ ptyId: PTY_ID, callbacks: second.callbacks }) + resolveSnapshot( + batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], { + replay: true, + seq: 5 + }) + ) + await Promise.resolve() + + expect(first.events).toEqual([]) + expect(second.events).toEqual([]) + }) + + it('restores the snapshot title for a live registration', async () => { + const snapshot = batch([{ kind: 'title', normalizedTitle: 'restored', rawTitle: 'restored' }], { + replay: true, + seq: 5 + }) + ;(globalThis as { window: unknown }).window = { + api: { pty: { getSideEffectSnapshot: vi.fn(async () => snapshot) } } + } + const { callbacks, events } = createCallbackRecorder() + registerTerminalSideEffectFactConsumer({ + ptyId: PTY_ID, + callbacks, + restoreTitleOnRegister: true + }) + + await Promise.resolve() + await Promise.resolve() + + expect(events).toEqual([['title', 'restored', 'restored']]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts new file mode 100644 index 00000000000..6fffdbea1a4 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-side-effect-facts-handler.ts @@ -0,0 +1,242 @@ +/** + * Renderer consumer registry for the `pty:sideEffect` channel. + * + * Why: with main as the side-effect parser for local-daemon/SSH PTYs + * (docs/reference/terminal-side-effect-authority.md), the renderer no longer + * derives title/bell/agent facts from bytes for those PTYs. This module is + * the single channel subscriber; mounted panes and parked-tab watchers + * register exactly one fact consumer per PTY (their existing policy + * callbacks), so every fact has exactly one policy consumer regardless of + * whether the tab is mounted, hidden, or parked. Facts for PTYs without a + * registered consumer are dropped — mirroring today's eager-buffer behavior + * where pre-mount output produces no attention side effects. + */ +import type { GlobalSettings } from '../../../../shared/types' +import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' +import type { + TerminalSideEffectBatch, + TerminalSideEffectFact +} from '../../../../shared/terminal-side-effect-facts' + +// Why: cached once per session — the blocking read should only ever run on +// the pre-hydration startup path, never per pane bind. +let persistedAuthorityFlagCache: boolean | null | undefined + +function readPersistedSideEffectAuthorityFlagSync(): boolean | null { + if (persistedAuthorityFlagCache === undefined) { + try { + const getSync = (globalThis as { window?: Window }).window?.api?.settings?.getSync + persistedAuthorityFlagCache = + typeof getSync === 'function' ? (getSync()?.terminalMainSideEffectAuthority ?? null) : null + } catch { + persistedAuthorityFlagCache = null + } + } + return persistedAuthorityFlagCache +} + +/** + * Structural authority predicate: main owns side effects for a PTY when its + * bytes transit local main (everything except remote-runtime PTYs) and the + * kill switch is on. Decided at transport/watcher creation — never per chunk — + * so each fact has one consumer with no race. + */ +export function isMainTerminalSideEffectAuthorityForPty(args: { + settings: Pick | null + /** Remote-runtime owner environment; null means bytes transit local main. */ + runtimeEnvironmentId: string | null +}): boolean { + if (args.runtimeEnvironmentId !== null) { + return false + } + if (args.settings !== null) { + return args.settings.terminalMainSideEffectAuthority !== false + } + // Why: settings hydrate asynchronously, and the authority decision made + // here at transport/watcher creation is never revisited. A pane bound + // before hydration must honor the persisted kill switch — otherwise a user + // who turned main authority off gets startup panes with no byte parsers + // and a fact consumer they disabled. Surfaces without the sync read (web + // remote clients, tests) keep the default-on behavior. + return readPersistedSideEffectAuthorityFlagSync() !== false +} + +export type TerminalSideEffectFactConsumerCallbacks = { + /** `meta.staleWorkingTitleClear` marks facts derived from main's 3s + * stale-title timer — policy must clear title/cache state without + * scheduling task-complete notifications or unread attention. */ + onTitleChange?: ( + normalizedTitle: string, + rawTitle: string, + meta?: { staleWorkingTitleClear?: boolean } + ) => void + onBell?: () => void + onAgentBecameIdle?: (title: string, meta?: { staleWorkingTitleClear?: boolean }) => void + onAgentBecameWorking?: () => void + onAgentExited?: () => void + /** OSC 133;D — same policy hook the byte-mode commandLifecycle drove + * (stale agent-status row drop + interrupt-inference coordination). */ + onCommandFinished?: (bestEffortExitCode: number | null) => void + onPrLink?: (link: TerminalGitHubPRLink) => void + /** Command Code output scrape (no hooks): working seeds the status row; + * done is settle-checked by the pane policy before completing the turn. */ + onCommandCodeWorking?: (prompt: string) => void + onCommandCodeDone?: (prompt: string) => void + /** DECSET 2031 subscribe observed by main's tracker. Registered only by + * hidden-delivery-gated consumers (their bytes never arrive); the theme + * reply is sent renderer-side — query authority stays with the view. */ + onMode2031Subscribe?: () => void +} + +type ConsumerEntry = { + callbacks: TerminalSideEffectFactConsumerCallbacks + /** Output sequence of the last live title fact applied. Replay snapshots at + * or before this point are stale and must not regress the title state. */ + lastLiveTitleSeq: number | null +} + +const consumersByPtyId = new Map() +let channelUnsubscribe: (() => void) | null = null + +function applyLiveFact(entry: ConsumerEntry, fact: TerminalSideEffectFact, seq: number): void { + switch (fact.kind) { + case 'title': + entry.lastLiveTitleSeq = seq + entry.callbacks.onTitleChange?.( + fact.normalizedTitle, + fact.rawTitle, + fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + ) + return + case 'bell': + entry.callbacks.onBell?.() + return + case 'agent-working': + entry.callbacks.onAgentBecameWorking?.() + return + case 'agent-idle': + entry.callbacks.onAgentBecameIdle?.( + fact.title, + fact.staleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + ) + return + case 'agent-exited': + entry.callbacks.onAgentExited?.() + return + case 'command-finished': + entry.callbacks.onCommandFinished?.(fact.exitCode) + return + case 'pr-link': + entry.callbacks.onPrLink?.(fact.link) + return + case 'command-code-working': + entry.callbacks.onCommandCodeWorking?.(fact.prompt) + return + case 'command-code-done': + entry.callbacks.onCommandCodeDone?.(fact.prompt) + return + case '2031-subscribe': + entry.callbacks.onMode2031Subscribe?.() + } +} + +function applyBatchToConsumer(entry: ConsumerEntry, batch: TerminalSideEffectBatch): void { + if (batch.replay) { + // Why: the no-attention-replay rule — (re)attach snapshots restore title + // state only; historical bells/completions must never fire again. A replay + // older (by output sequence) than the last live title fact is stale. + if (entry.lastLiveTitleSeq !== null && batch.seq <= entry.lastLiveTitleSeq) { + return + } + for (const fact of batch.facts) { + if (fact.kind === 'title') { + entry.callbacks.onTitleChange?.(fact.normalizedTitle, fact.rawTitle) + } + } + return + } + for (const fact of batch.facts) { + applyLiveFact(entry, fact, batch.seq) + } +} + +function handleSideEffectBatch(batch: TerminalSideEffectBatch): void { + const entry = consumersByPtyId.get(batch.ptyId) + if (!entry) { + return + } + applyBatchToConsumer(entry, batch) +} + +function ensureSideEffectChannelSubscription(): void { + if (channelUnsubscribe !== null) { + return + } + // Why: optional-chained from globalThis so unit tests (and any non-preload + // surface) without window.api degrade to "no channel" instead of throwing. + const onSideEffect = (globalThis as { window?: Window }).window?.api?.pty?.onSideEffect + if (typeof onSideEffect !== 'function') { + return + } + channelUnsubscribe = onSideEffect(handleSideEffectBatch) +} + +export type TerminalSideEffectFactConsumerOptions = { + ptyId: string + callbacks: TerminalSideEffectFactConsumerCallbacks + /** Pull main's title-only replay snapshot on registration. Pane transports + * use this in place of deriving titles from eager-buffer byte replay; + * parked watchers skip it because the pane's runtime title slot is already + * current at park time. */ + restoreTitleOnRegister?: boolean +} + +/** + * Register the single fact consumer for a PTY. A new registration replaces a + * stale one for the same PTY (same semantics as the parked watcher registry): + * two consumers would double-fire bell/completion policy for the same bytes. + */ +export function registerTerminalSideEffectFactConsumer( + options: TerminalSideEffectFactConsumerOptions +): () => void { + ensureSideEffectChannelSubscription() + const entry: ConsumerEntry = { + callbacks: options.callbacks, + lastLiveTitleSeq: null + } + consumersByPtyId.set(options.ptyId, entry) + + if (options.restoreTitleOnRegister) { + const getSnapshot = (globalThis as { window?: Window }).window?.api?.pty?.getSideEffectSnapshot + if (typeof getSnapshot === 'function') { + void getSnapshot(options.ptyId) + .then((batch) => { + // Why: apply only while this registration is still the live + // consumer; a slow snapshot must not fire into a replaced one. + if (batch && consumersByPtyId.get(options.ptyId) === entry) { + applyBatchToConsumer(entry, { ...batch, replay: true }) + } + }) + .catch(() => {}) + } + } + + return () => { + if (consumersByPtyId.get(options.ptyId) === entry) { + consumersByPtyId.delete(options.ptyId) + } + } +} + +/** Test seam: deliver a batch as if it arrived on the channel. */ +export function _dispatchTerminalSideEffectBatchForTest(batch: TerminalSideEffectBatch): void { + handleSideEffectBatch(batch) +} + +/** Test seam: reset module state between tests. */ +export function _resetTerminalSideEffectFactConsumersForTest(): void { + consumersByPtyId.clear() + channelUnsubscribe?.() + channelUnsubscribe = null + persistedAuthorityFlagCache = undefined +} diff --git a/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts new file mode 100644 index 00000000000..ae5fa99b63f --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-title-tracker-parity.test.ts @@ -0,0 +1,378 @@ +// Why: Phase 3 slice 1 of terminal-side-effect-authority.md runs a per-PTY +// title tracker in main alongside the renderer transport's byte parser. Both +// must derive IDENTICAL ordered title/status facts from the same bytes, or +// main-side consumers (tui-idle waiters, worktree ps, mobile titles) drift +// from what the renderer shows. This harness feeds identical byte fixtures +// through the renderer `createPtyOutputProcessor` and through main's +// consumption shape (OSC 9999 strip → shared title tracker) and asserts the +// event sequences match. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { createAgentStatusOscProcessor } from '../../../../shared/agent-status-osc' +import { createCommandCodeOutputStatusDetector } from '../../../../shared/command-code-output-status' +import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector' +import { createTerminalTitleTracker } from '../../../../shared/terminal-output-side-effects' +import { createPtyOutputProcessor } from './pty-transport' +import { createTerminalCommandLifecycle } from './terminal-command-lifecycle' + +const ESC = '\x1b' +const BEL = '\x07' +const ST = `${ESC}\\` + +type TitleFactEvent = + | { kind: 'title'; normalized: string; raw: string } + | { kind: 'became-working' } + | { kind: 'became-idle'; title: string } + | { kind: 'agent-exited' } + | { kind: 'bell' } + +type TitleFactPath = { + events: TitleFactEvent[] + feed: (chunk: string) => void +} + +function createRendererPath(): TitleFactPath { + const events: TitleFactEvent[] = [] + const processor = createPtyOutputProcessor({ + onTitleChange: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), + onAgentBecameWorking: () => events.push({ kind: 'became-working' }), + onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), + onAgentExited: () => events.push({ kind: 'agent-exited' }), + onBell: () => events.push({ kind: 'bell' }) + }) + const callbacks = { onData: () => {} } + return { + events, + feed(chunk: string): void { + processor.processData(chunk, callbacks) + // Why: the renderer defers side effects behind a setTimeout(0) drain to + // protect xterm paint. Flush synchronously so both paths observe each + // chunk at the same fake-timer instant. + processor.flushPendingSideEffects() + } + } +} + +function createMainPath(): TitleFactPath { + const events: TitleFactEvent[] = [] + // Why: mirrors OrcaRuntimeService.onPtyData — the per-PTY OSC 9999 + // processor strips status payloads before the title tracker sees the chunk. + const processAgentStatusChunk = createAgentStatusOscProcessor() + const tracker = createTerminalTitleTracker({ + onTitle: (normalized, raw) => events.push({ kind: 'title', normalized, raw }), + onAgentBecameWorking: () => events.push({ kind: 'became-working' }), + onAgentBecameIdle: (title) => events.push({ kind: 'became-idle', title }), + onAgentExited: () => events.push({ kind: 'agent-exited' }), + onBell: () => events.push({ kind: 'bell' }) + }) + return { + events, + feed(chunk: string): void { + tracker.handleChunk(processAgentStatusChunk(chunk).cleanData) + } + } +} + +type ChunkFeed = { feed: (chunk: string) => void } + +function feedBoth(paths: { renderer: ChunkFeed; main: ChunkFeed }, chunk: string): void { + paths.renderer.feed(chunk) + paths.main.feed(chunk) +} + +describe('main title tracker parity with the renderer transport processor', () => { + let paths: { renderer: TitleFactPath; main: TitleFactPath } + + beforeEach(() => { + vi.useFakeTimers() + paths = { renderer: createRendererPath(), main: createMainPath() } + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('derives identical facts from a coalesced spinner+idle chunk (issue #1083)', () => { + // One realistic node-pty batch: Pi's 80ms spinner frames plus agent_end's + // trailing idle title. A last-title reader sees only the idle title and + // never observes the working state. + const chunk = + `${ESC}]0;⠋ π - cwd${BEL}response text\r\n` + + `${ESC}]0;⠙ π - cwd${BEL}more text\r\n` + + `${ESC}]0;π - cwd${BEL}` + feedBoth(paths, chunk) + + expect(paths.main.events).toEqual(paths.renderer.events) + const kinds = paths.main.events.map((event) => event.kind) + expect(kinds).toContain('became-working') + expect(kinds.indexOf('became-working')).toBeLessThan(kinds.indexOf('became-idle')) + }) + + it('derives identical facts from BEL- and ST-terminated titles', () => { + feedBoth(paths, `${ESC}]2;Codex working${ST}body bytes`) + feedBoth(paths, `${ESC}]0;Codex done${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toContainEqual({ kind: 'became-idle', title: 'Codex done' }) + }) + + it('drops the bare cursor-agent native title in both paths', () => { + feedBoth(paths, `${ESC}]0;⠋ Cursor Agent${BEL}`) + feedBoth(paths, `${ESC}]0;Cursor Agent${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + const titles = paths.main.events.filter((event) => event.kind === 'title') + expect(titles).toEqual([{ kind: 'title', normalized: '⠋ Cursor Agent', raw: '⠋ Cursor Agent' }]) + }) + + it('clears a stale working title after the 3s timeout in both paths', () => { + feedBoth(paths, `${ESC}]0;. Claude working${BEL}`) + feedBoth(paths, 'output with no title\r\n') + + vi.advanceTimersByTime(3_000) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.at(-1)).toEqual({ kind: 'became-idle', title: 'Claude' }) + }) + + it('keeps the stale-title timer unperturbed by pure OSC 9999 status chunks', () => { + feedBoth(paths, `${ESC}]0;Codex working${BEL}`) + feedBoth(paths, 'plain output arms the timer\r\n') + + vi.advanceTimersByTime(2_000) + // Why: a chunk that is ONLY an Orca status payload strips to empty + // cleanData; neither path may restart (or newly arm) the stale probe. + feedBoth(paths, `${ESC}]9999;{"state":"working","agentType":"codex"}${BEL}`) + vi.advanceTimersByTime(1_000) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.at(-1)).toEqual({ kind: 'became-idle', title: 'Codex' }) + }) + + it('ignores a title split across chunk boundaries in both paths', () => { + feedBoth(paths, `${ESC}]0;split-ti`) + feedBoth(paths, `tle${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) + + it('orders a real BEL after the same chunk titles in both paths', () => { + feedBoth(paths, `${ESC}]0;⠋ Claude working${BEL}done text${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.map((event) => event.kind)).toEqual([ + 'title', + 'became-working', + 'bell' + ]) + }) + + it('never reports an OSC-terminator BEL as a bell, even spanning chunks', () => { + feedBoth(paths, `${ESC}]0;par`) + feedBoth(paths, `tial title${BEL}`) + feedBoth(paths, `${ESC}]2;st-terminated${ST}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events.filter((event) => event.kind === 'bell')).toEqual([]) + }) + + it('treats a BEL after a CAN-cancelled OSC as a real bell in both paths', () => { + // ECMA-48 CAN aborts the in-progress OSC; the next BEL is a real bell. + feedBoth(paths, `${ESC}]0;truncated`) + feedBoth(paths, `\x18${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([{ kind: 'bell' }]) + }) + + it('keeps bells suppressed inside OSC 9999 status payloads in both paths', () => { + feedBoth(paths, `${ESC}]9999;{"state":"working","agentType":"codex"}${BEL}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) +}) + +// Why: slice 3 moves the renderer's OSC 133;D and PR-link byte parsing into +// main's tracker for local/SSH PTYs. Both must derive identical fact +// sequences from the same chunk boundaries, or flipping the kill switch +// changes which commands/links are observed. +type LifecycleFactEvent = ['command-finished', number | null] | ['pr-link', string, number] + +type LifecycleFactPath = { + events: LifecycleFactEvent[] + feed: (chunk: string) => void +} + +function createRendererLifecyclePath(): LifecycleFactPath { + const events: LifecycleFactEvent[] = [] + // Why: mirrors pty-connection's dataCallback wiring — the transport + // processor strips OSC 9999 before the lifecycle/PR-link byte scans run. + const processAgentStatusChunk = createAgentStatusOscProcessor() + const lifecycle = createTerminalCommandLifecycle({ + onCommandFinished: (exitCode) => events.push(['command-finished', exitCode]) + }) + const detectPRLinks = createTerminalGitHubPRLinkDetector() + return { + events, + feed(chunk: string): void { + const clean = processAgentStatusChunk(chunk).cleanData + lifecycle.handlePtyData(clean) + for (const link of detectPRLinks(clean)) { + events.push(['pr-link', link.url, link.number]) + } + } + } +} + +function createMainLifecyclePath(): LifecycleFactPath { + const events: LifecycleFactEvent[] = [] + const processAgentStatusChunk = createAgentStatusOscProcessor() + const tracker = createTerminalTitleTracker({ + onCommandFinished: (exitCode) => events.push(['command-finished', exitCode]), + onPrLink: (link) => events.push(['pr-link', link.url, link.number]) + }) + return { + events, + feed(chunk: string): void { + tracker.handleChunk(processAgentStatusChunk(chunk).cleanData) + } + } +} + +describe('main tracker parity with renderer 133;D and PR-link byte parsers', () => { + let paths: { renderer: LifecycleFactPath; main: LifecycleFactPath } + + beforeEach(() => { + paths = { renderer: createRendererLifecyclePath(), main: createMainLifecyclePath() } + }) + + it('derives identical command-finished facts from split OSC 133;D chunks', () => { + feedBoth(paths, `output${ESC}]133`) + feedBoth(paths, ';D;13') + feedBoth(paths, `0${BEL}prompt $ `) + feedBoth(paths, `${ESC}]133;D;0${BEL}`) + feedBoth(paths, `${ESC}]133;D${ST}`) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([ + ['command-finished', 130], + ['command-finished', 0], + ['command-finished', null] + ]) + }) + + it('derives identical pr-link facts from split and repeated URLs', () => { + feedBoth(paths, 'Created https://github.com/acme/orca/pull/4') + feedBoth(paths, '2\r\nAlso https://github.com/acme/orca/pull/43 merged\r\n') + feedBoth(paths, 'again https://github.com/acme/orca/pull/42\r\n') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([ + ['pr-link', 'https://github.com/acme/orca/pull/42', 42], + ['pr-link', 'https://github.com/acme/orca/pull/43', 43] + ]) + }) + + it('ignores 133;D and PR URLs inside stripped OSC 9999 payloads in both paths', () => { + feedBoth( + paths, + `${ESC}]9999;{"state":"done","prompt":"https://github.com/acme/orca/pull/9"}${BEL}\r\n` + ) + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) +}) + +// Why: slice 4 moves the Command Code output scrape into main for local/SSH +// PTYs. The renderer byte path observes raw transport data; main observes the +// OSC 9999-stripped cleanData. Both must derive identical working/done +// sequences from the same chunk boundaries, or flipping the kill switch +// changes Command Code status rows. +type CommandCodeFactEvent = ['working' | 'done', string] + +type CommandCodeFactPath = { + events: CommandCodeFactEvent[] + feed: (chunk: string) => void +} + +function createCommandCodePath(options: { stripStatusPayloads: boolean }): CommandCodeFactPath { + const events: CommandCodeFactEvent[] = [] + const processAgentStatusChunk = createAgentStatusOscProcessor() + const detector = createCommandCodeOutputStatusDetector({ + startupCommand: null, + onWorking: (prompt) => events.push(['working', prompt]), + onDone: (prompt) => events.push(['done', prompt]) + }) + return { + events, + feed(chunk: string): void { + detector.observe( + options.stripStatusPayloads ? processAgentStatusChunk(chunk).cleanData : chunk + ) + } + } +} + +describe('main Command Code scrape parity with the renderer byte detector', () => { + let paths: { renderer: CommandCodeFactPath; main: CommandCodeFactPath } + + beforeEach(() => { + paths = { + renderer: createCommandCodePath({ stripStatusPayloads: false }), + main: createCommandCodePath({ stripStatusPayloads: true }) + } + }) + + it('derives identical working facts after the banner arms across chunks', () => { + feedBoth(paths, '# Command') + feedBoth(paths, ' Code v0.27.3\r\n') + feedBoth(paths, '❯ Fix the spinner\r\n\x1b[35m✻ Thinking...\x1b[0m') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([['working', 'Fix the spinner']]) + }) + + it('derives identical done facts for a no-tool turn in both paths', () => { + feedBoth(paths, '# Command Code v0.27.3\r\n') + feedBoth(paths, '❯ say hi\r\n✻ Thinking...') + feedBoth(paths, '\r\n:: Hi!\r\n❯ Ask your question...') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([ + ['working', 'say hi'], + ['done', 'say hi'] + ]) + }) + + it('recovers prompt capture from interleaved OSC 9999 payloads (main improvement)', () => { + // Deliberate divergence, not drift: the renderer's raw byte path lets an + // OSC 9999 payload leak partial text into the scrape window (its ANSI + // strip consumes only the ESC] introducer), which breaks the prompt-echo + // line match. Main feeds the OSC 9999-stripped cleanData, so the prompt + // (and therefore the done settle hint) survives an adjacent payload. + const payloadThenPrompt = [ + '# Command Code v0.27.3\r\n', + `${ESC}]9999;{"state":"working","agentType":"command-code"}${BEL}`, + '❯ say hi\r\n✻ Thinking...', + '\r\n:: Hi!\r\n❯ Ask your question...' + ] + for (const chunk of payloadThenPrompt) { + feedBoth(paths, chunk) + } + + expect(paths.renderer.events).toEqual([['working', '']]) + expect(paths.main.events).toEqual([ + ['working', 'say hi'], + ['done', 'say hi'] + ]) + }) + + it('stays silent without the Command Code banner in both paths', () => { + feedBoth(paths, '❯ Fix the spinner\r\nThinking about unrelated shell output...') + + expect(paths.main.events).toEqual(paths.renderer.events) + expect(paths.main.events).toEqual([]) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts new file mode 100644 index 00000000000..ea7613ea354 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.test.ts @@ -0,0 +1,249 @@ +/** + * View-attribute bridge publication (terminal-query-authority.md §View- + * attribute bridge): the composed snapshot must mirror xterm ThemeService + * resolution (defaults, cursor blend, 256-entry palette), and pushes must + * happen once per actual change — not per pane, not per font tweak. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { PaneManager, ManagedPane } from '@/lib/pane-manager/pane-manager' +import { getDefaultSettings } from '../../../../shared/constants' +import type { TerminalViewAttributes } from '../../../../shared/terminal-view-attributes' +import { applyTerminalAppearance } from './terminal-appearance' +import { + _resetTerminalViewAttributesPublisherForTest, + composeTerminalViewAttributes, + publishTerminalViewAttributes +} from './terminal-view-attributes-publisher' + +const cursorSettings = { + terminalCursorStyle: 'block' as const, + terminalCursorBlink: true +} + +beforeEach(() => { + _resetTerminalViewAttributesPublisherForTest() + vi.unstubAllGlobals() +}) + +describe('composeTerminalViewAttributes', () => { + it('resolves a null theme to the xterm ThemeService defaults', () => { + const attrs = composeTerminalViewAttributes(null, 'dark', cursorSettings) + expect(attrs.foreground).toEqual([0xff, 0xff, 0xff]) + expect(attrs.background).toEqual([0x00, 0x00, 0x00]) + expect(attrs.cursor).toEqual([0xff, 0xff, 0xff]) + expect(attrs.ansi).toHaveLength(256) + // DEFAULT_ANSI_COLORS parity: named 16, color cube, greys. + expect(attrs.ansi[0]).toEqual([0x2e, 0x34, 0x36]) + expect(attrs.ansi[1]).toEqual([0xcc, 0x00, 0x00]) + expect(attrs.ansi[15]).toEqual([0xee, 0xee, 0xec]) + expect(attrs.ansi[16]).toEqual([0x00, 0x00, 0x00]) + expect(attrs.ansi[196]).toEqual([0xff, 0x00, 0x00]) + expect(attrs.ansi[232]).toEqual([8, 8, 8]) + expect(attrs.ansi[255]).toEqual([238, 238, 238]) + expect(attrs.colorSchemeMode).toBe('dark') + expect(attrs.cursorStyle).toBe('block') + expect(attrs.cursorBlink).toBe(true) + }) + + it('parses composed theme colors including rgba() opacity forms', () => { + const attrs = composeTerminalViewAttributes( + { + // composeActiveTerminalTheme emits rgba() when terminalBackgroundOpacity + // or terminalCursorOpacity apply; the reply drops alpha like xterm's + // toColorRGB, except the cursor which blends over the background. + background: 'rgba(30, 30, 46, 0.9)', + foreground: '#d0d0d0', + cursor: 'rgba(255, 0, 0, 0.5)', + red: '#ff8800' + }, + 'light', + { terminalCursorStyle: 'underline', terminalCursorBlink: false } + ) + expect(attrs.background).toEqual([30, 30, 46]) + expect(attrs.foreground).toEqual([0xd0, 0xd0, 0xd0]) + // color.blend parity: a = round(0.5*255)/255; ch = bg + round((fg-bg)*a). + expect(attrs.cursor).toEqual([143, 15, 23]) + expect(attrs.ansi[1]).toEqual([0xff, 0x88, 0x00]) + expect(attrs.colorSchemeMode).toBe('light') + expect(attrs.cursorStyle).toBe('underline') + expect(attrs.cursorBlink).toBe(false) + }) + + it('keeps an opaque cursor un-blended and blends short-hex alpha', () => { + const attrs = composeTerminalViewAttributes( + { background: '#000000', cursor: '#ff0000' }, + 'dark', + cursorSettings + ) + expect(attrs.cursor).toEqual([255, 0, 0]) + + const blended = composeTerminalViewAttributes( + { background: '#000000', cursor: '#f00a' }, + 'dark', + cursorSettings + ) + // #f00a → alpha 0xaa: 0 + round(255 * (0xaa/0xff)) = 170. + expect(blended.cursor).toEqual([170, 0, 0]) + }) + + it('overlays extendedAnsi onto the default 256 palette tail', () => { + const attrs = composeTerminalViewAttributes( + { extendedAnsi: ['#102030'] }, + 'dark', + cursorSettings + ) + expect(attrs.ansi[16]).toEqual([0x10, 0x20, 0x30]) + // Untouched tail entries stay on the generated cube. + expect(attrs.ansi[17]).toEqual([0x00, 0x00, 0x5f]) + }) + + it('falls back to slot defaults for named colors (hand-edited settings divergence)', () => { + // A visible pane resolves named CSS via canvas; the composer cannot, so + // hand-edited values fall back — the documented divergence boundary. + const attrs = composeTerminalViewAttributes( + { red: 'darkred', foreground: 'hotpink' }, + 'dark', + cursorSettings + ) + expect(attrs.ansi[1]).toEqual([0xcc, 0x00, 0x00]) + expect(attrs.foreground).toEqual([0xff, 0xff, 0xff]) + }) +}) + +describe('publishTerminalViewAttributes dedupe', () => { + it('publishes once per snapshot change, not per call', () => { + const send = vi.fn(() => true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(false) + expect(send).toHaveBeenCalledTimes(1) + + // A real attribute change (theme flip) publishes again. + expect(publishTerminalViewAttributes(null, 'light', cursorSettings, send)).toBe(true) + expect(send).toHaveBeenCalledTimes(2) + }) + + it('does not record a failed send, so the next call retries', () => { + const failingSend = vi.fn(() => false) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, failingSend)).toBe(false) + + const send = vi.fn(() => true) + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings, send)).toBe(true) + }) + + it('skips silently when the preload bridge is unavailable (web client, tests)', () => { + // No window stub: default send must be a safe no-op. + expect(publishTerminalViewAttributes(null, 'dark', cursorSettings)).toBe(false) + }) +}) + +describe('applyTerminalAppearance publication', () => { + function makePane(id: number): ManagedPane { + return { + id, + terminal: { options: {}, cols: 80, rows: 24 } + } as unknown as ManagedPane + } + + function makeManager(panes: ManagedPane[]): PaneManager { + return { + getPanes: () => panes, + setPaneLigaturesEnabled: vi.fn(), + setPaneStyleOptions: vi.fn() + } as unknown as PaneManager + } + + function stubPublishBridge(): ReturnType { + const publish = vi.fn<(attributes: TerminalViewAttributes) => void>() + vi.stubGlobal('window', { api: { pty: { publishTerminalViewAttributes: publish } } }) + return publish + } + + it('pushes the app-global snapshot once per change, not per pane or per manager', () => { + const publish = stubPublishBridge() + const settings = getDefaultSettings('/tmp') + + // Two panes in one manager plus a second manager (another tab): the + // attributes are app-global, so identical applies publish exactly once. + applyTerminalAppearance( + makeManager([makePane(1), makePane(2)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + applyTerminalAppearance( + makeManager([makePane(3)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(1) + const attributes = publish.mock.calls[0][0] as TerminalViewAttributes + expect(attributes.ansi).toHaveLength(256) + expect(attributes.cursorStyle).toBe(settings.terminalCursorStyle) + + // Attribute-neutral tweak (font size) must not re-push… + applyTerminalAppearance( + makeManager([makePane(1)]), + { ...settings, terminalFontSize: settings.terminalFontSize + 2 }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(1) + + // …while a cursor-style change is a real attribute change. + applyTerminalAppearance( + makeManager([makePane(1)]), + { ...settings, terminalCursorStyle: 'underline' }, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + expect(publish).toHaveBeenCalledTimes(2) + }) + + it('publishes the resolved color-scheme mode flip (system dark toggle)', () => { + const publish = stubPublishBridge() + const settings = { ...getDefaultSettings('/tmp'), theme: 'system' as const } + + applyTerminalAppearance( + makeManager([makePane(1)]), + settings, + true, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + applyTerminalAppearance( + makeManager([makePane(1)]), + settings, + false, + new Map(), + new Map(), + 'false', + new Map(), + new Map() + ) + + const modes = publish.mock.calls.map( + (call) => (call[0] as TerminalViewAttributes).colorSchemeMode + ) + expect(modes).toEqual(['dark', 'light']) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts new file mode 100644 index 00000000000..304ff399f6c --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-view-attributes-publisher.ts @@ -0,0 +1,251 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): renderer→main `pty:terminalViewAttributes` publication. Composes + * the reply-relevant slots of the active terminal theme exactly the way + * xterm's browser ThemeService resolves an ITheme (defaults, cursor blend, + * 256-entry palette), so main's hidden-PTY responder replies byte-identically + * to a visible pane's xterm. Deduped module-globally: applyTerminalAppearance + * runs per pane manager and on every font/opacity tweak, but the attributes + * are app-global, so identical snapshots publish once. + */ +import type { ITheme } from '@xterm/xterm' +import type { GlobalSettings } from '../../../../shared/types' +import type { TerminalColorSchemeMode } from '../../../../shared/terminal-color-scheme-protocol' +import type { + TerminalViewAttributes, + TerminalViewRgb +} from '../../../../shared/terminal-view-attributes' + +type ParsedCssColor = { + rgb: TerminalViewRgb + /** 0-255, the precision xterm stores (rgba byte) — blend parity needs it. */ + alpha: number +} + +// ThemeService defaults for the reply-relevant slots (browser/services/ +// ThemeService.ts): fg #ffffff, bg #000000, cursor #ffffff. +const DEFAULT_FOREGROUND: ParsedCssColor = { rgb: [0xff, 0xff, 0xff], alpha: 0xff } +const DEFAULT_BACKGROUND: ParsedCssColor = { rgb: [0x00, 0x00, 0x00], alpha: 0xff } +const DEFAULT_CURSOR: ParsedCssColor = { rgb: [0xff, 0xff, 0xff], alpha: 0xff } + +// xterm's DEFAULT_ANSI_COLORS first 16 entries (browser/Types.ts). +const DEFAULT_ANSI_16: readonly string[] = [ + '#2e3436', + '#cc0000', + '#4e9a06', + '#c4a000', + '#3465a4', + '#75507b', + '#06989a', + '#d3d7cf', + '#555753', + '#ef2929', + '#8ae234', + '#fce94f', + '#729fcf', + '#ad7fa8', + '#34e2e2', + '#eeeeec' +] + +const THEME_ANSI_KEYS: readonly (keyof ITheme)[] = [ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white', + 'brightBlack', + 'brightRed', + 'brightGreen', + 'brightYellow', + 'brightBlue', + 'brightMagenta', + 'brightCyan', + 'brightWhite' +] + +function buildDefaultAnsiPalette(): TerminalViewRgb[] { + const palette = DEFAULT_ANSI_16.map((hex) => parseThemeColor(hex, DEFAULT_BACKGROUND).rgb) + // 16-231: the 6x6x6 color cube, 232-255: greys — same generator as xterm's + // DEFAULT_ANSI_COLORS IIFE so untouched extended slots reply identically. + const v = [0x00, 0x5f, 0x87, 0xaf, 0xd7, 0xff] + for (let i = 0; i < 216; i++) { + palette.push([v[((i / 36) % 6) | 0], v[((i / 6) % 6) | 0], v[i % 6]]) + } + for (let i = 0; i < 24; i++) { + const c = 8 + i * 10 + palette.push([c, c, c]) + } + return palette +} + +const DEFAULT_ANSI_PALETTE: readonly TerminalViewRgb[] = buildDefaultAnsiPalette() + +/** Mirror of xterm's css.toColor fast paths (#rgb[a], #rrggbb[aa], rgb(), + * rgba()) — every format first-party inputs produce (builtin themes and the + * ghostty import are hex-validated; composeActiveTerminalTheme only adds the + * rgba() form this regex accepts). Known divergence boundary: the renderer's + * css.toColor also resolves named/modern CSS via a canvas litmus, so a + * hand-edited settings value like `background: 'darkslategray'` renders on + * a visible pane but falls back to the slot default in the hidden reply. */ +export function parseCssColor(css: string): ParsedCssColor | null { + if (/^#[\da-f]{3,8}$/i.test(css)) { + switch (css.length) { + case 4: + return { + rgb: [ + Number.parseInt(css.slice(1, 2).repeat(2), 16), + Number.parseInt(css.slice(2, 3).repeat(2), 16), + Number.parseInt(css.slice(3, 4).repeat(2), 16) + ], + alpha: 0xff + } + case 5: + return { + rgb: [ + Number.parseInt(css.slice(1, 2).repeat(2), 16), + Number.parseInt(css.slice(2, 3).repeat(2), 16), + Number.parseInt(css.slice(3, 4).repeat(2), 16) + ], + alpha: Number.parseInt(css.slice(4, 5).repeat(2), 16) + } + case 7: + return { + rgb: [ + Number.parseInt(css.slice(1, 3), 16), + Number.parseInt(css.slice(3, 5), 16), + Number.parseInt(css.slice(5, 7), 16) + ], + alpha: 0xff + } + case 9: + return { + rgb: [ + Number.parseInt(css.slice(1, 3), 16), + Number.parseInt(css.slice(3, 5), 16), + Number.parseInt(css.slice(5, 7), 16) + ], + alpha: Number.parseInt(css.slice(7, 9), 16) + } + default: + return null + } + } + const rgbaMatch = css.match( + /rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(,\s*(0|1|\d?\.(\d+))\s*)?\)/ + ) + if (rgbaMatch) { + return { + rgb: [ + Number.parseInt(rgbaMatch[1], 10), + Number.parseInt(rgbaMatch[2], 10), + Number.parseInt(rgbaMatch[3], 10) + ], + alpha: Math.round((rgbaMatch[5] === undefined ? 1 : Number.parseFloat(rgbaMatch[5])) * 0xff) + } + } + return null +} + +function parseThemeColor(css: string | undefined, fallback: ParsedCssColor): ParsedCssColor { + if (css !== undefined) { + const parsed = parseCssColor(css) + if (parsed) { + return parsed + } + } + return fallback +} + +// Mirror of xterm's color.blend: ThemeService blends the cursor color's +// alpha over the background at theme-set time (terminalCursorOpacity), and +// the OSC 12 reply reports the blended value. +function blendOverBackground(background: TerminalViewRgb, color: ParsedCssColor): TerminalViewRgb { + if (color.alpha === 0xff) { + return color.rgb + } + const a = color.alpha / 0xff + return [ + background[0] + Math.round((color.rgb[0] - background[0]) * a), + background[1] + Math.round((color.rgb[1] - background[1]) * a), + background[2] + Math.round((color.rgb[2] - background[2]) * a) + ] +} + +export function composeTerminalViewAttributes( + theme: ITheme | null, + mode: TerminalColorSchemeMode, + settings: Pick +): TerminalViewAttributes { + const foreground = parseThemeColor(theme?.foreground, DEFAULT_FOREGROUND) + const background = parseThemeColor(theme?.background, DEFAULT_BACKGROUND) + const cursor = parseThemeColor(theme?.cursor, DEFAULT_CURSOR) + const ansi: TerminalViewRgb[] = THEME_ANSI_KEYS.map((key, i) => { + const value = theme?.[key] + return parseThemeColor(typeof value === 'string' ? value : undefined, { + rgb: DEFAULT_ANSI_PALETTE[i], + alpha: 0xff + }).rgb + }) + for (let i = 16; i < DEFAULT_ANSI_PALETTE.length; i++) { + const extended = theme?.extendedAnsi?.[i - 16] + ansi.push( + parseThemeColor(extended, { + rgb: DEFAULT_ANSI_PALETTE[i], + alpha: 0xff + }).rgb + ) + } + return { + foreground: foreground.rgb, + background: background.rgb, + cursor: blendOverBackground(background.rgb, cursor), + ansi, + colorSchemeMode: mode, + // Same resolution as the per-pane option writes in applyTerminalAppearance. + cursorStyle: settings.terminalCursorStyle ?? 'block', + cursorBlink: settings.terminalCursorBlink === true + } +} + +let lastPublishedSnapshot: string | null = null + +function sendViaPreload(attributes: TerminalViewAttributes): boolean { + // Guarded: unit tests and the web client run without the preload bridge + // (remote-runtime PTYs are never hidden-gate markable anyway). + if (typeof window === 'undefined' || !window.api?.pty?.publishTerminalViewAttributes) { + return false + } + window.api.pty.publishTerminalViewAttributes(attributes) + return true +} + +/** Publishes the composed app-global attributes, once per actual change: + * repeat calls from per-pane appearance applies (and attribute-neutral + * tweaks like font size) are deduped against the last published snapshot. */ +export function publishTerminalViewAttributes( + theme: ITheme | null, + mode: TerminalColorSchemeMode, + settings: Pick, + send: (attributes: TerminalViewAttributes) => boolean = sendViaPreload +): boolean { + const attributes = composeTerminalViewAttributes(theme, mode, settings) + const serialized = JSON.stringify(attributes) + if (serialized === lastPublishedSnapshot) { + return false + } + if (!send(attributes)) { + // Not recorded: a later call with a working bridge must still publish. + return false + } + lastPublishedSnapshot = serialized + return true +} + +/** Test seam: reset the dedupe state between tests. */ +export function _resetTerminalViewAttributesPublisherForTest(): void { + lastPublishedSnapshot = null +} diff --git a/src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts b/src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts new file mode 100644 index 00000000000..8fbb426ade8 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/terminal-webgl-diagnostics-breadcrumbs.test.ts @@ -0,0 +1,30 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' +import { + getTerminalFreezeBreadcrumbs, + resetTerminalFreezeBreadcrumbsForTesting +} from './terminal-freeze-breadcrumbs' + +// Why: lib-layer WebGL code records through the shared sink because it may not +// import the components-layer ring. Importing terminal-freeze-breadcrumbs wires +// that sink to the ring at module load; this pins that the WebGL crumbs land in +// the same one-paste report as delivery/visibility history. +describe('WebGL diagnostics → freeze breadcrumb ring', () => { + beforeEach(() => { + resetTerminalFreezeBreadcrumbsForTesting() + }) + + afterEach(() => { + resetTerminalFreezeBreadcrumbsForTesting() + }) + + it('routes context-loss and atlas-reset crumbs into the freeze report ring', () => { + recordTerminalWebglDiagnostic('webgl-context-loss', { paneId: 3 }) + recordTerminalWebglDiagnostic('webgl-atlas-reset', { managers: 1 }) + + const crumbs = getTerminalFreezeBreadcrumbs() + expect(crumbs.map((crumb) => crumb.kind)).toEqual(['webgl-context-loss', 'webgl-atlas-reset']) + expect(crumbs[0]?.detail).toEqual({ paneId: 3 }) + expect(crumbs[1]?.detail).toEqual({ managers: 1 }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts index e021ced6b6a..09033df6264 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-global-effects.ts @@ -21,6 +21,10 @@ import { type TerminalHiddenReason } from './terminal-visibility-resume' import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery' +import { + releaseRendererPtyVisibilityClaim, + setRendererPtyVisibilityClaim +} from './pty-renderer-delivery-claims' type UseTerminalPaneGlobalEffectsArgs = { tabId: string @@ -50,7 +54,7 @@ function reportRendererPtyVisibility( // renderer-visibility registry, so reporting them here is misleading. continue } - window.api.pty.setRendererPtyVisible?.(ptyId, visible) + setRendererPtyVisibilityClaim(transport, ptyId, visible) } } @@ -122,7 +126,11 @@ export function useTerminalPaneGlobalEffects({ useEffect(() => { const paneTransports = paneTransportsRef.current reportRendererPtyVisibility(paneTransports, rendererVisible) - return () => reportRendererPtyVisibility(paneTransports, false) + return () => { + for (const transport of paneTransports.values()) { + releaseRendererPtyVisibilityClaim(transport) + } + } }, [rendererVisible, paneTransportsRef]) useEffect(() => { diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts index 7999db503ca..b0e2cd10ef8 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-lifecycle.ts @@ -15,6 +15,7 @@ import { resolveTerminalCursorInactiveStyle } from '@/lib/pane-manager/pane-terminal-options' import { normalizeDesktopTerminalScrollbackRows } from '../../../../shared/terminal-scrollback-policy' +import { configureTerminalOutputBacklogCap } from '@/lib/pane-manager/pane-terminal-output-scheduler' import { normalizeTerminalLineHeight } from '../../../../shared/terminal-line-height-settings' import { normalizeTerminalTuiMouseWheelMultiplier } from '@/lib/pane-manager/pane-terminal-mouse-wheel' import { buildWindowsPtyCompatibilityOptions } from '@/lib/pane-manager/windows-pty-compatibility' @@ -65,6 +66,7 @@ import { import { handleOsc52ClipboardRequest } from './osc52-clipboard' import { showOsc52ClipboardBlockedToast } from './osc52-clipboard-blocked-toast' import { parseOsc7 } from './parse-osc7' +import { guardParserHandler } from './terminal-parser-handler-guard' import { resolveTerminalJisYenInput } from './terminal-jis-yen-input' import { installTerminalImeCompositionTracker } from './terminal-ime-composition-tracker' import { @@ -107,6 +109,7 @@ import { syncTerminalScrollIntentSoon } from '@/lib/pane-manager/terminal-scroll-intent' import { registerRuntimeTerminalTab, scheduleRuntimeGraphSync } from '@/runtime/sync-runtime-graph' +import { captureParkedTerminalPaneCandidates } from './terminal-parked-tab-watchers' import { e2eConfig } from '@/lib/e2e-config' import { PRIMARY_SELECTION_MAX_LENGTH, @@ -559,6 +562,10 @@ export function useTerminalPaneLifecycle({ const terminalScrollbackRows = normalizeDesktopTerminalScrollbackRows( settings?.terminalScrollbackRows ) + // Why here: the output scheduler's backlog cap scales with the same + // scrollback setting; applying it where the setting is read keeps the two + // in lockstep without a separate settings subscription. + configureTerminalOutputBacklogCap(settings?.terminalScrollbackRows) const systemPrefersDarkRef = useRef(systemPrefersDark) systemPrefersDarkRef.current = systemPrefersDark const previousVisibleForReconcileRef = useRef(null) @@ -764,6 +771,13 @@ export function useTerminalPaneLifecycle({ setCacheTimerStartedAt, syncPanePtyLayoutBinding, clearExitedPanePtyLayoutBinding, + // Why: a DECSET 2031 subscribe answered from main's fact channel must + // land in the same registries the xterm CSI handler writes — otherwise + // theme flips never push CSI 997 and the TUI keeps a stale theme. + recordPaneMode2031Subscription: (paneId: number, repliedMode: 'dark' | 'light') => { + paneMode2031Ref.current.set(paneId, true) + paneLastThemeModeRef.current.set(paneId, repliedMode) + }, restoredPtyIdByLeafId: initialLayoutRef.current.ptyIdsByLeafId ?? {} } @@ -791,7 +805,19 @@ export function useTerminalPaneLifecycle({ const mode2031Disposables = installMode2031Handlers({ paneId: pane.id, parser: pane.terminal.parser, - onSubscribe: () => pushMode2031ForPane(pane.id), + onSubscribe: () => { + // Why: for hidden-delivery-gate-managed PTYs main's + // '2031-subscribe' fact is the sole responder — bytes reaching + // xterm live (foreground, sidecar interest) must not produce a + // second reply. The CSI handler still records the subscription. + const binding = panePtyBindings.get(pane.id) as + | (IDisposable & { isHiddenDeliveryGateManagedPty?: () => boolean }) + | undefined + if (binding?.isHiddenDeliveryGateManagedPty?.()) { + return + } + pushMode2031ForPane(pane.id) + }, isReplaying: () => isPaneReplaying(replayingPanesRef, pane.id), paneMode2031: paneMode2031Ref.current, paneLastThemeMode: paneLastThemeModeRef.current @@ -805,12 +831,15 @@ export function useTerminalPaneLifecycle({ // both the enabled and disabled paths so xterm doesn't fall // through to any other OSC 52 handler and so our intentional drop // in the disabled path is explicit. - const osc52Disposable = pane.terminal.parser.registerOscHandler(52, (data) => - handleOsc52ClipboardRequest(data, { - allowClipboardWrite: settingsRef.current?.terminalAllowOsc52Clipboard === true, - writeClipboardText: window.api.ui.writeClipboardText, - onBlockedWrite: showOsc52ClipboardBlockedToast - }) + const osc52Disposable = pane.terminal.parser.registerOscHandler( + 52, + guardParserHandler('osc-52-clipboard', (data) => + handleOsc52ClipboardRequest(data, { + allowClipboardWrite: settingsRef.current?.terminalAllowOsc52Clipboard === true, + writeClipboardText: window.api.ui.writeClipboardText, + onBlockedWrite: showOsc52ClipboardBlockedToast + }) + ) ) osc52DisposablesRef.current.set(pane.id, osc52Disposable) @@ -836,14 +865,17 @@ export function useTerminalPaneLifecycle({ confirmed: false }) } - const osc7Disposable = pane.terminal.parser.registerOscHandler(7, (data) => { - const parsedCwd = parseOsc7(data, { uncHost: osc7UncHost }) - if (parsedCwd) { - const confirmed = !isPaneReplaying(replayingPanesRef, pane.id) - paneCwdRef.current.set(pane.id, { cwd: parsedCwd, confirmed }) - } - return true - }) + const osc7Disposable = pane.terminal.parser.registerOscHandler( + 7, + guardParserHandler('osc-7-cwd', (data) => { + const parsedCwd = parseOsc7(data, { uncHost: osc7UncHost }) + if (parsedCwd) { + const confirmed = !isPaneReplaying(replayingPanesRef, pane.id) + paneCwdRef.current.set(pane.id, { cwd: parsedCwd, confirmed }) + } + return true + }) + ) osc7DisposablesRef.current.set(pane.id, osc7Disposable) // Why: let host-handled keys bypass xterm's kitty CSI-u encoder. @@ -1722,6 +1754,19 @@ export function useTerminalPaneLifecycle({ disposable.dispose() } imeNativeTextForwarderDisposables.clear() + // Why: hidden-view parking starts pane-less byte watchers right after + // this unmount; record pane identities before transports detach so the + // watchers write the same runtime-title slots the live panes used. + captureParkedTerminalPaneCandidates( + tabId, + worktreeId, + manager.getPanes().map((capturedPane) => ({ + ptyId: paneTransports.get(capturedPane.id)?.getPtyId() ?? null, + paneId: capturedPane.id, + leafId: capturedPane.leafId, + drivesTabTitle: manager.getActivePane()?.id === capturedPane.id + })) + ) for (const transport of paneTransports.values()) { const ptyId = transport.getPtyId() if ( diff --git a/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts new file mode 100644 index 00000000000..190a5fd5a27 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-tab-cold-parking.ts @@ -0,0 +1,243 @@ +/** + * Per-tab hidden-view parking for TerminalPaneOverlayLayer. + * + * Why: owns the cold-park policy bookkeeping (hiddenSince tracking, recheck + * timers, parked-set selection) and the parked byte-watcher reconciliation so + * the overlay layer only consumes the final parked tab set when deciding to + * render a slot as null. See docs/reference/terminal-hidden-view-parking.md. + */ +import { useEffect, useMemo, useRef, useState } from 'react' +import type { TerminalTab } from '../../../../shared/types' +import { useAppStore } from '../../store' +import { + findActivityTerminalPortal, + type ActivityTerminalPortalTarget +} from '../activity/activity-terminal-portal' +import { + getTerminalTabColdParkRecheckDelayMs, + selectColdParkedTerminalTabs, + type TerminalTabColdParkCandidate +} from './terminal-hidden-view-parking' +import { getTerminalParkingPolicyOverrides } from './terminal-parking-e2e-overrides' +import { + canWatcherCoverParkedTerminalTab, + disposeParkedTerminalWatchersForWorktree, + syncParkedTerminalTabWatchers +} from './terminal-parked-tab-watchers' + +type TerminalOverlayTabAssignment = { + groupId: string + isActiveInGroup: boolean +} + +function haveSameTerminalTabIds(left: ReadonlySet, right: ReadonlySet): boolean { + if (left.size !== right.size) { + return false + } + for (const id of left) { + if (!right.has(id)) { + return false + } + } + return true +} + +export function useTerminalTabColdParking(args: { + worktreeId: string + terminalTabs: readonly TerminalTab[] + assignments: ReadonlyMap + isWorktreeActive: boolean + /** Worktree-level park verdict from Terminal.tsx. */ + coldParkTerminalPanes: boolean + /** Hidden-measuring startup probe from Terminal.tsx — the panes must stay + * mounted for their first xterm fit, mirroring the worktree-level guard. */ + shouldMeasureHiddenWorktree: boolean + activityTerminalPortals: ActivityTerminalPortalTarget[] +}): ReadonlySet { + const { + worktreeId, + terminalTabs, + assignments, + isWorktreeActive, + coldParkTerminalPanes, + shouldMeasureHiddenWorktree, + activityTerminalPortals + } = args + const pendingStartupByTabId = useAppStore((state) => state.pendingStartupByTabId) + const terminalParkingEnabled = useAppStore( + (state) => state.settings?.terminalHiddenViewParking !== false + ) + const terminalTabHiddenSinceRef = useRef(new Map()) + const terminalTabParkingTimersRef = useRef(new Map()) + const [terminalTabParkingRevision, setTerminalTabParkingRevision] = useState(0) + const [coldParkedTerminalTabIds, setColdParkedTerminalTabIds] = useState>( + () => new Set() + ) + + useEffect(() => { + const timers = terminalTabParkingTimersRef.current + return () => { + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + } + }, []) + + // Why: per-tab cold-park policy — hiddenSince bookkeeping, parked-set + // selection, and one recheck timer per still-pending deadline so React + // re-renders exactly when the hysteresis elapses instead of polling. + useEffect(() => { + const timers = terminalTabParkingTimersRef.current + for (const timer of timers.values()) { + window.clearTimeout(timer) + } + timers.clear() + + const nowMs = Date.now() + const overrides = getTerminalParkingPolicyOverrides() + const currentTerminalTabIds = new Set(terminalTabs.map((tab) => tab.id)) + const portalTabIds = new Set( + activityTerminalPortals + .filter((portal) => portal.worktreeId === worktreeId) + .map((portal) => portal.tabId) + ) + for (const tabId of Array.from(terminalTabHiddenSinceRef.current.keys())) { + if (!currentTerminalTabIds.has(tabId)) { + terminalTabHiddenSinceRef.current.delete(tabId) + } + } + + const candidates: TerminalTabColdParkCandidate[] = terminalTabs.map((terminalTab) => { + const assignment = assignments.get(terminalTab.id) + const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) + const hasActivityTerminalPortal = portalTabIds.has(terminalTab.id) + // Why: hidden-measuring counts as visibility — the startup probe needs + // mounted panes, so the hidden clock must not run during it. + if (isVisible || hasActivityTerminalPortal || shouldMeasureHiddenWorktree) { + terminalTabHiddenSinceRef.current.delete(terminalTab.id) + } else if (!terminalTabHiddenSinceRef.current.has(terminalTab.id)) { + terminalTabHiddenSinceRef.current.set(terminalTab.id, nowMs) + } + return { + id: terminalTab.id, + ptyId: terminalTab.ptyId, + pendingActivationSpawn: terminalTab.pendingActivationSpawn, + isVisible, + hasActivityTerminalPortal, + hiddenSinceMs: terminalTabHiddenSinceRef.current.get(terminalTab.id) ?? null + } + }) + + const nextColdParkedTerminalTabIds = selectColdParkedTerminalTabs({ + worktreeId, + terminalTabs: candidates, + pendingStartupByTabId, + parkingEnabled: terminalParkingEnabled, + nowMs, + ...overrides + }) + // Why: a tab the byte watchers cannot cover (no capture, no layout + // snapshot, legacy leaf ids) must never park — it would go silent for + // bells/titles/completions, the failure that sank the first attempt. + for (const terminalTab of terminalTabs) { + if ( + nextColdParkedTerminalTabIds.has(terminalTab.id) && + !canWatcherCoverParkedTerminalTab(worktreeId, terminalTab) + ) { + nextColdParkedTerminalTabIds.delete(terminalTab.id) + } + } + setColdParkedTerminalTabIds((current) => + haveSameTerminalTabIds(current, nextColdParkedTerminalTabIds) + ? current + : nextColdParkedTerminalTabIds + ) + + for (const candidate of candidates) { + if ( + candidate.isVisible || + candidate.hasActivityTerminalPortal || + nextColdParkedTerminalTabIds.has(candidate.id) + ) { + continue + } + const delayMs = getTerminalTabColdParkRecheckDelayMs({ + parkingEnabled: terminalParkingEnabled, + hiddenSinceMs: candidate.hiddenSinceMs, + nowMs, + ...overrides + }) + if (delayMs !== null && delayMs > 0) { + const tabId = candidate.id + const timer = window.setTimeout(() => { + timers.delete(tabId) + setTerminalTabParkingRevision((revision) => revision + 1) + }, delayMs) + timers.set(tabId, timer) + } + } + }, [ + activityTerminalPortals, + assignments, + isWorktreeActive, + pendingStartupByTabId, + shouldMeasureHiddenWorktree, + terminalParkingEnabled, + terminalTabParkingRevision, + terminalTabs, + worktreeId + ]) + + // Why: the rendered park verdict — worktree-level park (prop from + // Terminal.tsx) or per-tab cold park, never portal-hosted tabs. Render and + // the watcher-sync effect must share this exact set so watcher lifecycle + // tracks the committed unmounts. + const parkedTerminalTabIds = useMemo(() => { + const parked = new Set() + for (const terminalTab of terminalTabs) { + const assignment = assignments.get(terminalTab.id) + const isVisible = Boolean(isWorktreeActive && assignment && assignment.isActiveInGroup) + const hasActivityTerminalPortal = + findActivityTerminalPortal(activityTerminalPortals, { + worktreeId, + tabId: terminalTab.id + }) !== null + if ( + (coldParkTerminalPanes || (!isVisible && coldParkedTerminalTabIds.has(terminalTab.id))) && + !hasActivityTerminalPortal && + // Why: the hidden-measuring startup probe needs mounted panes; gate + // here too so the reveal lands in the same render that starts it. + !shouldMeasureHiddenWorktree + ) { + parked.add(terminalTab.id) + } + } + return parked + }, [ + activityTerminalPortals, + assignments, + coldParkTerminalPanes, + coldParkedTerminalTabIds, + isWorktreeActive, + shouldMeasureHiddenWorktree, + terminalTabs, + worktreeId + ]) + + // Why: runs in the same effect flush as the commit that parked/revealed the + // panes — watcher disposal therefore lands before any PTY data IPC can + // reach a freshly remounted pane, and watcher start lands after the parked + // pane's unmount capture. + useEffect(() => { + syncParkedTerminalTabWatchers({ + worktreeId, + tabs: terminalTabs, + parkedTabIds: parkedTerminalTabIds + }) + }, [parkedTerminalTabIds, terminalTabs, worktreeId]) + + useEffect(() => () => disposeParkedTerminalWatchersForWorktree(worktreeId), [worktreeId]) + + return parkedTerminalTabIds +} diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts new file mode 100644 index 00000000000..c83cdde9288 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.test.ts @@ -0,0 +1,114 @@ +// @vitest-environment happy-dom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { renderHook } from '@testing-library/react' +import type { PaneManager } from '@/lib/pane-manager/pane-manager' + +const { recoverVisibleTerminalWindowWakeMock } = vi.hoisted(() => ({ + recoverVisibleTerminalWindowWakeMock: vi.fn() +})) + +vi.mock('./terminal-visibility-resume', () => ({ + recoverVisibleTerminalWindowWake: recoverVisibleTerminalWindowWakeMock +})) + +import { useTerminalWindowWakeRecovery } from './use-terminal-window-wake-recovery' +import { + getTerminalFreezeBreadcrumbs, + resetTerminalFreezeBreadcrumbsForTesting +} from './terminal-freeze-breadcrumbs' + +describe('useTerminalWindowWakeRecovery', () => { + const manager = {} as PaneManager + let systemResumedCallback: (() => void) | null = null + const unsubscribeSystemResumed = vi.fn() + const onSystemResumed = vi.fn((callback: () => void) => { + systemResumedCallback = callback + return unsubscribeSystemResumed + }) + + beforeEach(() => { + systemResumedCallback = null + recoverVisibleTerminalWindowWakeMock.mockClear() + unsubscribeSystemResumed.mockClear() + onSystemResumed.mockClear() + resetTerminalFreezeBreadcrumbsForTesting() + // Why: without requestAnimationFrame the hook skips its settled-frame + // follow-up, so every trigger maps to exactly one synchronous recovery. + vi.stubGlobal('requestAnimationFrame', undefined) + ;(window as unknown as { api: unknown }).api = { ui: { onSystemResumed } } + }) + + afterEach(() => { + vi.unstubAllGlobals() + delete (window as unknown as { api?: unknown }).api + }) + + function renderWakeRecoveryHook(isVisible = true) { + return renderHook(() => + useTerminalWindowWakeRecovery({ + isVisible, + managerRef: { current: manager }, + isActiveRef: { current: true }, + isVisibleRef: { current: true } + }) + ) + } + + it('clears the glyph atlas on system resume but not on plain window focus', () => { + // Why: wiping the shared WebGL glyph atlas on a plain refocus provokes + // xterm's page-merge race and paints garbled glyphs (#7604). Only a genuine + // OS resume — which can leave a stale renderer surface — clears the atlas. + renderWakeRecoveryHook() + + window.dispatchEvent(new Event('focus')) + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenCalledTimes(1) + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenNthCalledWith(1, { + manager, + isActive: true, + clearGlyphAtlases: false + }) + + expect(systemResumedCallback).toBeTypeOf('function') + systemResumedCallback?.() + + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenCalledTimes(2) + expect(recoverVisibleTerminalWindowWakeMock).toHaveBeenNthCalledWith(2, { + manager, + isActive: true, + clearGlyphAtlases: true + }) + }) + + it('records a wake-recovery breadcrumb with the trigger source and atlas decision', () => { + // Why: a post-wake garble report attributes to the trigger that ran (or its + // absence). Pin that focus records source=focus/atlas=false and system + // resume records source=system-resumed/atlas=true. + renderWakeRecoveryHook() + + window.dispatchEvent(new Event('focus')) + systemResumedCallback?.() + + const wakeCrumbs = getTerminalFreezeBreadcrumbs().filter((crumb) => + crumb.kind.startsWith('wake-recovery:') + ) + expect(wakeCrumbs.map((crumb) => [crumb.kind, crumb.detail])).toEqual([ + ['wake-recovery:focus', { clearGlyphAtlases: false }], + ['wake-recovery:system-resumed', { clearGlyphAtlases: true }] + ]) + }) + + it('unsubscribes from the system resume event on cleanup', () => { + const { unmount } = renderWakeRecoveryHook() + expect(onSystemResumed).toHaveBeenCalledTimes(1) + + unmount() + + expect(unsubscribeSystemResumed).toHaveBeenCalledTimes(1) + }) + + it('does not subscribe while the terminal surface is hidden', () => { + renderWakeRecoveryHook(false) + + expect(onSystemResumed).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts index 8c5da7ed97b..839b6184696 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-window-wake-recovery.ts @@ -1,6 +1,7 @@ import { useEffect } from 'react' import type { PaneManager } from '@/lib/pane-manager/pane-manager' import { recoverVisibleTerminalWindowWake } from './terminal-visibility-resume' +import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs' type UseTerminalWindowWakeRecoveryArgs = { isVisible: boolean @@ -29,7 +30,19 @@ export function useTerminalWindowWakeRecovery({ cancelAnimationFrame(wakeRecoveryFrameId) wakeRecoveryFrameId = null } - const recoverVisibleWake = (clearGlyphAtlases: boolean): void => { + const recoverVisibleWake = ( + clearGlyphAtlases: boolean, + source: 'focus' | 'visibilitychange' | 'system-resumed' + ): void => { + // Why: the decisive crumb for a post-wake garble report — which trigger + // fired and whether it wiped the atlas. If the report shows a stale pane + // but NO wake crumb near the unlock time, the trigger never fired (the + // unlock-screen gap); a crumb with clearGlyphAtlases=false means the light + // path ran but may not have healed a corrupted atlas. Silent (memory ring). + // Source is in the kind so distinct triggers don't coalesce into one + // entry (focus and resume often fire together); repeats of the same + // source still fold, which is the noise control we want. + recordTerminalFreezeBreadcrumb(`wake-recovery:${source}`, { clearGlyphAtlases }) // Focus and visibility often fire together; keep one immediate recovery and one settled RAF pass. if (wakeRecoveryFrameId !== null) { // Why: a pending settled pass may only upgrade in strength — a plain @@ -69,23 +82,27 @@ export function useTerminalWindowWakeRecovery({ // an agent streams; wiping the shared glyph atlas then provokes xterm's // page-merge race and paints garbled glyphs. Focus recovery keeps the warm // atlas: it only retries WebGL attach, refits, and repaints pane-scoped. - const onFocus = (): void => recoverVisibleWake(false) + const onFocus = (): void => recoverVisibleWake(false, 'focus') const onVisibilityChange = (): void => { if (typeof document !== 'undefined' && document.visibilityState === 'visible') { - recoverVisibleWake(true) + recoverVisibleWake(true, 'visibilitychange') } } // Why: Linux has no window-occlusion tracking, so visibilitychange never // fires around system suspend; the main process broadcasts OS resume. const onSystemResumed = (): void => { if (typeof document === 'undefined' || document.visibilityState === 'visible') { - recoverVisibleWake(true) + recoverVisibleWake(true, 'system-resumed') } } window.addEventListener('focus', onFocus) if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') { document.addEventListener('visibilitychange', onVisibilityChange) } + // Why: a focus-preserving display wake fires neither focus nor + // visibilitychange, so main relays powerMonitor resume over IPC. Genuine + // wake clears the WebGL glyph atlas (clearGlyphAtlases=true via + // onSystemResumed) — the latch-clearing recovery — unlike plain refocus. const unsubscribeSystemResumed = typeof window.api?.ui?.onSystemResumed === 'function' ? window.api.ui.onSystemResumed(onSystemResumed) diff --git a/src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts b/src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts new file mode 100644 index 00000000000..084b74edcc3 --- /dev/null +++ b/src/renderer/src/components/terminal-pane/xterm-write-buffer-stall.repro.test.ts @@ -0,0 +1,79 @@ +/** + * Repro for the frozen-terminal investigation (Discord #performance / #2836). + * + * The vendored xterm WriteBuffer (6.1.0-beta.287) permanently wedges when a + * synchronous exception escapes a write-completion callback: `_innerWrite` + * has no try/catch around `cb()`, the tail `_scheduleInnerWrite()` never + * runs, and later `write()` calls only re-schedule processing when the + * buffer is EMPTY — which a stalled buffer never is again. + * + * In Orca, write-completion callbacks run settleForegroundRender → refresh → + * renderer/WebGL code (pane-terminal-foreground-render-settle.ts) and the + * replay-guard decrement (replay-guard.ts). So one renderer exception during + * write completion freezes that pane's output forever AND latches the replay + * guard, whose gate in pty-connection.ts onData then silently drops every + * keystroke — the exact live-shell/flat-output.log/frozen-pane state the + * field reports describe. @xterm/headless shares the same WriteBuffer as + * @xterm/xterm at the same pinned version. + */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Terminal } from '@xterm/headless' + +describe('xterm WriteBuffer stall (vendored 6.1.0-beta.287)', () => { + afterEach(() => { + vi.useRealTimers() + }) + + it('permanently stops completing writes after a sync throw in a write-completion callback', () => { + vi.useFakeTimers() + const term = new Terminal({ allowProposedApi: true }) + const completed: string[] = [] + + term.write('first', () => { + completed.push('first') + throw new Error('synthetic renderer failure during write completion') + }) + term.write('second', () => { + completed.push('second') + }) + + expect(() => vi.runAllTimers()).toThrow('synthetic renderer failure') + + // The wedge: the stalled buffer is never empty again, so new writes only + // enqueue — no drain is ever scheduled and no callback ever fires. + term.write('third', () => { + completed.push('third') + }) + vi.runAllTimers() + expect(completed).toEqual(['first']) + expect(vi.getTimerCount()).toBe(0) + }) + + it('permanently stops completing writes after a sync throw in a custom parser handler', () => { + // Orca registers custom CSI/OSC handlers (capability replies, titles, + // agent status). The parser does NOT isolate sync handler exceptions: + // they escape _action and wedge the buffer exactly like the callback + // case — custom handlers are a second freeze vector. + vi.useFakeTimers() + const term = new Terminal({ allowProposedApi: true }) + const completed: string[] = [] + term.parser.registerCsiHandler({ final: 'z' }, () => { + throw new Error('synthetic parser handler failure') + }) + + term.write('\x1b[z', () => { + completed.push('poisoned') + }) + term.write('after', () => { + completed.push('after') + }) + expect(() => vi.runAllTimers()).toThrow('synthetic parser handler failure') + + term.write('later', () => { + completed.push('later') + }) + vi.runAllTimers() + expect(completed).toEqual([]) + expect(vi.getTimerCount()).toBe(0) + }) +}) diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 31c51256a88..cbf24b7c1a6 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -67,6 +67,10 @@ declare global { interface Window { __paneManagers?: Map __onboardingFeatureSetupDeps?: OnboardingFeatureSetupDeps + __terminalParkingDebug?: { + parkDelayMs: number + parkedTabIds: () => string[] + } } } diff --git a/src/renderer/src/lib/automation-session-observer.test.ts b/src/renderer/src/lib/automation-session-observer.test.ts new file mode 100644 index 00000000000..6c8efbf81cf --- /dev/null +++ b/src/renderer/src/lib/automation-session-observer.test.ts @@ -0,0 +1,129 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockSubscribeToPtyData = vi.fn() +const mockSubscribeToPtyExit = vi.fn() +const mockSubscribeTerminal = vi.fn() +const mockCallRuntimeRpc = vi.fn() + +const state = { + settings: { + activeRuntimeEnvironmentId: null as string | null, + terminalMainSideEffectAuthority: undefined as boolean | undefined + }, + setAgentStatus: vi.fn() +} + +vi.mock('@/store', () => ({ + useAppStore: { + getState: () => state + } +})) + +vi.mock('@/components/terminal-pane/pty-dispatcher', () => ({ + subscribeToPtyExit: mockSubscribeToPtyExit +})) + +vi.mock('@/components/terminal-pane/pty-data-sidecar-subscriptions', () => ({ + subscribeToPtyData: mockSubscribeToPtyData +})) + +vi.mock('@/runtime/runtime-rpc-client', () => ({ + callRuntimeRpc: mockCallRuntimeRpc, + getActiveRuntimeTarget: vi.fn(() => ({ kind: 'local' })) +})) + +vi.mock('@/runtime/remote-runtime-terminal-multiplexer', () => ({ + getRemoteRuntimeTerminalMultiplexer: () => ({ subscribeTerminal: mockSubscribeTerminal }) +})) + +const DONE_STATUS_OSC = '\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07' + +describe('observeExistingAutomationSession', () => { + beforeEach(() => { + vi.clearAllMocks() + state.settings = { + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined + } + mockSubscribeToPtyData.mockReturnValue(vi.fn()) + mockSubscribeToPtyExit.mockReturnValue(vi.fn()) + mockCallRuntimeRpc.mockReturnValue(new Promise(() => {})) + mockSubscribeTerminal.mockResolvedValue({ close: vi.fn() }) + }) + + it('skips the duplicate OSC store write for local PTYs under main authority', async () => { + // Why: main already parses OSC 9999 for local/SSH PTYs and routes it to + // the store via agentStatus:set; writing here too would race that path. + const onAgentStatus = vi.fn() + const { observeExistingAutomationSession } = await import('./automation-session-observer') + + await observeExistingAutomationSession({ + ptyId: 'pty-local-1', + paneKey: 'tab-1:leaf-1', + runId: 'run-1', + onData: vi.fn(), + onAgentStatus, + onExit: vi.fn() + }) + + const handleData = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + handleData(DONE_STATUS_OSC) + + expect(state.setAgentStatus).not.toHaveBeenCalled() + expect(onAgentStatus).toHaveBeenCalledWith( + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }) + ) + }) + + it('keeps the legacy OSC store write when the kill switch is off', async () => { + state.settings.terminalMainSideEffectAuthority = false + const onAgentStatus = vi.fn() + const { observeExistingAutomationSession } = await import('./automation-session-observer') + + await observeExistingAutomationSession({ + ptyId: 'pty-local-1', + paneKey: 'tab-1:leaf-1', + runId: 'run-1', + onData: vi.fn(), + onAgentStatus, + onExit: vi.fn() + }) + + const handleData = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + handleData(DONE_STATUS_OSC) + + expect(state.setAgentStatus).toHaveBeenCalledWith( + 'tab-1:leaf-1', + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }), + undefined + ) + expect(onAgentStatus).toHaveBeenCalledTimes(1) + }) + + it('keeps the OSC store write for remote-runtime PTYs (bytes never transit local main)', async () => { + const onAgentStatus = vi.fn() + const { observeExistingAutomationSession } = await import('./automation-session-observer') + + await observeExistingAutomationSession({ + ptyId: 'remote:env-1@@terminal-9', + paneKey: 'tab-1:leaf-1', + runId: 'run-1', + onData: vi.fn(), + onAgentStatus, + onExit: vi.fn() + }) + + expect(mockSubscribeTerminal).toHaveBeenCalledTimes(1) + const callbacks = mockSubscribeTerminal.mock.calls[0]?.[0]?.callbacks as { + onData: (data: string) => void + } + callbacks.onData(DONE_STATUS_OSC) + + expect(state.setAgentStatus).toHaveBeenCalledWith( + 'tab-1:leaf-1', + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }), + undefined + ) + expect(onAgentStatus).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/renderer/src/lib/automation-session-observer.ts b/src/renderer/src/lib/automation-session-observer.ts index c80ae9156e8..9976468ed69 100644 --- a/src/renderer/src/lib/automation-session-observer.ts +++ b/src/renderer/src/lib/automation-session-observer.ts @@ -10,6 +10,7 @@ import { import { useAppStore } from '@/store' import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc' import type { ParsedAgentStatusPayload } from '../../../shared/agent-status-types' +import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' export async function observeExistingAutomationSession(args: { ptyId: string @@ -20,12 +21,25 @@ export async function observeExistingAutomationSession(args: { onExit: (code: number) => void }): Promise<() => void> { const { ptyId, paneKey, runId, onData, onExit } = args + // Why: for local/SSH PTYs main already parses OSC 9999 and routes it + // through the hook server (agentStatus:set → store); writing here too + // would race/duplicate that path. Remote-runtime bytes never transit local + // main, and the kill switch restores the legacy write. The onAgentStatus + // callback always fires — automation completion tracking stays here. + const mainOwnsAgentStatusWrites = + !isRemoteRuntimePtyId(ptyId) && + isMainTerminalSideEffectAuthorityForPty({ + settings: useAppStore.getState().settings, + runtimeEnvironmentId: null + }) const processAgentStatus = createAgentStatusOscProcessor() const handleData = (data: string): void => { onData(data) const processed = processAgentStatus(data) for (const payload of processed.payloads) { - useAppStore.getState().setAgentStatus(paneKey, payload, undefined) + if (!mainOwnsAgentStatusWrites) { + useAppStore.getState().setAgentStatus(paneKey, payload, undefined) + } args.onAgentStatus(payload) } } diff --git a/src/renderer/src/lib/crash-breadcrumb-recorder.ts b/src/renderer/src/lib/crash-breadcrumb-recorder.ts new file mode 100644 index 00000000000..c4a646ea01c --- /dev/null +++ b/src/renderer/src/lib/crash-breadcrumb-recorder.ts @@ -0,0 +1,25 @@ +import type { CrashReportBreadcrumbData } from '../../../shared/crash-reporting' + +// Why a leaf module: terminal modules (replay-guard, output scheduler, parser +// guards) record breadcrumbs, and e2e specs import those modules' constants — +// Playwright loads spec imports at collection time under a transform that +// cannot handle crash-diagnostics.ts (top-level `import.meta.hot` plus the +// webview-registry import chain). Keep this file free of value imports and +// import.meta so it stays loadable from any context. + +/** Best-effort breadcrumb recording; must never create or mask failures. */ +export function recordRendererCrashBreadcrumb( + name: string, + data?: CrashReportBreadcrumbData +): void { + if (typeof window === 'undefined') { + return + } + + try { + const api = (window as Window & { api?: Window['api'] }).api + api?.crashReports.recordBreadcrumb({ name, ...(data ? { data } : {}) }) + } catch { + // Best-effort crash evidence only. + } +} diff --git a/src/renderer/src/lib/crash-diagnostics.ts b/src/renderer/src/lib/crash-diagnostics.ts index aa7ec860ef6..bb0875f1433 100644 --- a/src/renderer/src/lib/crash-diagnostics.ts +++ b/src/renderer/src/lib/crash-diagnostics.ts @@ -3,6 +3,7 @@ import type { CrashReportDetailValue } from '../../../shared/crash-reporting' import { getBrowserWebviewMemoryProfile } from '../components/browser-pane/webview-registry' +import { recordRendererCrashBreadcrumb } from './crash-breadcrumb-recorder' const RENDERER_MEMORY_SAMPLE_INTERVAL_MS = 60_000 const BYTES_PER_MEGABYTE = 1024 * 1024 @@ -16,22 +17,10 @@ type BrowserPerformanceMemory = { let rendererCrashDiagnosticsInstalled = false let rendererMemoryInterval: number | null = null -export function recordRendererCrashBreadcrumb( - name: string, - data?: CrashReportBreadcrumbData -): void { - if (typeof window === 'undefined') { - return - } - - try { - // Why: crash diagnostics must never create or mask renderer startup failures. - const api = (window as Window & { api?: Window['api'] }).api - api?.crashReports.recordBreadcrumb({ name, ...(data ? { data } : {}) }) - } catch { - // Best-effort crash evidence only. - } -} +// Why re-exported from a leaf module: terminal modules and their e2e-visible +// import chains need breadcrumb recording without this file's import.meta / +// webview-registry baggage. See crash-breadcrumb-recorder.ts. +export { recordRendererCrashBreadcrumb } from './crash-breadcrumb-recorder' export function installRendererCrashDiagnostics(): void { if (rendererCrashDiagnosticsInstalled || typeof window === 'undefined') { diff --git a/src/renderer/src/lib/github-links.ts b/src/renderer/src/lib/github-links.ts index 05ea8c812f5..34a53c0494e 100644 --- a/src/renderer/src/lib/github-links.ts +++ b/src/renderer/src/lib/github-links.ts @@ -1,19 +1,20 @@ +// Why: the parsing core moved to shared so main's terminal side-effect +// tracker can emit pr-link facts (terminal-side-effect-authority.md, slice 3). +// Re-exported here so renderer consumers keep their '@/lib' import path. +// normalizeGitHubLinkQuery stays renderer-side: its too-large guard is link- +// picker input policy, not parsing. +import { + type GitHubIssueOrPRLink, + parseGitHubIssueOrPRLink, + parseGitHubIssueOrPRNumber +} from '../../../shared/github-links' + import { isWorkItemLinkQueryTooLarge } from './work-item-link-query-bounds' -const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i +export * from '../../../shared/github-links' + const HTTP_URL_PREFIX_RE = /^https?:\/\//i -export type RepoSlug = { - owner: string - repo: string -} - -export type GitHubIssueOrPRLink = { - slug: RepoSlug - number: number - type: 'issue' | 'pr' -} - export type GitHubLinkQuery = { query: string directNumber: number | null @@ -21,93 +22,6 @@ export type GitHubLinkQuery = { tooLarge?: boolean } -export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null { - if (!slug?.owner || !slug.repo) { - return null - } - return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}` -} - -function matchGitHubItemPath(url: URL): RegExpExecArray | null { - return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, '')) -} - -function parseGitHubItemNumber(value: string): number | null { - const parsed = Number.parseInt(value, 10) - return parsed > 0 ? parsed : null -} - -/** - * Parses a GitHub issue/PR reference from plain input. - * Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs. - */ -export function parseGitHubIssueOrPRNumber(input: string): number | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed - if (/^\d+$/.test(numeric)) { - return parseGitHubItemNumber(numeric) - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - return null - } - - const match = matchGitHubItemPath(url) - if (!match) { - return null - } - - return parseGitHubItemNumber(match[4]) -} - -/** - * Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns - * null for anything that isn't a recognizable GitHub-shaped issue or pull URL. - */ -export function parseGitHubIssueOrPRLink(input: string): GitHubIssueOrPRLink | null { - const trimmed = input.trim() - if (!trimmed) { - return null - } - - let url: URL - try { - url = new URL(trimmed) - } catch { - return null - } - - if (url.protocol !== 'https:' && url.protocol !== 'http:') { - return null - } - - const match = matchGitHubItemPath(url) - if (!match) { - return null - } - const number = parseGitHubItemNumber(match[4]) - if (number === null) { - return null - } - - return { - slug: { owner: match[1], repo: match[2] }, - type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue', - number - } -} - /** * Normalizes link-picker input so both raw issue/PR numbers and full GitHub * URLs resolve to a usable query + direct-number lookup. diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 2a089f8411c..96f01753116 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -38,7 +38,11 @@ function expectStablePaneSpawn(): string { const state = { activeRepoId: 'repo-1', activeWorktreeId: 'wt-1', - settings: { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null as string | null }, + settings: { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: null as string | null, + terminalMainSideEffectAuthority: undefined as boolean | undefined + }, projects: [ { id: 'repo-1', @@ -110,7 +114,11 @@ describe('launchAgentBackgroundSession', () => { ) state.activeRepoId = 'repo-1' state.activeWorktreeId = 'wt-1' - state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: null } + state.settings = { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined + } state.projects = [ { id: 'repo-1', @@ -339,7 +347,10 @@ describe('launchAgentBackgroundSession', () => { expect(mockSpawn).toHaveBeenCalled() }) - it('parses agent status from hidden PTY output', async () => { + it('parses agent status from hidden PTY output when the kill switch is off', async () => { + // Why: with main side-effect authority disabled, this sidecar is the only + // OSC 9999 → store path for hidden local sessions. + state.settings.terminalMainSideEffectAuthority = false const onAgentStatus = vi.fn() const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -367,6 +378,29 @@ describe('launchAgentBackgroundSession', () => { ) }) + it('skips the duplicate OSC store write under main side-effect authority', async () => { + // Why: main already routes OSC 9999 through the hook server to the store + // (agentStatus:set); a second write here would race the authoritative + // path. The automation onAgentStatus callback must still fire. + const onAgentStatus = vi.fn() + const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') + + await launchAgentBackgroundSession({ + agent: 'claude', + worktreeId: 'wt-1', + prompt: 'run the automation', + onAgentStatus + }) + + const dataSidecar = mockSubscribeToPtyData.mock.calls[0]?.[1] as (data: string) => void + dataSidecar('\x1b]9999;{"state":"done","prompt":"ok","agentType":"codex"}\x07') + + expect(state.setAgentStatus).not.toHaveBeenCalled() + expect(onAgentStatus).toHaveBeenCalledWith( + expect.objectContaining({ state: 'done', prompt: 'ok', agentType: 'codex' }) + ) + }) + it('seeds a working status for Command Code prompt launches', async () => { const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -530,7 +564,8 @@ describe('launchAgentBackgroundSession', () => { state.repos = [{ id: 'repo-1', connectionId: 'ssh-1', path: '/repo' }] state.settings = { agentCmdOverrides: { codex: "codex --prefill 'draft from override'" }, - activeRuntimeEnvironmentId: null + activeRuntimeEnvironmentId: null, + terminalMainSideEffectAuthority: undefined } const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') @@ -591,7 +626,11 @@ describe('launchAgentBackgroundSession', () => { }) it('creates background sessions on the active runtime environment', async () => { - state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' } + state.settings = { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: 'env-1', + terminalMainSideEffectAuthority: undefined + } const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') const result = await launchAgentBackgroundSession({ @@ -663,7 +702,11 @@ describe('launchAgentBackgroundSession', () => { }) it('closes a created runtime terminal when its data subscription fails', async () => { - state.settings = { agentCmdOverrides: {}, activeRuntimeEnvironmentId: 'env-1' } + state.settings = { + agentCmdOverrides: {}, + activeRuntimeEnvironmentId: 'env-1', + terminalMainSideEffectAuthority: undefined + } mockRuntimeEnvironmentSubscribe.mockRejectedValueOnce(new Error('subscription failed')) const { launchAgentBackgroundSession } = await import('./launch-agent-background-session') diff --git a/src/renderer/src/lib/launch-agent-background-session.ts b/src/renderer/src/lib/launch-agent-background-session.ts index 85a8f1171d7..ccdbd5fa16d 100644 --- a/src/renderer/src/lib/launch-agent-background-session.ts +++ b/src/renderer/src/lib/launch-agent-background-session.ts @@ -37,6 +37,7 @@ import { createAgentStatusOscProcessor } from '../../../shared/agent-status-osc' import type { RuntimeTerminalCreate } from '../../../shared/runtime-types' import { createSshBackgroundStartupDelivery } from '@/lib/ssh-background-startup-delivery' import { shouldUseShellReadyStartupDelivery } from '../../../shared/codex-startup-delivery' +import { isMainTerminalSideEffectAuthorityForPty } from '@/components/terminal-pane/terminal-side-effect-facts-handler' function runBestEffortCleanup(action: () => void): void { try { @@ -163,6 +164,12 @@ export async function launchAgentBackgroundSession( useAppStore.getState().clearAgentLaunchConfig(paneKey) onExit?.(exitPtyId, code) } + // Why: local/SSH status facts already pass through main's authoritative + // scanner; remote-runtime bytes still need this renderer-side store write. + const mainOwnsAgentStatusWrites = isMainTerminalSideEffectAuthorityForPty({ + settings: store.settings, + runtimeEnvironmentId: runtimeTarget.kind === 'environment' ? runtimeTarget.environmentId : null + }) const processAgentStatus = createAgentStatusOscProcessor() const handleData = (data: string): void => { data = sshStartupDelivery.handleData(data) @@ -170,9 +177,11 @@ export async function launchAgentBackgroundSession( sshStartupDelivery.schedule(ptyId) const processed = processAgentStatus(data) for (const payload of processed.payloads) { - useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, { - launchToken - }) + if (!mainOwnsAgentStatusWrites) { + useAppStore.getState().setAgentStatus(paneKey, payload, undefined, undefined, undefined, { + launchToken + }) + } onAgentStatus?.(payload) } } diff --git a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts index 5fa2be90a20..2a52b31ba78 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager-registry.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager-registry.ts @@ -1,7 +1,11 @@ +import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' +import type { PaneRenderingDiagnostics } from './pane-manager-types' + type RegisteredPaneManager = { resetWebglTextureAtlases(): void fitAllPanes?: () => void refreshAllPanes?: () => void + getRenderingDiagnostics?: () => PaneRenderingDiagnostics[] } const liveManagers = new Set() @@ -35,6 +39,9 @@ export function resetAllTerminalWebglAtlases(): void { } export function resetAndRefreshAllTerminalWebglAtlases(): void { + // Why: the atlas wipe is the heavy recovery path; recording it lets a freeze + // report show whether a post-wake repaint actually ran. Silent breadcrumb. + recordTerminalWebglDiagnostic('webgl-atlas-reset', { managers: liveManagers.size }) const resetManagers: RegisteredPaneManager[] = [] for (const manager of liveManagers) { try { @@ -55,6 +62,27 @@ export function resetAndRefreshAllTerminalWebglAtlases(): void { } } +/** + * Per-pane WebGL renderer state across all live managers, for the one-paste + * freeze report. Lets a post-wake garble report show, per pane, whether it + * held a live WebGL addon or had fallen back after a context loss — the state + * that distinguishes "missed repaint" from "atlas corrupted". + */ +export function getAllPaneRenderingDiagnostics(): PaneRenderingDiagnostics[] { + const all: PaneRenderingDiagnostics[] = [] + for (const manager of liveManagers) { + try { + const diagnostics = manager.getRenderingDiagnostics?.() + if (diagnostics) { + all.push(...diagnostics) + } + } catch { + // Why: best-effort during teardown; one manager must not sink the report. + } + } + return all +} + export function refitAndRefreshAllTerminalPanes(): void { for (const manager of liveManagers) { try { diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts index 93b848e7450..eab1caa497d 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts @@ -1,3 +1,5 @@ +import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' + export type ForegroundTerminalOutputTarget = { buffer?: { active?: { @@ -131,18 +133,24 @@ export function writeForegroundTerminalChunk( const beforeWriteViewport = options.forceViewportRefresh ? captureViewportSnapshot(terminal) : null - try { - terminal.write(data, () => { - if (beforeWriteViewport) { - settleForegroundRender(terminal, beforeWriteViewport, options) - } - options.onParsed?.() - }) - } catch { + // Why guarded steps: this callback runs inside xterm's WriteBuffer loop, + // where an escaping throw permanently wedges the terminal (see + // xterm-write-callback-guard.ts). Guard settle and onParsed separately so a + // renderer/WebGL failure during settle can't starve the replay-guard release. + const runCompletionSteps = (): void => { if (beforeWriteViewport) { - settleForegroundRender(terminal, beforeWriteViewport, options) + runGuardedWriteCompletionStep('foreground-render-settle', () => + settleForegroundRender(terminal, beforeWriteViewport, options) + ) } - options.onParsed?.() + if (options.onParsed) { + runGuardedWriteCompletionStep('foreground-on-parsed', options.onParsed) + } + } + try { + terminal.write(data, runCompletionSteps) + } catch { + runCompletionSteps() } } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts new file mode 100644 index 00000000000..a245f2d82b9 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-ack-credit.ts @@ -0,0 +1,45 @@ +type TerminalOutputAckTarget = object + +const inFlightAckCompletions = new WeakMap void>>() + +/** Tracks credits after submission to xterm so pane disposal can treat its + * unparsed write buffer as discarded instead of leaking main's ACK window. */ +export function registerTerminalOutputAckCredits( + terminal: TerminalOutputAckTarget, + credits: readonly (() => void)[] +): (() => void) | undefined { + if (credits.length === 0) { + return undefined + } + let completions = inFlightAckCompletions.get(terminal) + if (!completions) { + completions = new Set() + inFlightAckCompletions.set(terminal, completions) + } + let completed = false + const complete = (): void => { + if (completed) { + return + } + completed = true + completions?.delete(complete) + if (completions?.size === 0) { + inFlightAckCompletions.delete(terminal) + } + for (const credit of credits) { + credit() + } + } + completions.add(complete) + return complete +} + +export function discardInFlightTerminalOutputAckCredits(terminal: TerminalOutputAckTarget): void { + const completions = inFlightAckCompletions.get(terminal) + if (!completions) { + return + } + for (const complete of completions) { + complete() + } +} diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts new file mode 100644 index 00000000000..77ca783af6c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts @@ -0,0 +1,127 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// Benchmark harness for the terminal performance initiative: measures the +// scheduler-imposed drain ceiling in isolation. A mock terminal parses +// instantly, so the measured rate is pure scheduler drip (writes-per-tick x +// chunk size / reschedule interval). Baseline-jul02 measured agent-tui at +// 2.0 MB/s end-to-end while bare xterm parses the same bytes at ~103 MB/s; +// this pins how much of that ceiling the drain loop itself imposes. +// Run with: +// ORCA_TERMINAL_PERF_BENCH=1 pnpm vitest run \ +// src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler-throughput.bench.test.ts \ +// --config config/vitest.config.ts +const benchEnabled = process.env.ORCA_TERMINAL_PERF_BENCH === '1' + +vi.mock('@/lib/e2e-config', () => ({ + e2eConfig: { exposeStore: false } +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +const TOTAL_CHARS = 4 * 1024 * 1024 +const FEED_CHUNK_CHARS = 8 * 1024 +const MAX_SIMULATED_MS = 60_000 + +function createInstantParseTerminal() { + let written = 0 + return { + get written() { + return written + }, + buffer: { active: { cursorY: 0, baseY: 0, viewportY: 0 } }, + rows: 24, + refresh: vi.fn(), + _core: { refresh: vi.fn() }, + write: vi.fn((data: string, callback?: () => void) => { + written += data.length + callback?.() + }) + } +} + +async function loadScheduler() { + vi.resetModules() + return import('./pane-terminal-output-scheduler') +} + +async function measure(options: { foreground: boolean }): Promise { + vi.useFakeTimers() + const scheduler = await loadScheduler() + const terminal = createInstantParseTerminal() + const payload = 'x'.repeat(FEED_CHUNK_CHARS) + // Why paced feeding: dumping the whole payload trips the backlog cap + // (replaceBacklogWithWarning). Real sources are paced by main's 512KB + // delivery watermark; keep in-flight below a 256KB window like a live PTY. + const IN_FLIGHT_WINDOW_CHARS = 256 * 1024 + let fed = 0 + let elapsed = 0 + while (terminal.written < TOTAL_CHARS && elapsed < MAX_SIMULATED_MS) { + while (fed < TOTAL_CHARS && fed - terminal.written < IN_FLIGHT_WINDOW_CHARS) { + scheduler.writeTerminalOutput(terminal as never, payload, { + foreground: options.foreground, + // Why false: floods are classified latency-insensitive by + // pty-connection's isLatencySensitiveForegroundOutput once the + // immediate budget is spent — this is the sustained-throughput path. + latencySensitive: false + }) + fed += FEED_CHUNK_CHARS + } + vi.advanceTimersByTime(1) + elapsed += 1 + } + expect(terminal.written).toBe(TOTAL_CHARS) + return TOTAL_CHARS / 1024 / 1024 / (elapsed / 1000) +} + +describe.skipIf(!benchEnabled)('scheduler drain ceiling', () => { + beforeEach(() => { + vi.stubGlobal('window', globalThis) + }) + + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + it('measures foreground (visible pane flood) and background ceilings', async () => { + const foreground = await measure({ foreground: true }) + const background = await measure({ foreground: false }) + // eslint-disable-next-line no-console -- bench harness output + console.log( + `\n[scheduler-ceiling] foreground flood: ${foreground.toFixed(1)} MB/s, background: ${background.toFixed(1)} MB/s (simulated time, instant parse)` + ) + }) +}) + +// Real-timer smoke: the MessageChannel drain path must actually drain a +// high-priority backlog without any timer advancing (the clamp-dodge works). +import { setUseMessageChannelDrainForTesting } from './pane-terminal-output-scheduler' + +describe('message-channel drain path', () => { + it('drains high-priority output with real timers and no timer advance', async () => { + vi.useRealTimers() + setUseMessageChannelDrainForTesting(true) + try { + const writes: string[] = [] + const terminal = { + write: (data: string, cb?: () => void) => { + writes.push(data) + cb?.() + } + } + const { writeTerminalOutput, discardTerminalOutput } = + await import('./pane-terminal-output-scheduler') + for (let i = 0; i < 40; i++) { + writeTerminalOutput(terminal as never, `chunk-${i};`, { foreground: true }) + } + await new Promise((resolve) => setTimeout(resolve, 250)) + expect(writes.join('')).toContain('chunk-39;') + discardTerminalOutput(terminal as never) + } finally { + setUseMessageChannelDrainForTesting(null) + vi.useFakeTimers() + } + }) +}) diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index bca02b5fcc5..f85d9b8c56f 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -5,6 +5,14 @@ vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: true } })) +const mocks = vi.hoisted(() => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb +})) + function createTerminal() { const classes = new Set() return { @@ -51,6 +59,7 @@ async function loadScheduler() { describe('pane terminal output scheduler', () => { beforeEach(() => { vi.stubGlobal('window', globalThis) + mocks.recordRendererCrashBreadcrumb.mockClear() }) afterEach(() => { @@ -60,6 +69,168 @@ describe('pane terminal output scheduler', () => { vi.unstubAllGlobals() }) + describe('parse-deferred ACK crediting', () => { + // Why these tests: the credit invariant is "every delivered chunk credits + // exactly once, whether parsed or discarded" — a missed credit permanently + // shrinks main's in-flight window and wedges the PTY (rc.7.perf). + function makeCredit(): { fire: () => void; count: () => number } { + let fired = 0 + return { fire: () => (fired += 1), count: () => fired } + } + + it('credits when a queued chunk finishes parsing', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + let parsed: (() => void) | undefined + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + parsed = callback + }) + const credit = makeCredit() + + writeTerminalOutput(terminal, 'queued', { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire + }) + expect(credit.count()).toBe(0) + + vi.advanceTimersByTime(0) + expect(terminal.write).toHaveBeenCalledWith('queued', expect.any(Function)) + expect(credit.count()).toBe(0) + parsed?.() + expect(credit.count()).toBe(1) + }) + + it('credits exactly once when a chunk is split across drain slices', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const credit = makeCredit() + + // 40 KB > the 16 KB slice size: consumed across multiple drain writes. + writeTerminalOutput(terminal, 'q'.repeat(40 * 1024), { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire + }) + for (let index = 0; index < 24; index += 1) { + vi.advanceTimersByTime(4) + } + const written = terminal.write.mock.calls.map((call) => String(call[0])).join('') + expect(written).toContain('q'.repeat(40 * 1024)) + expect(credit.count()).toBe(1) + }) + + it('defers split-chunk credit and onParsed until the final slice parses', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const parseCallbacks: (() => void)[] = [] + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + if (callback) { + parseCallbacks.push(callback) + } + }) + const credit = makeCredit() + const onParsed = vi.fn() + + writeTerminalOutput(terminal, 'q'.repeat(40 * 1024), { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire, + onParsed + }) + vi.advanceTimersByTime(0) + + expect(parseCallbacks).toHaveLength(3) + parseCallbacks[0]() + parseCallbacks[1]() + expect(credit.count()).toBe(0) + expect(onParsed).not.toHaveBeenCalled() + parseCallbacks[2]() + expect(credit.count()).toBe(1) + expect(onParsed).toHaveBeenCalledTimes(1) + }) + + it('credits when the foreground backlog is replaced with the overflow warning', async () => { + vi.useFakeTimers() + const { writeTerminalOutput, configureTerminalOutputBacklogCap } = await loadScheduler() + configureTerminalOutputBacklogCap(1_000) + const terminal = createTerminal() + // Never complete a write so the queue only grows. + terminal.write.mockImplementation(() => {}) + const credits = [makeCredit(), makeCredit(), makeCredit()] + + for (const credit of credits) { + writeTerminalOutput(terminal, 'x'.repeat(1024 * 1024), { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire + }) + } + // The cap replacement discards queued chunks — their deliveries still + // consumed and must credit. + for (const credit of credits) { + expect(credit.count()).toBe(1) + } + }) + + it('credits when queued output is discarded', async () => { + vi.useFakeTimers() + const { writeTerminalOutput, discardTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + terminal.write.mockImplementation(() => {}) + const credit = makeCredit() + + writeTerminalOutput(terminal, 'doomed', { + foreground: true, + latencySensitive: false, + ackCredit: credit.fire + }) + expect(credit.count()).toBe(0) + discardTerminalOutput(terminal) + expect(credit.count()).toBe(1) + }) + + it('credits an empty write immediately', async () => { + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const credit = makeCredit() + + writeTerminalOutput(terminal, '', { foreground: true, ackCredit: credit.fire }) + expect(credit.count()).toBe(1) + }) + + it('credits the immediate foreground path after its parse callback', async () => { + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + let parsed: (() => void) | undefined + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + parsed = callback + }) + const credit = makeCredit() + + writeTerminalOutput(terminal, 'now', { foreground: true, ackCredit: credit.fire }) + expect(terminal.write).toHaveBeenCalledWith('now', expect.any(Function)) + expect(credit.count()).toBe(0) + parsed?.() + expect(credit.count()).toBe(1) + }) + + it('credits submitted but unparsed output when the terminal is discarded', async () => { + const { writeTerminalOutput, discardTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + terminal.write.mockImplementation(() => {}) + const credit = makeCredit() + + writeTerminalOutput(terminal, 'submitted', { foreground: true, ackCredit: credit.fire }) + expect(credit.count()).toBe(0) + discardTerminalOutput(terminal) + expect(credit.count()).toBe(1) + }) + }) + it('writes foreground output immediately', async () => { const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() @@ -277,7 +448,7 @@ describe('pane terminal output scheduler', () => { expect(terminal._core.refresh).not.toHaveBeenCalled() }) - it('keeps parsed callbacks on large background chunks split by the scheduler', async () => { + it('runs parsed callbacks after the final background slice', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() const terminal = createTerminal() @@ -299,14 +470,12 @@ describe('pane terminal output scheduler', () => { vi.advanceTimersByTime(50) expect(writes.map((data) => data.length)).toEqual([16 * 1024, 4 * 1024]) - expect(parseCallbacks).toHaveLength(2) + expect(parseCallbacks).toHaveLength(1) expect(onParsed).not.toHaveBeenCalled() parseCallbacks[0]?.() expect(onParsed).toHaveBeenCalledTimes(1) - parseCallbacks[1]?.() - expect(onParsed).toHaveBeenCalledTimes(2) }) it('defers throughput foreground output to the shared high-priority drain', async () => { @@ -798,6 +967,31 @@ describe('pane terminal output scheduler', () => { expect(terminals[2].write).toHaveBeenCalledWith('pane-2') }) + it('drains active foreground backlog before older background terminal backlog', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const backgroundA = createTerminal() + const backgroundB = createTerminal() + const active = createTerminal() + + writeTerminalOutput(backgroundA, 'background-a', { foreground: false }) + writeTerminalOutput(backgroundB, 'background-b', { foreground: false }) + writeTerminalOutput(active, 'active', { + foreground: true, + latencySensitive: false + }) + + vi.advanceTimersByTime(0) + + expect(active.write).toHaveBeenCalledWith('active', expect.any(Function)) + expect(active.write.mock.invocationCallOrder[0]).toBeLessThan( + backgroundA.write.mock.invocationCallOrder[0] + ) + expect(active.write.mock.invocationCallOrder[0]).toBeLessThan( + backgroundB.write.mock.invocationCallOrder[0] + ) + }) + it('rotates terminals with remaining backlog behind untouched queued terminals', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() @@ -897,11 +1091,14 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).not.toHaveBeenCalled() + // Why 8: promoted backlogs use the parse-clocked high-priority budget + // (HIGH_PRIORITY_MAX_WRITES_PER_DRAIN) so a visible flood drains at the + // parser's pace instead of a fixed 2-write drip. vi.advanceTimersByTime(0) - expect(terminal.write).toHaveBeenCalledTimes(2) + expect(terminal.write).toHaveBeenCalledTimes(8) vi.advanceTimersByTime(4) - expect(terminal.write).toHaveBeenCalledTimes(4) + expect(terminal.write).toHaveBeenCalledTimes(16) }) it('yields high-priority backlog drains when writes spend the frame budget', async () => { @@ -979,6 +1176,95 @@ describe('pane terminal output scheduler', () => { expect(output).not.toContain('x'.repeat(1024)) }) + it('caps a visible pane backlog the drain cannot keep up with and writes a warning', async () => { + // Why: the foreground path was previously uncapped — a flooding visible + // TUI on a starved renderer grew queuedChars without bound (field + // reports of ~1.5 GB renderer RSS before terminals froze). + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(512 * 1024) + + for (let i = 0; i < 5; i++) { + writeTerminalOutput(terminal, chunk, { foreground: true, latencySensitive: false }) + } + writeTerminalOutput(terminal, 'after-cap\r\n', { foreground: true, latencySensitive: false }) + + vi.advanceTimersByTime(0) + + const output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).toContain('Orca skipped a burst of terminal output') + expect(output).toContain('after-cap') + expect(output).not.toContain('x'.repeat(1024)) + }) + + it('records a drop breadcrumb with sizes when the cap replaces a backlog', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(512 * 1024) + + for (let i = 0; i < 5; i++) { + writeTerminalOutput(terminal, chunk, { foreground: false }) + } + + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_output_backlog_dropped', + expect.objectContaining({ + foreground: false, + droppedChars: expect.any(Number), + capChars: 2 * 1024 * 1024 + }) + ) + }) + + it('scales the backlog cap with the scrollback setting', async () => { + vi.useFakeTimers() + const { writeTerminalOutput, configureTerminalOutputBacklogCap } = await loadScheduler() + const terminal = createTerminal() + const chunk = 'x'.repeat(512 * 1024) + + // 50k-row scrollback ⇒ 6 MB cap: a 2.5 MB flood that would trip the + // 2 MB floor must survive intact. + configureTerminalOutputBacklogCap(50_000) + for (let i = 0; i < 5; i++) { + writeTerminalOutput(terminal, chunk, { foreground: true, latencySensitive: false }) + } + vi.advanceTimersByTime(0) + + let output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).not.toContain('Orca skipped') + expect(output).toContain('x'.repeat(1024)) + + // But the scaled cap still bounds a runaway flood. + terminal.write.mockClear() + for (let i = 0; i < 13; i++) { + writeTerminalOutput(terminal, chunk, { foreground: true, latencySensitive: false }) + } + vi.advanceTimersByTime(0) + output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).toContain('Orca skipped a burst of terminal output') + }) + + it('caps a held/coalesced foreground backlog as well', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + const chunk = 'y'.repeat(512 * 1024) + + // holdForeground engages the synchronized-output hold — the branch a + // flooding TUI in sync mode exercises. + for (let i = 0; i < 5; i++) { + writeTerminalOutput(terminal, chunk, { foreground: true, holdForeground: true }) + } + + vi.advanceTimersByTime(1_000) + + const output = terminal.write.mock.calls.map(([data]) => data).join('') + expect(output).toContain('Orca skipped a burst of terminal output') + expect(output).not.toContain('y'.repeat(1024)) + }) + it('caps hidden backlog chunk count even when each chunk is tiny', async () => { vi.useFakeTimers() const { writeTerminalOutput } = await loadScheduler() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 20a3b8138ca..79ddae6e086 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -11,6 +11,16 @@ import { captureTerminalWriteScrollIntent, enforceTerminalWriteScrollIntent } from './terminal-scroll-intent' +import { runGuardedWriteCompletionStep } from './xterm-write-callback-guard' +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { + discardInFlightTerminalOutputAckCredits, + registerTerminalOutputAckCredits +} from './pane-terminal-output-ack-credit' +import { + TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS, + terminalOutputBacklogCapChars +} from '../../../../shared/terminal-scrollback-policy' type TerminalOutputTarget = ForegroundTerminalOutputTarget @@ -22,6 +32,12 @@ type WriteTerminalOutputOptions = { foreground: boolean beforeWrite?: TerminalOutputBeforeWrite onParsed?: TerminalOutputParsedCallback + /** Parse-deferred delivery ACK (terminal-pty-ack-gate). The scheduler MUST + * invoke it exactly when the chunk's bytes are parsed by xterm OR discarded + * by any drop path. A missed credit permanently shrinks + * main's in-flight window for this PTY (the callback is fire-once, so + * double invocation is safe; omission is not). */ + ackCredit?: () => void onBackgroundBacklogDropped?: () => void latencySensitive?: boolean forceForegroundRefresh?: boolean @@ -38,6 +54,7 @@ type QueueChunk = { followupForegroundRefresh: boolean stripTransientCursorShows: boolean onParsed?: TerminalOutputParsedCallback + ackCredit?: () => void } type QueuedWrite = { @@ -47,6 +64,7 @@ type QueuedWrite = { followupForegroundRefresh: boolean stripTransientCursorShows: boolean onParsed?: TerminalOutputParsedCallback + ackCredits: (() => void)[] } type QueueEntry = { @@ -71,12 +89,26 @@ const BACKGROUND_DRAIN_INTERVAL_MS = 16 const HIGH_PRIORITY_DRAIN_INTERVAL_MS = 4 const BACKGROUND_CHUNK_CHARS = 16 * 1024 const MAX_WRITES_PER_DRAIN = 2 -const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 2 +// Why 8: with the parse-clock pacer, high-priority ticks fire only after +// xterm confirms the previous batch parsed, and Chromium clamps chained +// timers to ~4ms — so per-tick volume (8 x 16KB = 128KB ≈ 1.3ms of parse) +// sets the sustained ceiling (~30MB/s) while staying far inside +// DRAIN_TIME_BUDGET_MS. At 2 the ceiling was 8MB/s against a ~100MB/s +// parser (see pane-terminal-output-scheduler-throughput.bench.test.ts). +const HIGH_PRIORITY_MAX_WRITES_PER_DRAIN = 8 const DRAIN_TIME_BUDGET_MS = 8 const LARGE_BACKLOG_CHARS = 512 * 1024 const SYNC_FOREGROUND_FLUSH_CHARS = 256 * 1024 -const MAX_BACKGROUND_QUEUE_CHARS = 2 * 1024 * 1024 +// Why mutable: the cap scales with the user's scrollback setting (see +// terminalOutputBacklogCapChars); the terminal lifecycle configures it when +// settings are applied. The chunk-count cap stays fixed — it bounds queue +// bookkeeping, not retained content. +let maxQueueChars = TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS const MAX_BACKGROUND_QUEUE_CHUNKS = 4096 + +export function configureTerminalOutputBacklogCap(scrollbackRows: unknown): void { + maxQueueChars = terminalOutputBacklogCapChars(scrollbackRows) +} const PARSE_SETTLE_TIMEOUT_MS = 250 const FOREGROUND_COALESCE_DELAY_MS = 1000 const FOREGROUND_HOLD_SAFETY_DELAY_MS = 250 @@ -88,9 +120,15 @@ const CURSOR_SHOW_SEQUENCE = '\x1b[?25h' const CURSOR_HIDE_SEQUENCE = '\x1b[?25l' const SYNCHRONIZED_OUTPUT_END_SEQUENCE = '\x1b[?2026l' // Why: CAN aborts a partial escape sequence before resetting style and showing -// the lossy-backlog warning. +// the lossy-backlog warning. Cap-agnostic wording: the byte limit scales with +// the scrollback setting. const BACKGROUND_BACKLOG_WARNING = - '\x18\x1b[0m\r\n[Orca skipped hidden terminal output because the backlog exceeded 2 MB.]\r\n' + '\x18\x1b[0m\r\n[Orca skipped hidden terminal output because the backlog grew too large.]\r\n' +// Why a separate foreground message: a visible pane hitting the cap means the +// drain could not keep up with a flood (starved renderer) — the output was +// skipped, not merely produced while hidden. +const FOREGROUND_BACKLOG_WARNING = + '\x18\x1b[0m\r\n[Orca skipped a burst of terminal output because the backlog grew too large.]\r\n' const queuedByTerminal = new Map() const backlogRecoveryByTerminal = new WeakMap< @@ -99,6 +137,47 @@ const backlogRecoveryByTerminal = new WeakMap< >() let drainTimer: ReturnType | null = null let drainTimerDelayMs: number | null = null +// Why a MessageChannel for zero-delay drains: Chromium clamps nested +// setTimeout(0) to ~4ms, which stacks a dead gap onto every parse-clocked +// drain tick under flood (measured: standing queue ~18ms vs VS Code ~7ms). +// A posted message is still a macrotask — input events and paint are +// serviced between posts — so the cooperative yield survives without the +// clamp. Cancellation is by generation: posts carry the generation they +// were armed with and no-op when it has moved on. +let drainImmediatePending = false +let drainImmediateGeneration = 0 +let useMessageChannelDrain = typeof MessageChannel !== 'undefined' && !isVitestEnv() +let drainChannel: MessageChannel | null = null + +function isVitestEnv(): boolean { + // Why: vitest fake timers cannot advance MessageChannel macrotasks; the + // timer path keeps the existing suites' virtual clock authoritative. + return typeof process !== 'undefined' && process.env?.VITEST === 'true' +} + +function getDrainChannel(): MessageChannel { + if (drainChannel === null) { + drainChannel = new MessageChannel() + drainChannel.port1.onmessage = (event: MessageEvent) => { + if (event.data !== drainImmediateGeneration || !drainImmediatePending) { + return + } + drainImmediatePending = false + drainQueuedOutput() + } + } + return drainChannel +} + +function cancelImmediateDrain(): void { + drainImmediateGeneration++ + drainImmediatePending = false +} + +export function setUseMessageChannelDrainForTesting(value: boolean | null): void { + cancelImmediateDrain() + useMessageChannelDrain = value ?? (typeof MessageChannel !== 'undefined' && !isVitestEnv()) +} const debugEnabled = e2eConfig.exposeStore // Why the cap is lossy: a hidden/backgrounded Chromium document can throttle @@ -219,6 +298,10 @@ function exposeDebugApi(): void { } function scheduleDrain(delayMs: number): void { + if (drainImmediatePending) { + // An immediate drain is already armed — nothing can beat zero delay. + return + } if (drainTimer !== null) { if (drainTimerDelayMs !== null && drainTimerDelayMs <= delayMs) { return @@ -233,6 +316,11 @@ function scheduleDrain(delayMs: number): void { if (debugEnabled) { debugState.scheduledDrainCount++ } + if (delayMs === 0 && useMessageChannelDrain) { + drainImmediatePending = true + getDrainChannel().port2.postMessage(drainImmediateGeneration) + return + } drainTimer = setTimeout(drainQueuedOutput, delayMs) drainTimerDelayMs = delayMs } @@ -485,6 +573,7 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { let followupForegroundRefresh = false let stripTransientCursorShows = false const parsedCallbacks: TerminalOutputParsedCallback[] = [] + const ackCredits: (() => void)[] = [] while (remaining > 0 && entry.chunkIndex < entry.chunks.length) { const chunk = entry.chunks[entry.chunkIndex] @@ -503,13 +592,13 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { if (chunk.onParsed) { parsedCallbacks.push(chunk.onParsed) } + if (chunk.ackCredit) { + ackCredits.push(chunk.ackCredit) + } continue } data += chunk.data.slice(0, remaining) - if (chunk.onParsed) { - parsedCallbacks.push(chunk.onParsed) - } entry.chunks[entry.chunkIndex] = { ...chunk, data: chunk.data.slice(remaining) @@ -537,7 +626,8 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { callback() } } - : undefined + : undefined, + ackCredits } : null } @@ -566,6 +656,7 @@ function enqueueChunk( followupForegroundRefresh?: boolean stripTransientCursorShows?: boolean onParsed?: TerminalOutputParsedCallback + ackCredit?: () => void } ): void { entry.chunks.push({ @@ -574,18 +665,49 @@ function enqueueChunk( forceForegroundRefresh: options?.forceForegroundRefresh === true, followupForegroundRefresh: options?.followupForegroundRefresh === true, stripTransientCursorShows: options?.stripTransientCursorShows === true, - onParsed: options?.onParsed + onParsed: options?.onParsed, + ackCredit: options?.ackCredit }) entry.queuedChars += data.length recordQueueDebugPressure() } -function replaceBacklogWithWarning(entry: QueueEntry): void { +// Fires the delivery ACK credits of every not-yet-consumed queued chunk. +// Every discard path MUST call this before clearing/replacing the queue — +// a dropped chunk still counts as consumed for main's in-flight window, or +// the window shrinks permanently and the PTY wedges behind lost credit. +function fireQueuedAckCredits(entry: QueueEntry): void { + for (let index = entry.chunkIndex; index < entry.chunks.length; index += 1) { + entry.chunks[index].ackCredit?.() + } +} + +function queueCapExceeded(entry: QueueEntry): boolean { + return ( + entry.queuedChars > maxQueueChars || + entry.chunks.length - entry.chunkIndex > MAX_BACKGROUND_QUEUE_CHUNKS + ) +} + +function replaceBacklogWithWarning( + entry: QueueEntry, + warning: string = BACKGROUND_BACKLOG_WARNING +): void { const shouldNotify = !entry.backgroundBacklogDropped + if (shouldNotify) { + // Why: field visibility for cap tuning — how often drops happen and at + // what size decides whether the cap is too small (issue #2836 / #7017). + recordRendererCrashBreadcrumb('terminal_output_backlog_dropped', { + foreground: warning === FOREGROUND_BACKLOG_WARNING, + droppedChars: entry.queuedChars, + capChars: maxQueueChars + }) + } clearForegroundHoldSafety(entry) + fireQueuedAckCredits(entry) entry.chunks = [ { - data: BACKGROUND_BACKLOG_WARNING, + data: warning, foreground: false, forceForegroundRefresh: false, followupForegroundRefresh: false, @@ -593,7 +715,7 @@ function replaceBacklogWithWarning(entry: QueueEntry): void { } ] entry.chunkIndex = 0 - entry.queuedChars = BACKGROUND_BACKLOG_WARNING.length + entry.queuedChars = warning.length entry.backgroundBacklogDropped = true entry.highPriority = true entry.foregroundHold = false @@ -637,26 +759,34 @@ function writeBackgroundTerminalChunk( data: string, onParsed?: TerminalOutputParsedCallback ): void { + // Why guarded: these callbacks run inside xterm's WriteBuffer loop, where an + // escaping throw permanently wedges the terminal (see + // xterm-write-callback-guard.ts). + const runOnParsed = onParsed + ? (): void => runGuardedWriteCompletionStep('background-on-parsed', onParsed) + : undefined const scrollIntent = captureTerminalWriteScrollIntent(terminal) if (!scrollIntent) { - if (!onParsed || terminal.write.length < 2) { + if (!runOnParsed || terminal.write.length < 2) { terminal.write(data) - onParsed?.() + runOnParsed?.() return } - terminal.write(data, onParsed) + terminal.write(data, runOnParsed) return } + const runScrollIntentThenParsed = (): void => { + runGuardedWriteCompletionStep('background-scroll-intent', () => + enforceTerminalWriteScrollIntent(terminal, scrollIntent) + ) + runOnParsed?.() + } if (terminal.write.length < 2) { terminal.write(data) - enforceTerminalWriteScrollIntent(terminal, scrollIntent) - onParsed?.() + runScrollIntentThenParsed() return } - terminal.write(data, () => { - enforceTerminalWriteScrollIntent(terminal, scrollIntent) - onParsed?.() - }) + terminal.write(data, runScrollIntentThenParsed) } function writeForegroundTerminalChunkWithIntent( @@ -682,6 +812,25 @@ function writeForegroundTerminalChunkWithIntent( } function takeNextDrainableEntry(): QueueEntry | null { + let largeBacklogEntry: QueueEntry | null = null + for (const entry of queuedByTerminal.values()) { + if (!isEntryDrainable(entry)) { + continue + } + // Why: active/foreground output should be chosen first, not just widen the + // drain budget while older background terminals keep their insertion order. + if (entry.highPriority) { + queuedByTerminal.delete(entry.terminal) + return entry + } + if (!largeBacklogEntry && entry.queuedChars > LARGE_BACKLOG_CHARS) { + largeBacklogEntry = entry + } + } + if (largeBacklogEntry) { + queuedByTerminal.delete(largeBacklogEntry.terminal) + return largeBacklogEntry + } for (const entry of queuedByTerminal.values()) { if (!isEntryDrainable(entry)) { continue @@ -692,11 +841,50 @@ function takeNextDrainableEntry(): QueueEntry | null { return null } +// Why: the parse-completion pacer re-arms a zero-delay drain as soon as xterm +// reports the previous high-priority batch parsed. Without it, cadence is a +// fixed 4/16ms nap per <=32KB batch — a ~2-8 MB/s drip against xterm's +// ~100 MB/s parse rate (measured: scheduler-throughput bench + baseline-jul02). +// Only high-priority (visible-pane) backlogs are pacer-clocked; background +// panes keep the fixed cadence that protects the focused terminal. +function makeParseClockPacer(): () => void { + return () => { + try { + if (queuedByTerminal.size > 0 && hasHighPriorityBacklog()) { + scheduleDrain(0) + } + } catch { + // Why: runs inside xterm's write-callback chain; a throw here would + // wedge the terminal (see xterm-write-callback-guard.ts). + } + } +} + +function composeParsedCallback( + onParsed: TerminalOutputParsedCallback | undefined, + ackCreditsParsed: (() => void) | undefined, + pacer: (() => void) | undefined +): TerminalOutputParsedCallback | undefined { + if (!onParsed && !ackCreditsParsed && !pacer) { + return undefined + } + return () => { + try { + onParsed?.() + } finally { + ackCreditsParsed?.() + pacer?.() + } + } +} + function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null { const queuedWrite = takeQueuedChunk(entry, BACKGROUND_CHUNK_CHARS) if (!queuedWrite) { return null } + const pacer = entry.highPriority ? makeParseClockPacer() : undefined + const ackCreditsParsed = registerTerminalOutputAckCredits(entry.terminal, queuedWrite.ackCredits) try { entry.beforeWrite?.(queuedWrite.data) if (queuedWrite.foreground) { @@ -708,16 +896,22 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, - onParsed: queuedWrite.onParsed + onParsed: composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, pacer) } ) } else { - writeBackgroundTerminalChunk(entry.terminal, queuedWrite.data, queuedWrite.onParsed) + writeBackgroundTerminalChunk( + entry.terminal, + queuedWrite.data, + composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, pacer) + ) } } catch { // Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping; // a write to a disposed terminal throws. Drop the entry rather than crashing // the scheduler for other panes still draining. + ackCreditsParsed?.() + fireQueuedAckCredits(entry) entry.chunks.length = 0 entry.chunkIndex = 0 entry.queuedChars = 0 @@ -781,8 +975,17 @@ function drainQueuedOutput(): void { } recordQueueDebugPressure() if (queuedByTerminal.size > 0 && hasDrainableBacklog()) { + // Why 0 on the channel path: the 4ms high-priority interval existed to + // yield between ticks, but a posted message already yields — Chromium + // services input and paint between macrotasks. The explicit sleep only + // deepened the standing queue (~4ms per 128KB tick). Timer path keeps + // the interval so fake-timer tests retain stepwise drain semantics. scheduleDrain( - hasHighPriorityBacklog() ? HIGH_PRIORITY_DRAIN_INTERVAL_MS : BACKGROUND_DRAIN_INTERVAL_MS + hasHighPriorityBacklog() + ? useMessageChannelDrain + ? 0 + : HIGH_PRIORITY_DRAIN_INTERVAL_MS + : BACKGROUND_DRAIN_INTERVAL_MS ) } } @@ -794,6 +997,9 @@ export function writeTerminalOutput( ): void { exposeDebugApi() if (!data) { + // Why: an empty write still consumed its delivery — credit or main's + // in-flight window leaks. + options.ackCredit?.() return } @@ -810,12 +1016,20 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onParsed: options.onParsed + onParsed: options.onParsed, + ackCredit: options.ackCredit }) if (debugEnabled) { debugState.foregroundWriteCount++ debugState.deferredForegroundEnqueueCount++ } + // Why: a visible pane's queue was previously uncapped — a flood the + // drain can't keep up with ballooned renderer memory without bound. + if (queueCapExceeded(queued)) { + replaceBacklogWithWarning(queued, FOREGROUND_BACKLOG_WARNING) + scheduleDrain(0) + return + } if (options.holdForeground) { // Why: synchronized-output start/body chunks contain transient cursor // moves. Holding them prevents Chromium from rasterizing those states. @@ -877,12 +1091,16 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onParsed: options.onParsed + onParsed: options.onParsed, + ackCredit: options.ackCredit }) if (debugEnabled) { debugState.foregroundWriteCount++ debugState.deferredForegroundEnqueueCount++ } + if (queueCapExceeded(entry)) { + replaceBacklogWithWarning(entry, FOREGROUND_BACKLOG_WARNING) + } // Why: returning from a hidden window can have megabytes queued. Keep // byte order, but drain it asynchronously so the first foreground frame // is not pinned behind the entire backlog. @@ -904,12 +1122,16 @@ export function writeTerminalOutput( forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, stripTransientCursorShows: options.stripTransientCursorShows, - onParsed: options.onParsed + onParsed: options.onParsed, + ackCredit: options.ackCredit }) if (debugEnabled) { debugState.foregroundWriteCount++ debugState.deferredForegroundEnqueueCount++ } + if (queueCapExceeded(queued)) { + replaceBacklogWithWarning(queued, FOREGROUND_BACKLOG_WARNING) + } // Why: visible command floods are throughput work, not keystroke echo. // Queue them behind a zero-delay drain so one IPC callback cannot pin // the renderer in xterm.write while input and paint are waiting. @@ -920,16 +1142,27 @@ export function writeTerminalOutput( if (debugEnabled) { debugState.foregroundWriteCount++ } - options.beforeWrite?.(data) - writeForegroundTerminalChunkWithIntent( + const ackCreditsParsed = registerTerminalOutputAckCredits( terminal, - options.stripTransientCursorShows ? removeTransientCursorShowSequences(data) : data, - { - forceViewportRefresh: options.forceForegroundRefresh === true, - followupViewportRefresh: options.followupForegroundRefresh === true, - onParsed: options.onParsed - } + options.ackCredit ? [options.ackCredit] : [] ) + try { + options.beforeWrite?.(data) + writeForegroundTerminalChunkWithIntent( + terminal, + options.stripTransientCursorShows ? removeTransientCursorShowSequences(data) : data, + { + forceViewportRefresh: options.forceForegroundRefresh === true, + followupViewportRefresh: options.followupForegroundRefresh === true, + onParsed: composeParsedCallback(options.onParsed, ackCreditsParsed, undefined) + } + ) + } catch (error) { + // beforeWrite can throw before xterm owns the callback; consume the + // delivery here. xterm write throws are caught by the foreground writer. + ackCreditsParsed?.() + throw error + } return } @@ -943,12 +1176,10 @@ export function writeTerminalOutput( entry.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped } enqueueChunk(entry, data, { - onParsed: options.onParsed + onParsed: options.onParsed, + ackCredit: options.ackCredit }) - if ( - entry.queuedChars > MAX_BACKGROUND_QUEUE_CHARS || - entry.chunks.length - entry.chunkIndex > MAX_BACKGROUND_QUEUE_CHUNKS - ) { + if (queueCapExceeded(entry)) { replaceBacklogWithWarning(entry) } if (debugEnabled) { @@ -977,6 +1208,7 @@ export function flushTerminalOutput( return } if (entry.backgroundBacklogDropped && requestRegisteredTerminalBacklogRecovery(terminal)) { + fireQueuedAckCredits(entry) entry.chunks.length = 0 entry.chunkIndex = 0 entry.queuedChars = 0 @@ -994,6 +1226,7 @@ export function flushTerminalOutput( if (debugEnabled) { debugState.flushWriteCount++ } + const ackCreditsParsed = registerTerminalOutputAckCredits(terminal, queuedWrite.ackCredits) try { entry.beforeWrite?.(queuedWrite.data) if (queuedWrite.foreground) { @@ -1005,16 +1238,23 @@ export function flushTerminalOutput( { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, - onParsed: queuedWrite.onParsed + onParsed: composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, undefined) } ) } else { - writeBackgroundTerminalChunk(terminal, queuedWrite.data, queuedWrite.onParsed) + writeBackgroundTerminalChunk( + terminal, + queuedWrite.data, + composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, undefined) + ) } } catch { // Why: pane.terminal.dispose() can race with a queued late-arriving PTY ping; // a write to a disposed terminal throws. Drop the entry rather than crashing - // the scheduler for other panes still draining. + // the scheduler for other panes still draining. Consumed + abandoned + // chunks both credit their deliveries. + ackCreditsParsed?.() + fireQueuedAckCredits(entry) clearForegroundHoldSafety(entry) clearForegroundCoalesce(entry) recordQueueDebugPressure() @@ -1089,6 +1329,13 @@ export function waitForTerminalOutputParsed(terminal: TerminalOutputTarget): Pro export function discardTerminalOutput(terminal: TerminalOutputTarget): void { exposeDebugApi() + const entry = queuedByTerminal.get(terminal) + if (entry) { + // Why: discarded queued chunks still consumed their deliveries — credit + // them or main's in-flight window leaks (see fireQueuedAckCredits). + fireQueuedAckCredits(entry) + } + discardInFlightTerminalOutputAckCredits(terminal) queuedByTerminal.delete(terminal) discardForegroundRenderSettle(terminal) recordQueueDebugPressure() diff --git a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts index e0ca805801e..7ae295b4a9d 100644 --- a/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts +++ b/src/renderer/src/lib/pane-manager/pane-webgl-renderer.ts @@ -1,5 +1,6 @@ import { WebglAddon } from '@xterm/addon-webgl' import type { ManagedPaneInternal } from './pane-manager-types' +import { recordTerminalWebglDiagnostic } from '../../../../shared/terminal-webgl-diagnostics' import { forceRepaintThroughRenderPause } from './terminal-render-pause-release' import { getTerminalWebglAutoDecision, @@ -170,6 +171,10 @@ export function attachWebgl(pane: ManagedPaneInternal): void { pane.id, '— falling back to DOM renderer' ) + // Why: a lost context is the decisive signal for a post-wake garble + // report — it means the glyph atlas was wiped (needs a full reset), not + // just a missed repaint. Silent breadcrumb; the console.warn stays. + recordTerminalWebglDiagnostic('webgl-context-loss', { paneId: pane.id }) // Why: Chromium starts reclaiming terminal contexts under pressure. // Recreating WebGL for this pane can loop context loss and leave xterm // visually blank, so keep the pane on the DOM renderer until the next diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts index bfd67604707..6060ec3b3ee 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.test.ts @@ -3,7 +3,8 @@ import { describe, expect, it } from 'vitest' import { buildWindowsPtyCompatibilityOptions, isLocalNativeWindowsConpty, - isLocalNativeWindowsPty + isLocalNativeWindowsPty, + resolveWindowsShellOverride } from './windows-pty-compatibility' function writeTerminal(terminal: Terminal, data: string): Promise { @@ -164,6 +165,39 @@ describe('buildWindowsPtyCompatibilityOptions', () => { ).toEqual({}) }) + it('classifies a global-WSL default shell as non-native, matching main', () => { + // Why: main folds the global terminalWindowsShell into its spawn + // classification (isNativeWindowsLocalPtySpawn). Without the fold the + // renderer would call a tab with no override native-ConPTY while main + // never marks it. + const windowsUserAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' + expect( + isLocalNativeWindowsPty({ + userAgent: windowsUserAgent, + connectionId: null, + cwd: 'C:\\repo', + shellOverride: resolveWindowsShellOverride(undefined, 'wsl.exe') + }) + ).toBe(false) + // A tab-level override beats the global setting, both directions. + expect( + isLocalNativeWindowsPty({ + userAgent: windowsUserAgent, + connectionId: null, + cwd: 'C:\\repo', + shellOverride: resolveWindowsShellOverride('powershell.exe', 'wsl.exe') + }) + ).toBe(true) + expect( + isLocalNativeWindowsPty({ + userAgent: windowsUserAgent, + connectionId: null, + cwd: 'C:\\repo', + shellOverride: resolveWindowsShellOverride('wsl.exe', 'powershell.exe') + }) + ).toBe(false) + }) + it('exposes the same local native Windows predicate for related renderer workarounds', () => { expect( isLocalNativeWindowsPty({ diff --git a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts index 0f35968c21d..549defbb3a5 100644 --- a/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts +++ b/src/renderer/src/lib/pane-manager/windows-pty-compatibility.ts @@ -62,6 +62,17 @@ export function buildWindowsPtyCompatibilityOptions( } } +/** Mirror of main's effectiveShellOverride fold (pty.ts spawn handlers): a + * tab-level shell override wins, else the global Windows shell setting + * applies — so renderer and main classify a global-WSL default identically + * (the main-side twin is isNativeWindowsLocalPtySpawn). */ +export function resolveWindowsShellOverride( + tabShellOverride: string | null | undefined, + globalWindowsShell: string | null | undefined +): string | undefined { + return tabShellOverride ?? globalWindowsShell ?? undefined +} + /** * Raw client-side heuristic for a native-Windows ConPTY pane (Windows UA, no SSH * connection, non-WSL cwd/shell). Necessary but not sufficient: it cannot tell a diff --git a/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts b/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts new file mode 100644 index 00000000000..52285bae7bd --- /dev/null +++ b/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.test.ts @@ -0,0 +1,110 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + _resetWriteCompletionReportsForTests, + runGuardedWriteCompletionStep +} from './xterm-write-callback-guard' +import { writeForegroundTerminalChunk } from './pane-terminal-foreground-render-settle' + +const mocks = vi.hoisted(() => ({ + recordRendererCrashBreadcrumb: vi.fn() +})) + +vi.mock('@/lib/crash-breadcrumb-recorder', () => ({ + recordRendererCrashBreadcrumb: mocks.recordRendererCrashBreadcrumb +})) + +beforeEach(() => { + mocks.recordRendererCrashBreadcrumb.mockClear() + _resetWriteCompletionReportsForTests() +}) + +describe('runGuardedWriteCompletionStep', () => { + it('contains a synchronous throw and reports a breadcrumb', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + expect(() => + runGuardedWriteCompletionStep('test-step', () => { + throw new RangeError('synthetic settle failure') + }) + ).not.toThrow() + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_write_completion_error', + expect.objectContaining({ + context: 'test-step', + errorName: 'RangeError', + errorMessage: 'synthetic settle failure' + }) + ) + } finally { + errorSpy.mockRestore() + } + }) + + it('caps repeated reports per context so a throw-per-write loop cannot spam breadcrumbs', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + for (let i = 0; i < 20; i++) { + runGuardedWriteCompletionStep('spammy-step', () => { + throw new Error('always fails') + }) + } + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledTimes(5) + } finally { + errorSpy.mockRestore() + } + }) + + it('runs non-throwing steps transparently', () => { + const step = vi.fn() + runGuardedWriteCompletionStep('ok-step', step) + expect(step).toHaveBeenCalledTimes(1) + expect(mocks.recordRendererCrashBreadcrumb).not.toHaveBeenCalled() + }) +}) + +describe('writeForegroundTerminalChunk completion guarding', () => { + it('still releases onParsed when the settle step throws (replay-guard latch protection)', () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + const pendingCallbacks: (() => void)[] = [] + // Why a getter that throws only after the write is dispatched: it + // models renderer/buffer state failing between parse start and the + // post-parse viewport settle (refreshVisibleRowsNow self-catches, so + // the viewport comparison is the escaping surface). + let bufferAccessPoisoned = false + const realBuffer = { active: { cursorY: 0, baseY: 0, viewportY: 0 } } + const terminal = { + rows: 24, + get buffer() { + if (bufferAccessPoisoned) { + throw new Error('synthetic buffer access failure') + } + return realBuffer + }, + write: (_data: string, cb?: () => void) => { + if (cb) { + pendingCallbacks.push(cb) + } + } + } + const onParsed = vi.fn() + + writeForegroundTerminalChunk(terminal, 'restored bytes', { + forceViewportRefresh: true, + onParsed + }) + bufferAccessPoisoned = true + // Simulate xterm completing the parse: the completion callback must not + // let the settle throw escape into the WriteBuffer, and onParsed (the + // replay-guard release) must still run. + expect(() => pendingCallbacks.forEach((cb) => cb())).not.toThrow() + expect(onParsed).toHaveBeenCalledTimes(1) + expect(mocks.recordRendererCrashBreadcrumb).toHaveBeenCalledWith( + 'terminal_write_completion_error', + expect.objectContaining({ context: 'foreground-render-settle' }) + ) + } finally { + errorSpy.mockRestore() + } + }) +}) diff --git a/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.ts b/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.ts new file mode 100644 index 00000000000..0eb9227737c --- /dev/null +++ b/src/renderer/src/lib/pane-manager/xterm-write-callback-guard.ts @@ -0,0 +1,40 @@ +import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' + +// Why: xterm's WriteBuffer._innerWrite invokes write-completion callbacks with +// no try/catch; a synchronous throw skips the loop's tail re-schedule, and +// write() only re-arms processing when the buffer is EMPTY — which a stalled +// buffer never is again. One escaping throw therefore permanently freezes the +// pane: output stops rendering and a pending replay guard never releases, so +// the pane silently eats every keystroke while the shell stays alive +// (Discord #performance / issue #2836). Verified against the vendored xterm +// 6.1.0-beta.287 in xterm-write-buffer-stall.repro.test.ts. +const MAX_REPORTS_PER_CONTEXT = 5 +const reportCountsByContext = new Map() + +/** + * Run one step of a write-completion callback so a synchronous throw cannot + * escape into xterm's WriteBuffer. Steps are guarded individually so an + * earlier step's failure (e.g. a WebGL refresh during viewport settle) cannot + * starve a later step (e.g. the replay-guard release). + */ +export function runGuardedWriteCompletionStep(context: string, step: () => void): void { + try { + step() + } catch (error: unknown) { + const reported = reportCountsByContext.get(context) ?? 0 + if (reported >= MAX_REPORTS_PER_CONTEXT) { + return + } + reportCountsByContext.set(context, reported + 1) + console.error(`[terminal] write-completion step "${context}" threw`, error) + recordRendererCrashBreadcrumb('terminal_write_completion_error', { + context, + errorName: error instanceof Error ? error.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error) + }) + } +} + +export function _resetWriteCompletionReportsForTests(): void { + reportCountsByContext.clear() +} diff --git a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts index daebefb6e73..e5ada3e6ca6 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-frame-drop-resync.test.ts @@ -181,7 +181,7 @@ describe('remote terminal frame-drop resync', () => { expect(data).toEqual(['aaa']) expect(server.droppedFrames).toBe(1) // Instead, a fresh authoritative snapshot recovers the terminal. - expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[2J\x1b[3J\x1b[HRECOVERED']) }) it('passes contiguous output straight through without resyncing', async () => { @@ -208,7 +208,7 @@ describe('remote terminal frame-drop resync', () => { await Promise.resolve() expect(data).toEqual(['é']) - expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[2J\x1b[3J\x1b[HRECOVERED']) }) it('defers recovery until an in-flight manual snapshot finishes', async () => { @@ -231,6 +231,6 @@ describe('remote terminal frame-drop resync', () => { await expect(manualSnapshot).resolves.toMatchObject({ data: 'MANUAL' }) expect(server.snapshotRequests).toHaveLength(2) - expect(snapshots).toEqual(['INITIAL', 'RECOVERED']) + expect(snapshots).toEqual(['INITIAL', '\x1b[2J\x1b[3J\x1b[HRECOVERED']) }) }) diff --git a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts index 5f0b751a91f..e854a5e7efc 100644 --- a/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts +++ b/src/renderer/src/runtime/remote-runtime-terminal-multiplexer.ts @@ -9,6 +9,7 @@ import { encodeTerminalStreamJson, encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' +import { e2eConfig } from '@/lib/e2e-config' import { unwrapRuntimeRpcResult } from './runtime-rpc-client' type RuntimeEnvironmentSubscriptionHandle = { @@ -70,10 +71,12 @@ type RemoteRuntimeMultiplexedTerminalState = { streamId: number terminal: string callbacks: RemoteRuntimeMultiplexedTerminalCallbacks + acknowledgeOutput: boolean + heldAckBytes: number snapshotChunks: Uint8Array[] snapshotBytes: number snapshotOverflowed: boolean - snapshotTarget: 'initial' | 'request' + snapshotTarget: 'initial' | 'request' | 'recovery' snapshotInfo: RemoteRuntimeSnapshotInfo | null initialSnapshotReceived: boolean pendingSnapshotRequest: RemoteRuntimeSnapshotRequest | null @@ -123,6 +126,73 @@ const REMOTE_TERMINAL_SNAPSHOT_REQUEST_TIMEOUT_MS = 10_000 export const REMOTE_TERMINAL_SNAPSHOT_TOO_LARGE = 'Remote terminal snapshot exceeded the 2 MiB replay limit; live output will continue.' +type E2eRemoteTerminalMultiplexAckGateSnapshot = { + heldTerminalCount: number + heldStreamCount: number + heldAckChars: number + releasedAckChars: number +} + +type E2eRemoteTerminalMultiplexAckGateApi = { + hold: (terminals: string[]) => void + release: () => void + snapshot: () => E2eRemoteTerminalMultiplexAckGateSnapshot +} + +type E2eRemoteTerminalMultiplexAckGateWindow = Window & { + __remoteTerminalMultiplexAckGate?: E2eRemoteTerminalMultiplexAckGateApi +} + +const e2eHeldRemoteAckTerminals = new Set() +let e2eReleasedRemoteAckChars = 0 + +function shouldHoldE2eRemoteTerminalAck(terminal: string): boolean { + return e2eConfig.exposeStore && e2eHeldRemoteAckTerminals.has(terminal) +} + +function getE2eRemoteAckSnapshot(): E2eRemoteTerminalMultiplexAckGateSnapshot { + let heldStreamCount = 0 + let heldAckChars = 0 + for (const multiplexer of multiplexers.values()) { + for (const stream of multiplexer.getStreamsForE2e()) { + if (stream.heldAckBytes > 0) { + heldStreamCount += 1 + heldAckChars += stream.heldAckBytes + } + } + } + return { + heldTerminalCount: e2eHeldRemoteAckTerminals.size, + heldStreamCount, + heldAckChars, + releasedAckChars: e2eReleasedRemoteAckChars + } +} + +function releaseE2eRemoteTerminalAcks(): void { + for (const multiplexer of multiplexers.values()) { + e2eReleasedRemoteAckChars += multiplexer.releaseHeldAcksForE2e() + } + e2eHeldRemoteAckTerminals.clear() +} + +function exposeE2eRemoteTerminalMultiplexAckGate(): void { + if (!e2eConfig.exposeStore || typeof window === 'undefined') { + return + } + const target = window as E2eRemoteTerminalMultiplexAckGateWindow + target.__remoteTerminalMultiplexAckGate ??= { + hold: (terminals) => { + releaseE2eRemoteTerminalAcks() + for (const terminal of terminals) { + e2eHeldRemoteAckTerminals.add(terminal) + } + }, + release: releaseE2eRemoteTerminalAcks, + snapshot: getE2eRemoteAckSnapshot + } +} + class RemoteRuntimeTerminalMultiplexer { private readonly streams = new Map() private subscription: RuntimeEnvironmentSubscriptionHandle | null = null @@ -152,6 +222,8 @@ class RemoteRuntimeTerminalMultiplexer { streamId, terminal: args.terminal, callbacks: args.callbacks, + acknowledgeOutput: args.client.type === 'desktop', + heldAckBytes: 0, snapshotChunks: [], snapshotBytes: 0, snapshotOverflowed: false, @@ -198,7 +270,8 @@ class RemoteRuntimeTerminalMultiplexer { streamId, terminal: args.terminal, client: args.client, - viewport: args.viewport + viewport: args.viewport, + capabilities: args.client.type === 'desktop' ? { ackOutput: 1 } : undefined }) ) if (!sent) { @@ -346,21 +419,31 @@ class RemoteRuntimeTerminalMultiplexer { } if (frame.opcode === TerminalStreamOpcode.Output) { const data = decodeTerminalStreamText(frame.payload) - const rawLength = data.length - // Why: a resync snapshot is authoritative; drop live output that arrives - // while it is in flight so the corrupt post-gap tail is never rendered. - if (stream.resyncInFlight) { - return + try { + const rawLength = data.length + // Why: a resync snapshot is authoritative; discard live output while + // it is in flight, but still return transport credit in finally. + if (stream.resyncInFlight) { + return + } + const seq = typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined + if (this.detectOutputGap(stream, seq, rawLength)) { + this.requestResyncSnapshot(stream) + return + } + if (typeof seq === 'number') { + stream.expectedSeq = seq + } + stream.callbacks.onData(data, { seq, rawLength }) + } finally { + if (stream.acknowledgeOutput) { + if (shouldHoldE2eRemoteTerminalAck(stream.terminal)) { + stream.heldAckBytes += frame.payload.byteLength + } else { + this.acknowledgeOutput(stream, frame.payload.byteLength) + } + } } - const seq = typeof frame.seq === 'number' && frame.seq > 0 ? frame.seq : undefined - if (this.detectOutputGap(stream, seq, rawLength)) { - this.requestResyncSnapshot(stream) - return - } - if (typeof seq === 'number') { - stream.expectedSeq = seq - } - stream.callbacks.onData(data, { seq, rawLength }) return } if (frame.opcode === TerminalStreamOpcode.SnapshotStart) { @@ -371,7 +454,9 @@ class RemoteRuntimeTerminalMultiplexer { typeof requestId === 'number' || (stream.initialSnapshotReceived && stream.pendingSnapshotRequest) ? 'request' - : 'initial' + : stream.initialSnapshotReceived + ? 'recovery' + : 'initial' return } if (frame.opcode === TerminalStreamOpcode.SnapshotChunk) { @@ -417,6 +502,14 @@ class RemoteRuntimeTerminalMultiplexer { stream.callbacks.onSnapshot(data ?? '', { pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi }) + } else if (target === 'recovery') { + // Why: a server-pushed recovery snapshot replaces terminal state + // mid-session; clear the screen and scrollback before applying it. + // An empty snapshot is still applied so stale dropped output does + // not linger on a terminal the model says is blank. + stream.callbacks.onSnapshot(`\x1b[2J\x1b[3J\x1b[H${data ?? ''}`, { + pendingEscapeTailAnsi: info?.pendingEscapeTailAnsi + }) } } else if (matchesPendingRequest) { pendingRequest.resolve(null) @@ -562,6 +655,33 @@ class RemoteRuntimeTerminalMultiplexer { return id } + private acknowledgeOutput(stream: RemoteRuntimeMultiplexedTerminalState, bytes: number): boolean { + return this.sendFrame( + stream.streamId, + TerminalStreamOpcode.Ack, + encodeTerminalStreamJson({ bytes }) + ) + } + + getStreamsForE2e(): Iterable { + return this.streams.values() + } + + releaseHeldAcksForE2e(): number { + let released = 0 + for (const stream of this.streams.values()) { + if (stream.heldAckBytes <= 0) { + continue + } + const bytes = stream.heldAckBytes + stream.heldAckBytes = 0 + if (this.acknowledgeOutput(stream, bytes)) { + released += bytes + } + } + return released + } + private sendFrame( streamId: number, opcode: TerminalStreamOpcode, @@ -644,6 +764,7 @@ function releaseRemoteRuntimeTerminalMultiplexer( export function getRemoteRuntimeTerminalMultiplexer( environmentId: string ): RemoteRuntimeTerminalMultiplexer { + exposeE2eRemoteTerminalMultiplexAckGate() let multiplexer = multiplexers.get(environmentId) if (!multiplexer) { multiplexer = new RemoteRuntimeTerminalMultiplexer( @@ -661,6 +782,8 @@ export function _getRemoteRuntimeTerminalMultiplexerCountForTest(): number { export function resetRemoteRuntimeTerminalMultiplexersForTests(): void { multiplexers.clear() + e2eHeldRemoteAckTerminals.clear() + e2eReleasedRemoteAckChars = 0 } function concatBytes(chunks: Uint8Array[]): Uint8Array { diff --git a/src/renderer/src/runtime/runtime-terminal-stream.test.ts b/src/renderer/src/runtime/runtime-terminal-stream.test.ts index 0d0a59212f0..79166f666d2 100644 --- a/src/renderer/src/runtime/runtime-terminal-stream.test.ts +++ b/src/renderer/src/runtime/runtime-terminal-stream.test.ts @@ -4,6 +4,7 @@ import { decodeTerminalStreamFrame, decodeTerminalStreamJson, encodeTerminalStreamFrame, + encodeTerminalStreamJson, encodeTerminalStreamText } from '../../../shared/terminal-stream-protocol' import { @@ -98,8 +99,12 @@ describe('remote runtime terminal data subscriptions', () => { const subscribeFrame = decodeTerminalStreamFrame(sendBinary.mock.calls[0][0]) expect(subscribeFrame?.opcode).toBe(TerminalStreamOpcode.Subscribe) const subscribePayload = - subscribeFrame && decodeTerminalStreamJson<{ streamId: number }>(subscribeFrame.payload) + subscribeFrame && + decodeTerminalStreamJson<{ streamId: number; capabilities?: { ackOutput?: 1 } }>( + subscribeFrame.payload + ) expect(subscribePayload?.streamId).toEqual(expect.any(Number)) + expect(subscribePayload?.capabilities).toEqual({ ackOutput: 1 }) callbacks?.onBinary?.( encodeTerminalStreamFrame({ @@ -111,6 +116,12 @@ describe('remote runtime terminal data subscriptions', () => { ) expect(watcher).toHaveBeenCalledWith('live') + const ackFrame = sendBinary.mock.calls + .slice(1) + .map((call) => decodeTerminalStreamFrame(call[0])) + .find((frame) => frame?.opcode === TerminalStreamOpcode.Ack) + expect(ackFrame?.streamId).toBe(subscribePayload!.streamId) + expect(ackFrame && decodeTerminalStreamJson(ackFrame.payload)).toEqual({ bytes: 4 }) expect(_getRemoteRuntimeTerminalMultiplexerCountForTest()).toBe(1) dispose() expect(unsubscribe).toHaveBeenCalled() @@ -189,3 +200,228 @@ describe('remote runtime terminal data subscriptions', () => { expect(unsubscribe).toHaveBeenCalledOnce() }) }) + +describe('remote runtime terminal multiplex ACK gate', () => { + const runtimeSubscribe = vi.fn() + const sendBinary = vi.fn() + const unsubscribe = vi.fn() + let callbacks: { + onResponse: (response: unknown) => void + onBinary?: (bytes: Uint8Array) => void + onError?: (error: { message: string }) => void + onClose?: () => void + } | null = null + + beforeEach(async () => { + vi.resetModules() + vi.clearAllMocks() + callbacks = null + runtimeSubscribe.mockImplementation(async (_args: unknown, nextCallbacks: typeof callbacks) => { + callbacks = nextCallbacks + queueMicrotask(() => + callbacks?.onResponse({ + ok: true, + result: { type: 'ready' } + }) + ) + return { unsubscribe, sendBinary } + }) + vi.stubGlobal('window', { + api: { + e2e: { + getConfig: () => ({ exposeStore: true }) + }, + runtimeEnvironments: { + subscribe: runtimeSubscribe + } + } + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + vi.resetModules() + }) + + it('holds and releases ACKs for selected remote terminal streams only', async () => { + const { getRemoteRuntimeTerminalMultiplexer, resetRemoteRuntimeTerminalMultiplexersForTests } = + await import('./remote-runtime-terminal-multiplexer') + resetRemoteRuntimeTerminalMultiplexersForTests() + + const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-ack-gate') + const heldTerminal = await multiplexer.subscribeTerminal({ + terminal: 'terminal-held', + client: { id: 'desktop-held', type: 'desktop' }, + callbacks: { + onData: vi.fn(), + onSnapshot: vi.fn() + } + }) + const liveTerminal = await multiplexer.subscribeTerminal({ + terminal: 'terminal-live', + client: { id: 'desktop-live', type: 'desktop' }, + callbacks: { + onData: vi.fn(), + onSnapshot: vi.fn() + } + }) + + await vi.waitFor(() => expect(sendBinary).toHaveBeenCalledTimes(2)) + const heldStreamId = heldTerminal.streamId + const liveStreamId = liveTerminal.streamId + const gate = ( + window as typeof window & { + __remoteTerminalMultiplexAckGate?: { + hold: (terminals: string[]) => void + release: () => void + snapshot: () => { + heldTerminalCount: number + heldStreamCount: number + heldAckChars: number + releasedAckChars: number + } + } + } + ).__remoteTerminalMultiplexAckGate + expect(gate).toBeDefined() + gate?.hold(['terminal-held']) + sendBinary.mockClear() + + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: heldStreamId, + seq: 1, + payload: encodeTerminalStreamText('held-output') + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Output, + streamId: liveStreamId, + seq: 2, + payload: encodeTerminalStreamText('live-output') + }) + ) + + const immediateAckFrames = sendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Ack) + expect(immediateAckFrames).toHaveLength(1) + expect(immediateAckFrames[0]?.streamId).toBe(liveStreamId) + expect(gate?.snapshot()).toMatchObject({ + heldTerminalCount: 1, + heldStreamCount: 1, + heldAckChars: 'held-output'.length + }) + + gate?.release() + const allAckFrames = sendBinary.mock.calls + .map((call) => decodeTerminalStreamFrame(call[0])) + .filter((frame) => frame?.opcode === TerminalStreamOpcode.Ack) + const releasedAck = allAckFrames.find((frame) => frame?.streamId === heldStreamId) + expect(releasedAck && decodeTerminalStreamJson(releasedAck.payload)).toEqual({ + bytes: 'held-output'.length + }) + expect(gate?.snapshot()).toMatchObject({ + heldTerminalCount: 0, + heldStreamCount: 0, + heldAckChars: 0, + releasedAckChars: 'held-output'.length + }) + + heldTerminal.close() + liveTerminal.close() + }) + + it('applies mid-session recovery snapshots without re-subscribing', async () => { + const { getRemoteRuntimeTerminalMultiplexer } = + await import('./remote-runtime-terminal-multiplexer') + resetRemoteRuntimeTerminalMultiplexersForTests() + + const multiplexer = getRemoteRuntimeTerminalMultiplexer('env-recovery') + const onSnapshot = vi.fn() + const onSubscribed = vi.fn() + const stream = await multiplexer.subscribeTerminal({ + terminal: 'terminal-recovery', + client: { id: 'desktop-recovery', type: 'desktop' }, + callbacks: { + onData: vi.fn(), + onSnapshot, + onSubscribed + } + }) + await vi.waitFor(() => expect(sendBinary).toHaveBeenCalled()) + const streamId = stream.streamId + + const injectSnapshot = (info: Record, text: string): void => { + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotStart, + streamId, + seq: 1, + payload: encodeTerminalStreamJson(info) + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotChunk, + streamId, + seq: 2, + payload: encodeTerminalStreamText(text) + }) + ) + callbacks?.onBinary?.( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.SnapshotEnd, + streamId, + seq: 3, + payload: new Uint8Array(0) + }) + ) + } + + injectSnapshot({ kind: 'scrollback', cols: 120, rows: 40, truncated: false }, 'initial state') + expect(onSnapshot).toHaveBeenCalledWith('initial state', { + pendingEscapeTailAnsi: undefined + }) + expect(onSubscribed).toHaveBeenCalledTimes(1) + + injectSnapshot( + { + kind: 'scrollback', + cols: 120, + rows: 40, + reason: 'ack-pending-overflow', + truncated: false + }, + 'recovered state' + ) + // Why: an unsolicited recovery snapshot replaces terminal state, so it + // clears screen and scrollback first and must not replay the subscribe + // lifecycle. + expect(onSnapshot).toHaveBeenCalledWith(`\x1b[2J\x1b[3J\x1b[H${'recovered state'}`, { + pendingEscapeTailAnsi: undefined + }) + expect(onSubscribed).toHaveBeenCalledTimes(1) + + // Why: an empty recovery snapshot means the model terminal is blank, so + // the client must still clear stale dropped output. + injectSnapshot( + { + kind: 'scrollback', + cols: 120, + rows: 40, + reason: 'ack-pending-overflow', + truncated: false + }, + '' + ) + expect(onSnapshot).toHaveBeenCalledWith('\x1b[2J\x1b[3J\x1b[H', { + pendingEscapeTailAnsi: undefined + }) + expect(onSubscribed).toHaveBeenCalledTimes(1) + + stream.close() + }) +}) diff --git a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts index b773a0d7721..bf005d398e7 100644 --- a/src/renderer/src/store/slices/agent-status-quit-capture.test.ts +++ b/src/renderer/src/store/slices/agent-status-quit-capture.test.ts @@ -214,6 +214,52 @@ describe('captureAllSleepingAgentSessions', () => { }) }) + it('skips rewriting an unchanged resume record on repeated capture', () => { + const store = createTestStore() + store.setState({ + tabsByWorktree: { + 'wt-1': [makeTab({ id: 'tab-1', worktreeId: 'wt-1' })] + } + } as Partial) + const providerSession = { key: 'session_id' as const, id: 'codex-session-1' } + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'working', prompt: 'first task', agentType: 'codex' }, + 'Codex', + { updatedAt: 10, stateStartedAt: 10 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + + store.getState().captureAllSleepingAgentSessions() + const first = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1'] + expect(first).toMatchObject({ origin: 'quit' }) + + // Why: the periodic resume-record capture re-runs this action every + // minute; an unchanged agent must not dirty the store (and the debounced + // session write) with a record differing only by capturedAt. + store.getState().captureAllSleepingAgentSessions() + expect(store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1']).toBe(first) + + // A real status change must still refresh the record. + store + .getState() + .setAgentStatus( + 'tab-1:leaf-1', + { state: 'waiting', prompt: 'first task', agentType: 'codex' }, + 'Codex', + { updatedAt: 20, stateStartedAt: 20 }, + { tabId: 'tab-1', worktreeId: 'wt-1' }, + { providerSession } + ) + store.getState().captureAllSleepingAgentSessions() + const refreshed = store.getState().sleepingAgentSessionsByPaneKey['tab-1:leaf-1'] + expect(refreshed).not.toBe(first) + expect(refreshed).toMatchObject({ state: 'waiting', origin: 'quit' }) + }) + it('preserves hydrated launch config during live recapture without a registry entry', () => { const store = createTestStore() store.setState({ diff --git a/src/renderer/src/store/slices/agent-status.ts b/src/renderer/src/store/slices/agent-status.ts index 182d113411d..10415550216 100644 --- a/src/renderer/src/store/slices/agent-status.ts +++ b/src/renderer/src/store/slices/agent-status.ts @@ -603,6 +603,34 @@ export function collectHibernatedCompletionEvidenceForWorktree( return retained } +// Why: the periodic resume-record capture re-runs on an interval; comparing +// everything except capturedAt lets an unchanged agent skip the store write +// entirely, so idle ticks never dirty the session persistence pipeline. +function sleepingRecordsEquivalentIgnoringCaptureTime( + existing: SleepingAgentSessionRecord | undefined, + next: SleepingAgentSessionRecord +): boolean { + if (!existing) { + return false + } + return ( + existing.paneKey === next.paneKey && + existing.tabId === next.tabId && + existing.worktreeId === next.worktreeId && + existing.agent === next.agent && + existing.providerSession.key === next.providerSession.key && + existing.providerSession.id === next.providerSession.id && + existing.prompt === next.prompt && + existing.state === next.state && + existing.updatedAt === next.updatedAt && + existing.terminalTitle === next.terminalTitle && + existing.lastAssistantMessage === next.lastAssistantMessage && + existing.interrupted === next.interrupted && + existing.origin === next.origin && + launchConfigsEqual(existing.launchConfig, next.launchConfig) + ) +} + function recoveryRecordMatches( existing: SleepingAgentSessionRecord | undefined, next: SleepingAgentSessionRecord @@ -2203,7 +2231,10 @@ export const createAgentStatusSlice: StateCreator { seedStore(store, { settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'env-1' }, worktreesByRepo: { - repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1' })] + repo1: [makeWorktree({ id: worktreeId, repoId: 'repo1', hostId: 'runtime:env-1' })] }, tabsByWorktree: {}, ptyIdsByTabId: {}, diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index 211dded1173..77d93f75d19 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -57,6 +57,10 @@ import { restorePtyDataHandlersAfterFailedShutdown, unregisterPtyDataHandlers } from '@/components/terminal-pane/pty-transport' +// Why: import the store-free registry, not terminal-parked-tab-watchers — +// that module imports @/store, and a slice importing it would re-enter store +// creation before this slice finishes evaluating. +import { disposeParkedTerminalWatchersForPtyIds } from '@/components/terminal-pane/terminal-parked-watcher-registry' import { normalizeTerminalLayoutSnapshot, resolvePtyBoundActiveLeafId @@ -2281,6 +2285,11 @@ export const createTerminalSlice: StateCreator // Removing the data handlers first ensures the final flush is a no-op. if (expectedRuntimePtyIds.length === 0) { unregisterPtyDataHandlers(shutdownPtyIds) + // Why: parked-tab byte watchers observe the same flush through dispatcher + // sidecars, which the call above does not touch — dispose them now or a + // just-slept/deleted worktree still gets unread marks and delayed + // bell/completion OS notifications from its teardown bytes. + disposeParkedTerminalWatchersForPtyIds(shutdownPtyIds) } // Why (ordering invariant — DESIGN_DOC §3.3.c): on sleep, capture every diff --git a/src/renderer/src/store/slices/worktree-helpers.ts b/src/renderer/src/store/slices/worktree-helpers.ts index 1f2ce461ba2..e664e42f8b6 100644 --- a/src/renderer/src/store/slices/worktree-helpers.ts +++ b/src/renderer/src/store/slices/worktree-helpers.ts @@ -20,7 +20,7 @@ import type { WorktreeMeta, WorkspaceKey } from '../../../../shared/types' -import type { TerminalGitHubPRLink } from '@/lib/terminal-github-pr-link-detector' +import type { TerminalGitHubPRLink } from '../../../../shared/terminal-github-pr-link-detector' import type { PendingWorktreeCreation, WorktreeCreationPhase diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index 4bd602c7634..13fa9913334 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -51,6 +51,7 @@ import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../../shared/orca-profiles' import { legacyBaseRefSearchResult } from '../../../shared/base-ref-search-result' +import { EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS } from '../../../shared/pty-delivery-diagnostics' import { createE2EConfig } from '../../../shared/e2e-config' import { relativePathInsideRoot } from '../../../shared/cross-platform-path' import { @@ -571,6 +572,9 @@ function createWebPreloadApi(): Partial { }, settings: { get: async () => getRuntimeBackedStoredSettings(), + // Why: localStorage-backed settings are synchronous in the web client, + // so the pre-hydration kill-switch read works the same as desktop. + getSync: () => getStoredSettings(), set: async (updates) => { if (updates.activeRuntimeEnvironmentId === null) { disconnectActiveRuntimeEnvironment() @@ -2707,9 +2711,21 @@ function createPtyApi(): NonNullable['pty']> { kill: () => Promise.resolve(), ackColdRestore: () => {}, ackData: () => {}, + onDeliveryResyncRequest: () => noopUnsubscribe, + respondDeliveryResync: () => {}, + // Why healthy stub: web terminals ride the remote-runtime transport, not + // main's delivery gate — a zero-in-flight reply keeps the watchdog idle. + reportRendererDeliveryState: () => + Promise.resolve({ inFlightTotalChars: 0, inFlightPtyCount: 0, msSinceLastAck: null }), + getPtyDataListenerCount: () => 0, rendererDispatcherReady: () => {}, setActiveRendererPty: () => {}, setRendererPtyVisible: () => {}, + setHiddenRendererPty: () => {}, + setPtyDeliveryInterest: () => {}, + // Why no-op: remote-runtime PTYs are never hidden-gate markable, so the + // web client has no main-side responder to feed. + publishTerminalViewAttributes: () => {}, hasChildProcesses: () => Promise.resolve(false), getForegroundProcess: () => Promise.resolve(null), getCwd: () => Promise.resolve('~'), @@ -2717,6 +2733,10 @@ function createPtyApi(): NonNullable['pty']> { listSessions: () => Promise.resolve([]), hasPty: () => Promise.resolve(null), getMainBufferSnapshot: () => Promise.resolve(null), + // Why: remote-runtime PTYs never transit local main, so the web client has + // no side-effect facts source; renderer byte parsing stays authoritative. + onSideEffect: () => noopUnsubscribe, + getSideEffectSnapshot: () => Promise.resolve(null), getRendererDeliveryDebugSnapshot: () => Promise.resolve({ pendingPtyCount: 0, @@ -2732,6 +2752,14 @@ function createPtyApi(): NonNullable['pty']> { peakRendererInFlightChars: 0, peakMaxRendererInFlightCharsByPty: 0, ackGatedFlushSkipCount: 0, + hiddenDeliveryGatedPtyCount: 0, + hiddenDeliveryGatedVisiblePtyCount: 0, + hiddenDeliveryGatedActivePtyCount: 0, + deliveryInterestPtyCount: 0, + hiddenDeliveryDroppedChars: 0, + hiddenDeliveryDroppedChunks: 0, + pendingDroppedChars: 0, + diagnostics: EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS, rendererLifecycleResetCount: 0, lastLifecycleResetClearedChars: 0, rendererPtyDispatcherReady: false, @@ -2740,6 +2768,7 @@ function createPtyApi(): NonNullable['pty']> { resetRendererDeliveryDebug: () => Promise.resolve(), onData: () => noopUnsubscribe, onReplay: () => noopUnsubscribe, + onModelRestoreNeeded: () => noopUnsubscribe, onExit: () => noopUnsubscribe, onSerializeBufferRequest: () => noopUnsubscribe, onClearBufferRequest: () => noopUnsubscribe, diff --git a/src/shared/agent-detection.ts b/src/shared/agent-detection.ts index 90e72a93d90..dbb13895a1a 100644 --- a/src/shared/agent-detection.ts +++ b/src/shared/agent-detection.ts @@ -1,34 +1,35 @@ /** - * Compatibility barrel for terminal-title agent detection — used by the main - * process (stats collection), the renderer (activity indicators, unread - * badges), and shared siblings. + * Compatibility barrel for shared terminal agent-title detection. * - * The implementation was split into domain modules in Phase 3 of the title - * evidence work: identity/label detection → `terminal-title-agent-type`, status - * classification → `terminal-title-status`, and display normalization → - * `terminal-title-display`. This barrel is kept so the existing main/mobile/ - * renderer import paths that reference `agent-detection` stay stable. + * Why shared: main and renderer both consume OSC titles for facts, stats, and + * UI state. Keep existing imports stable while the implementation stays split + * into focused modules that satisfy max-lines. (main's #7612 split into + * `terminal-title-*` modules coexists — those files stay on disk for their + * direct `resolveTerminalTitleAgentType`/`synthetic-agent-title` consumers.) */ -export { titleHasAgentName } from './agent-name-token-match' +export type { AgentStatus } from './agent-title-core' +export { + isClaudeManagementTitle, + isCursorNativeAgentTitle, + isGeminiTerminalTitle, + isPiTerminalTitle, + STRONG_IDLE_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE +} from './agent-title-core' +export { getAgentLabel, isClaudeAgent } from './agent-title-identity' +export { + clearWorkingIndicators, + createAgentStatusTracker, + detectAgentStatusFromTitle, + normalizeTerminalTitle +} from './agent-title-status' + +// Re-export so existing `agent-detection` importers keep working. +export { AGENT_NAMES, titleHasAgentName } from './agent-name-token-match' export { extractAllOscTitles, extractLastOscTitle, MAX_OSC_TITLE_CHARS } from './osc-title-extraction' export { isShellProcess } from './shell-process-detection' -export { - getAgentLabel, - isClaudeAgent, - isClaudeManagementTitle, - isGeminiTerminalTitle, - isPiTerminalTitle -} from './terminal-title-agent-type' -export type { AgentStatus } from './terminal-title-status' -export { - createAgentStatusTracker, - detectAgentStatusFromTitle, - STRONG_IDLE_KEYWORDS_RE, - STRONG_WORKING_KEYWORDS_RE -} from './terminal-title-status' -export { clearWorkingIndicators, normalizeTerminalTitle } from './terminal-title-display' diff --git a/src/shared/agent-title-core.ts b/src/shared/agent-title-core.ts new file mode 100644 index 00000000000..1bafae3cbd4 --- /dev/null +++ b/src/shared/agent-title-core.ts @@ -0,0 +1,110 @@ +import { + AGY_AGENT_NAME_RE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + titleHasAgentName, + titleHasAnyLegacyAgentName +} from './agent-name-token-match' +import { isLegacyPiCompatibleTitle } from './pi-compatible-synthetic-title' + +export { AGY_AGENT_NAME_RE, DROID_AGENT_NAME_RE, HERMES_AGENT_NAME_RE, titleHasAgentName } + +export type AgentStatus = 'working' | 'permission' | 'idle' + +export const CLAUDE_IDLE = '\u2733' // ✳ +const CLAUDE_COMMAND_RE = String.raw`(?:.*[\\/])?claude(?:\.(?:exe|cmd|bat|ps1))?` +export const CLAUDE_MANAGEMENT_TITLE_RE = new RegExp( + String.raw`^\s*(?:"${CLAUDE_COMMAND_RE}"|'${CLAUDE_COMMAND_RE}'|${CLAUDE_COMMAND_RE})\s+agents\s*$`, + 'i' +) + +export const GEMINI_WORKING = '\u2726' // ✦ +export const GEMINI_SILENT_WORKING = '\u23f2' // ⏲ +export const GEMINI_IDLE = '\u25c7' // ◇ +export const GEMINI_PERMISSION = '\u270b' // ✋ + +const STRONG_IDLE_KEYWORDS = ['ready', 'idle', 'done'] as const +const STRONG_WORKING_KEYWORDS = ['working', 'thinking', 'running'] as const + +// Why: plain `\b` matches inside hyphenated tokens and cwd paths such as +// "~/codex/ready"; the left side also blocks path separators for Windows/Unix. +export const STRONG_IDLE_KEYWORDS_RE = new RegExp( + `(?= 0x2800 && codePoint <= 0x28ff) { + return true + } + } + return false +} + +export function containsLegacyAgentName(title: string): boolean { + return titleHasAnyLegacyAgentName(title) +} + +export function containsAgentName(title: string): boolean { + return ( + containsLegacyAgentName(title) || + AGY_AGENT_NAME_RE.test(title) || + DROID_AGENT_NAME_RE.test(title) || + HERMES_AGENT_NAME_RE.test(title) + ) +} + +export function containsAny(title: string, words: readonly string[]): boolean { + const lower = title.toLowerCase() + return words.some((word) => lower.includes(word)) +} + +export function isClaudeManagementTitle(title: string): boolean { + return CLAUDE_MANAGEMENT_TITLE_RE.test(title) +} + +export function isCursorNativeAgentTitle(title: string): boolean { + return title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER +} diff --git a/src/shared/agent-title-identity.ts b/src/shared/agent-title-identity.ts new file mode 100644 index 00000000000..89dbded12f8 --- /dev/null +++ b/src/shared/agent-title-identity.ts @@ -0,0 +1,111 @@ +import { + AGY_AGENT_NAME_RE, + CLAUDE_IDLE, + DROID_AGENT_NAME_RE, + HERMES_AGENT_NAME_RE, + containsBrailleSpinner, + isClaudeManagementTitle, + isGeminiTerminalTitle, + isPiAgentTitle, + titleHasAgentName +} from './agent-title-core' +import { getPiCompatibleSyntheticAgentLabel } from './pi-compatible-synthetic-title' + +/** + * Returns true when the terminal title matches Claude Code's title conventions. + * Used to scope prompt-cache-timer behavior to Claude sessions only. + */ +export function isClaudeAgent(title: string): boolean { + if (!title || isClaudeManagementTitle(title)) { + return false + } + const lower = title.toLowerCase() + + // Why: Claude title prefixes are stronger than task text, which can mention + // other agents without changing the owning CLI. + if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { + return true + } + if (title.startsWith('. ') || title.startsWith('* ')) { + return true + } + if (containsBrailleSpinner(title)) { + return !lower.includes('cursor') && !lower.includes('openclaude') + } + + const trimmedTitle = title.trimStart() + return ( + trimmedTitle.toLowerCase().startsWith('claude') && titleHasAgentName(trimmedTitle, 'claude') + ) +} + +export function getAgentLabel(title: string): string | null { + if (isClaudeManagementTitle(title)) { + return null + } + // Why: Claude task titles can mention another CLI; the prefix is the identity + // signal, not arbitrary task text. + if ( + title.startsWith(`${CLAUDE_IDLE} `) || + title === CLAUDE_IDLE || + title.startsWith('. ') || + title.startsWith('* ') + ) { + return 'Claude Code' + } + if (isGeminiTerminalTitle(title)) { + return 'Gemini CLI' + } + // Why: Pi-compatible synthetic titles can carry braille spinners, which the + // generic agent-title heuristics would otherwise claim first. + const piCompatibleSyntheticAgentLabel = getPiCompatibleSyntheticAgentLabel(title) + if (piCompatibleSyntheticAgentLabel) { + return piCompatibleSyntheticAgentLabel + } + if (isPiAgentTitle(title)) { + return 'Pi' + } + + if (titleHasAgentName(title, 'codex')) { + return 'Codex' + } + if (titleHasAgentName(title, 'openclaude')) { + return 'OpenClaude' + } + if (titleHasAgentName(title, 'copilot')) { + return 'GitHub Copilot' + } + if (titleHasAgentName(title, 'grok')) { + return 'Grok' + } + if (titleHasAgentName(title, 'devin')) { + return 'Devin' + } + if (titleHasAgentName(title, 'antigravity') || AGY_AGENT_NAME_RE.test(title)) { + return 'Antigravity' + } + if (titleHasAgentName(title, 'opencode')) { + return 'OpenCode' + } + if (titleHasAgentName(title, 'mimo')) { + return 'MiMo Code' + } + if (titleHasAgentName(title, 'aider')) { + return 'Aider' + } + // Why: match explicit names before Claude's generic braille heuristic. + if (titleHasAgentName(title, 'cursor')) { + return 'Cursor' + } + if (DROID_AGENT_NAME_RE.test(title)) { + return 'Droid' + } + if (HERMES_AGENT_NAME_RE.test(title)) { + return 'Hermes' + } + if (isClaudeAgent(title)) { + return 'Claude Code' + } + + return null +} diff --git a/src/shared/agent-title-status.ts b/src/shared/agent-title-status.ts new file mode 100644 index 00000000000..7c589e9935e --- /dev/null +++ b/src/shared/agent-title-status.ts @@ -0,0 +1,203 @@ +import { + AGY_AGENT_NAME_RE, + BRAILLE_SPINNER_RE, + CLAUDE_IDLE, + CURSOR_NATIVE_TITLE_LOWER, + DROID_AGENT_NAME_RE, + GEMINI_IDLE, + GEMINI_PERMISSION, + GEMINI_SILENT_WORKING, + GEMINI_WORKING, + HERMES_AGENT_NAME_RE, + STRONG_IDLE_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE, + STRONG_WORKING_KEYWORDS_RE_GLOBAL, + containsAgentName, + containsAny, + containsBrailleSpinner, + containsLegacyAgentName, + isClaudeManagementTitle, + isGeminiTerminalTitle, + isPiAgentTitle, + isPiTerminalTitle +} from './agent-title-core' +import type { AgentStatus } from './agent-title-core' +import { getPiCompatibleSyntheticAgentStatus } from './pi-compatible-synthetic-title' +import { isGrokRotatingWorkingTitle } from './terminal-title-agent-type' + +/** + * Strip working-status indicators so stale exit titles stop reporting working. + */ +export function clearWorkingIndicators(title: string): string { + let cleaned = title + + cleaned = cleaned.replace(GEMINI_WORKING, '') + cleaned = cleaned.replace(GEMINI_SILENT_WORKING, '') + cleaned = cleaned.replace(BRAILLE_SPINNER_RE, '') + if (cleaned.startsWith('. ')) { + cleaned = cleaned.slice(2) + } + if (containsAgentName(cleaned)) { + cleaned = cleaned.replace(STRONG_WORKING_KEYWORDS_RE_GLOBAL, '') + } + + cleaned = cleaned.replace(/\s{2,}/g, ' ').trim() + return cleaned || title +} + +/** + * Tracks agent status transitions from terminal title changes. + */ +export function createAgentStatusTracker( + onBecameIdle: (title: string) => void, + onBecameWorking?: () => void, + onAgentExited?: () => void, + initialTitle?: string +): { + handleTitle: (title: string) => void + seedTitle: (title: string) => void + reset: () => void +} { + // Why: trackers restored mid-session need a last-known status without firing + // callbacks, or a hidden working agent can miss its later idle transition. + let lastStatus: AgentStatus | null = + initialTitle !== undefined ? detectAgentStatusFromTitle(initialTitle) : null + + return { + handleTitle(title: string): void { + const newStatus = detectAgentStatusFromTitle(title) + if (lastStatus === 'working' && newStatus !== null && newStatus !== 'working') { + onBecameIdle(title) + } + if (lastStatus !== 'working' && newStatus === 'working') { + onBecameWorking?.() + } + // Why: reverting to a plain shell prompt after idle/permission means the + // agent exited; while working it can just be a transient internal title. + if (lastStatus !== null && lastStatus !== 'working' && newStatus === null) { + lastStatus = null + onAgentExited?.() + } + if (newStatus !== null) { + lastStatus = newStatus + } + }, + seedTitle(title: string): void { + lastStatus = detectAgentStatusFromTitle(title) + }, + reset(): void { + lastStatus = null + } + } +} + +/** + * Normalize high-churn agent titles into stable display labels before storage. + */ +export function normalizeTerminalTitle(title: string): string { + if (!title) { + return title + } + + if (isGeminiTerminalTitle(title)) { + const status = detectAgentStatusFromTitle(title) + if (status === 'permission') { + return `${GEMINI_PERMISSION} Gemini CLI` + } + if (status === 'working') { + return `${GEMINI_WORKING} Gemini CLI` + } + if (status === 'idle') { + return `${GEMINI_IDLE} Gemini CLI` + } + } + + // Why: Pi animates every 80ms; collapse frames while preserving status. + if (isPiAgentTitle(title)) { + const status = detectAgentStatusFromTitle(title) + if (status === 'working') { + return '\u280b Pi' + } + if (status === 'idle') { + return 'Pi' + } + } + + // Why: Grok Build interpolates a rotating status/tool phrase between the + // spinner and its name, so its working frames change the title many times per + // turn. Collapse them to one stable label; idle/session titles carry no + // spinner and pass through, so the meaningful final title still shows (#7863). + if (isGrokRotatingWorkingTitle(title)) { + return '\u280b Grok' + } + + return title +} + +export function detectAgentStatusFromTitle(title: string): AgentStatus | null { + if (!title || isClaudeManagementTitle(title)) { + return null + } + if (title.trim().toLowerCase() === CURSOR_NATIVE_TITLE_LOWER) { + return null + } + + if (title.includes(GEMINI_PERMISSION)) { + return 'permission' + } + if (title.includes(GEMINI_WORKING) || title.includes(GEMINI_SILENT_WORKING)) { + return 'working' + } + if (title.includes(GEMINI_IDLE)) { + return 'idle' + } + + // Why: resolve synthetic Pi/OMP permission/idle labels before the broader + // Pi and braille-spinner checks below. + const piCompatibleSyntheticAgentStatus = getPiCompatibleSyntheticAgentStatus(title) + if (piCompatibleSyntheticAgentStatus) { + return piCompatibleSyntheticAgentStatus + } + + if (title.startsWith(`${CLAUDE_IDLE} `) || title === CLAUDE_IDLE) { + return 'idle' + } + if (isPiTerminalTitle(title)) { + return 'idle' + } + if (containsBrailleSpinner(title)) { + return 'working' + } + + const hasDroidAgentName = DROID_AGENT_NAME_RE.test(title) + const hasHermesAgentName = HERMES_AGENT_NAME_RE.test(title) + const hasAgyAgentName = AGY_AGENT_NAME_RE.test(title) + const hasLegacyAgentName = containsLegacyAgentName(title) + if (!hasLegacyAgentName && !hasDroidAgentName && !hasHermesAgentName && !hasAgyAgentName) { + return null + } + if (containsAny(title, ['action required', 'permission', 'waiting'])) { + return 'permission' + } + // Why: boundary-aware regexes avoid cwd/path and substring false positives. + if (STRONG_IDLE_KEYWORDS_RE.test(title)) { + return 'idle' + } + if (STRONG_WORKING_KEYWORDS_RE.test(title)) { + return 'working' + } + if (title.startsWith('. ')) { + return 'working' + } + if (title.startsWith('* ')) { + return 'idle' + } + + // Why: Droid hook events are authoritative; native name-only titles should + // not turn a still-sleeping execute tool into completion. + if (hasDroidAgentName && !hasLegacyAgentName) { + return null + } + + return 'idle' +} diff --git a/src/shared/agent-tui-ansi-fuzz-stream.ts b/src/shared/agent-tui-ansi-fuzz-stream.ts new file mode 100644 index 00000000000..afffcc0a411 --- /dev/null +++ b/src/shared/agent-tui-ansi-fuzz-stream.ts @@ -0,0 +1,279 @@ +// Seeded ANSI byte-stream generator for the terminal garble differential +// fuzz suites (headless-emulator-fidelity.fuzz.test.ts and +// hidden-reveal-reconciliation.fuzz.test.ts). The op mix models real agent +// TUI output (Claude Code / Codex): CR status-line redraws, cursor-up panel +// repaints, DEC 2026 synchronized frames, SGR color runs, wide CJK and ZWJ +// emoji, wrapped long lines, alt-screen sessions, and scroll regions. +// +// Deliberately excluded ops (they would fuzz behavior the snapshot/restore +// contract does not promise to preserve, so any diff would be noise, not a +// garble bug): +// - DECAWM (?7l) and IRM (CSI 4h): SerializeAddon does not re-emit them and +// rehydrateSequences (headless-emulator.ts buildRehydrateSequences) only +// covers alt-screen/bracketed-paste/app-cursor/mouse modes. +// - Terminal queries (DA/DSR/DECRQM): the emulator is write-only by contract +// (headless-emulator.ts onQueryReply gating); replies are a separate +// authority problem with its own pinned tests (session.test.ts). + +/** Same seeded PRNG as retained-tail-redraw-window.equivalence.test.ts. */ +export function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +export type AgentTuiStreamDims = { cols: number; rows: number } + +export type AgentTuiStreamProfile = { + /** Mouse-mode toggles require mirroring TerminalMouseModeMirror to build + * rehydrate parity; the renderer-side fuzz cannot import that main-only + * module (tsconfig.tc.web.json excludes src/main/daemon), so it opts out. */ + includeMouseModes: boolean + /** OSC 8 hyperlinks mark their cells underlined in the xterm buffer, but + * SerializeAddon never re-emits OSC 8 — production restores link ranges + * out-of-band via snapshot.oscLinks (collectHeadlessOscLinkRanges), so + * byte-replay fidelity legitimately drops that underline. The suites pin + * the metadata compensation in a targeted test instead of fuzzing it. */ + includeOscHyperlinks: boolean + opCount: number +} + +function int(rng: () => number, min: number, max: number): number { + return min + Math.floor(rng() * (max - min + 1)) +} + +function pick(rng: () => number, values: readonly T[]): T { + return values[Math.floor(rng() * values.length)]! +} + +const WORDS = [ + 'reading', + 'src/main/daemon/session.ts', + 'tokens 12.4k', + 'esc to interrupt', + 'Thinking…', + 'bash: pnpm typecheck', + '+142 -37', + 'PASS terminal.test.ts', + 'waiting for approval', + 'diff --git a/pty.ts' +] as const + +const WIDE_RUNS = [ + '你好世界', + '터미널 상태 확인', + '進捗を表示中', + '🟢 working', + '🤖 codex', + '✅ done ✨', + // ZWJ emoji join — the exact width divergence the Orca unicode provider + // exists for (shared/terminal-unicode-provider.ts). + '👨‍👩‍👧‍👦 team', + '🇰🇷 locale' +] as const + +const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧'] as const + +function sgr(rng: () => number): string { + const roll = rng() + if (roll < 0.2) { + return `\x1b[${pick(rng, ['0', '1', '2', '3', '4', '7', '9', '22', '24', '39', '49'])}m` + } + if (roll < 0.55) { + return `\x1b[${int(rng, 30, 37) + (rng() < 0.3 ? 60 : 0)}m` + } + if (roll < 0.8) { + return `\x1b[38;5;${int(rng, 0, 255)}m` + } + return `\x1b[38;2;${int(rng, 0, 255)};${int(rng, 0, 255)};${int(rng, 0, 255)}m` +} + +function textRun(rng: () => number): string { + const parts: string[] = [] + const count = int(rng, 1, 3) + for (let i = 0; i < count; i++) { + parts.push(rng() < 0.25 ? pick(rng, WIDE_RUNS) : pick(rng, WORDS)) + } + return parts.join(' ') +} + +function styledLine(rng: () => number): string { + return `${sgr(rng)}${textRun(rng)}\x1b[0m\r\n` +} + +function panelRedraw(rng: () => number, dims: AgentTuiStreamDims): string { + const height = int(rng, 1, Math.min(6, dims.rows - 2)) + const lines: string[] = [`\x1b[${height}A\r`] + if (rng() < 0.5) { + lines.push('\x1b[0J') + } + for (let i = 0; i < height; i++) { + lines.push(`\x1b[2K${sgr(rng)}│ ${textRun(rng)}\x1b[0m\r\n`) + } + return lines.join('') +} + +function statusLineRewrite(rng: () => number): string { + return `\r\x1b[2K${sgr(rng)}${pick(rng, SPINNER)} ${textRun(rng)}\x1b[0m` +} + +function cursorMotion(rng: () => number, dims: AgentTuiStreamDims): string { + const roll = rng() + if (roll < 0.4) { + return `\x1b[${int(rng, 1, dims.rows)};${int(rng, 1, dims.cols)}H` + } + if (roll < 0.55) { + return `\x1b[${int(rng, 1, dims.cols)}G` + } + return `\x1b[${int(rng, 1, 4)}${pick(rng, ['A', 'B', 'C', 'D'] as const)}` +} + +function eraseOp(rng: () => number): string { + return pick(rng, ['\x1b[K', '\x1b[1K', '\x1b[2K', '\x1b[0J', '\x1b[1J'] as const) +} + +function wrappedLongLine(rng: () => number, dims: AgentTuiStreamDims): string { + const unit = `${textRun(rng)} ` + const repeats = Math.ceil((dims.cols * int(rng, 1, 3)) / Math.max(unit.length, 1)) + 1 + return `${sgr(rng)}${unit.repeat(repeats)}\x1b[0m\r\n` +} + +function scrollRegionBurst(rng: () => number, dims: AgentTuiStreamDims): string { + const top = int(rng, 1, Math.max(1, dims.rows - 4)) + const bottom = int(rng, top + 1, dims.rows) + const body: string[] = [`\x1b[${top};${bottom}r`, `\x1b[${bottom};1H`] + for (let i = 0; i < int(rng, 1, 4); i++) { + body.push(`${textRun(rng)}\r\n`) + } + body.push('\x1b[r') + return body.join('') +} + +function altScreenFrame(rng: () => number, dims: AgentTuiStreamDims): string { + const rows = int(rng, 2, Math.min(8, dims.rows)) + const body: string[] = ['\x1b[?1049h', '\x1b[2J\x1b[H', '\x1b[?25l'] + for (let i = 0; i < rows; i++) { + body.push(`${sgr(rng)}│ ${textRun(rng)}\x1b[0m${i === rows - 1 ? '' : '\r\n'}`) + } + body.push(`\x1b[${int(rng, 1, dims.rows)};${int(rng, 1, dims.cols)}H\x1b[?25h`) + if (rng() < 0.5) { + body.push('\x1b[?1049l') + } + return body.join('') +} + +function synchronizedFrame(rng: () => number, dims: AgentTuiStreamDims): string { + return `\x1b[?2026h${rng() < 0.5 ? panelRedraw(rng, dims) : statusLineRewrite(rng)}\x1b[?2026l` +} + +function savedCursorDetour(rng: () => number, dims: AgentTuiStreamDims): string { + return `\x1b7${cursorMotion(rng, dims)}${sgr(rng)}${textRun(rng)}\x1b[0m\x1b8` +} + +function oscOp(rng: () => number, profile: AgentTuiStreamProfile): string { + const roll = rng() + if (roll < 0.5) { + return `\x1b]0;${textRun(rng)}\x07` + } + if (roll < 0.8) { + return profile.includeOscHyperlinks + ? `\x1b]8;;https://example.com/${int(rng, 1, 999)}\x07link\x1b]8;;\x07` + : `https://example.com/pr/${int(rng, 1, 999)}\r\n` + } + return '\x1b]133;A\x07' +} + +function modeToggle(rng: () => number, profile: AgentTuiStreamProfile): string { + const toggles = ['\x1b[?2004h', '\x1b[?2004l', '\x1b[?1h', '\x1b[?1l', '\x1b[?25l', '\x1b[?25h'] + if (profile.includeMouseModes) { + toggles.push( + '\x1b[?1000h', + '\x1b[?1002h', + '\x1b[?1003h', + '\x1b[?1006h', + '\x1b[?1000l', + '\x1b[?1006l' + ) + } + return pick(rng, toggles) +} + +/** One seeded agent-TUI-shaped op. Weights favor the redraw ops that have + * historically produced hidden-restore garble (CR rewrites, cursor-up panel + * repaints, DEC 2026 frames, alt-screen churn). */ +function nextOp( + rng: () => number, + dims: AgentTuiStreamDims, + profile: AgentTuiStreamProfile +): string { + const roll = rng() + if (roll < 0.18) { + return styledLine(rng) + } + if (roll < 0.32) { + return statusLineRewrite(rng) + } + if (roll < 0.46) { + return panelRedraw(rng, dims) + } + if (roll < 0.54) { + return synchronizedFrame(rng, dims) + } + if (roll < 0.62) { + return wrappedLongLine(rng, dims) + } + if (roll < 0.7) { + return cursorMotion(rng, dims) + eraseOp(rng) + } + if (roll < 0.76) { + return altScreenFrame(rng, dims) + } + if (roll < 0.82) { + return scrollRegionBurst(rng, dims) + } + if (roll < 0.88) { + return savedCursorDetour(rng, dims) + } + if (roll < 0.94) { + return oscOp(rng, profile) + } + return modeToggle(rng, profile) +} + +export function buildAgentTuiStreamOps( + rng: () => number, + dims: AgentTuiStreamDims, + profile: AgentTuiStreamProfile +): string[] { + const ops: string[] = [] + for (let i = 0; i < profile.opCount; i++) { + ops.push(nextOp(rng, dims, profile)) + } + return ops +} + +/** Splits a stream at random boundaries, including inside escape sequences + * (PTY chunking does that constantly). Never splits a surrogate pair: PTY + * bytes are decoded to complete code points before reaching JS strings. */ +export function splitIntoRandomChunks( + rng: () => number, + stream: string, + bounds: { minLen: number; maxLen: number } +): string[] { + const chunks: string[] = [] + let cursor = 0 + while (cursor < stream.length) { + let end = Math.min(stream.length, cursor + int(rng, bounds.minLen, bounds.maxLen)) + const boundaryCode = stream.charCodeAt(end - 1) + if (end < stream.length && boundaryCode >= 0xd800 && boundaryCode <= 0xdbff) { + end += 1 + } + chunks.push(stream.slice(cursor, end)) + cursor = end + } + return chunks +} diff --git a/src/renderer/src/components/terminal-pane/command-code-output-status.test.ts b/src/shared/command-code-output-status.test.ts similarity index 100% rename from src/renderer/src/components/terminal-pane/command-code-output-status.test.ts rename to src/shared/command-code-output-status.test.ts diff --git a/src/renderer/src/components/terminal-pane/command-code-output-status.ts b/src/shared/command-code-output-status.ts similarity index 95% rename from src/renderer/src/components/terminal-pane/command-code-output-status.ts rename to src/shared/command-code-output-status.ts index 97e5f00db8b..8bba8a7472b 100644 --- a/src/renderer/src/components/terminal-pane/command-code-output-status.ts +++ b/src/shared/command-code-output-status.ts @@ -1,3 +1,11 @@ +/** + * Command Code TUI output scrape — that CLI lacks hooks, so working/done + * agent-status rows are seeded from its rendered status words and idle + * composer. Shared because main runs this per-PTY under side-effect authority + * (emitting command-code facts) while the renderer keeps the byte path for + * remote-runtime PTYs and the kill switch + * (docs/reference/terminal-side-effect-authority.md). + */ import { cleanCommandCodePromptCandidate, isCommandCodeIdlePromptCandidate diff --git a/src/renderer/src/components/terminal-pane/command-code-prompt-text.ts b/src/shared/command-code-prompt-text.ts similarity index 100% rename from src/renderer/src/components/terminal-pane/command-code-prompt-text.ts rename to src/shared/command-code-prompt-text.ts diff --git a/src/shared/constants.ts b/src/shared/constants.ts index d051d0500bb..f0b6c9dc04e 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -309,6 +309,10 @@ export function getDefaultSettings(homedir: string): GlobalSettings { claudeManagedAccounts: [], activeClaudeManagedAccountId: null, terminalScopeHistoryByWorktree: true, + terminalHiddenViewParking: true, + terminalMainSideEffectAuthority: true, + terminalHiddenDeliveryGate: true, + terminalModelQueryAuthority: true, defaultTuiAgent: null, disabledTuiAgents: [...DEFAULT_DISABLED_TUI_AGENTS], claudeAgentTeamsDefaultDisabledMigrated: true, diff --git a/src/shared/e2e-config.ts b/src/shared/e2e-config.ts index 54da72a45f0..e8f34d6bf51 100644 --- a/src/shared/e2e-config.ts +++ b/src/shared/e2e-config.ts @@ -3,23 +3,34 @@ export type E2EConfig = { headless: boolean exposeStore: boolean userDataDir: string | null + /** Test-only override (ORCA_E2E_TERMINAL_PARKING_DELAY_MS) shrinking the + * terminal hidden-view parking delays. null means use production timing. */ + terminalParkingDelayMs: number | null } type E2EConfigInput = { headless?: boolean exposeStore?: boolean userDataDir?: string | null + terminalParkingDelayMs?: number | null } export function createE2EConfig(input: E2EConfigInput): E2EConfig { const userDataDir = input.userDataDir?.trim() || null const headless = Boolean(input.headless) const exposeStore = Boolean(input.exposeStore) + const terminalParkingDelayMs = + typeof input.terminalParkingDelayMs === 'number' && + Number.isFinite(input.terminalParkingDelayMs) && + input.terminalParkingDelayMs > 0 + ? input.terminalParkingDelayMs + : null return { enabled: headless || exposeStore || userDataDir !== null, headless, exposeStore, - userDataDir + userDataDir, + terminalParkingDelayMs } } diff --git a/src/shared/github-links.ts b/src/shared/github-links.ts new file mode 100644 index 00000000000..a53562f5d5d --- /dev/null +++ b/src/shared/github-links.ts @@ -0,0 +1,102 @@ +// Why shared: main's terminal side-effect tracker emits pr-link facts +// (terminal-side-effect-authority.md, slice 3) and needs the same GitHub URL +// parsing core the renderer link picker uses. +const GH_ITEM_PATH_RE = /^\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/.*)?$/i + +export type RepoSlug = { + owner: string + repo: string +} + +export type GitHubIssueOrPRLink = { + slug: RepoSlug + number: number + type: 'issue' | 'pr' +} + +export function buildGitHubRepoUrl(slug: RepoSlug | null | undefined): string | null { + if (!slug?.owner || !slug.repo) { + return null + } + return `https://github.com/${encodeURIComponent(slug.owner)}/${encodeURIComponent(slug.repo)}` +} + +function matchGitHubItemPath(url: URL): RegExpExecArray | null { + return GH_ITEM_PATH_RE.exec(url.pathname.replace(/\/+$/, '')) +} + +function parseGitHubItemNumber(value: string): number | null { + const parsed = Number.parseInt(value, 10) + return parsed > 0 ? parsed : null +} + +/** + * Parses a GitHub issue/PR reference from plain input. + * Supports issue/PR numbers (e.g. "42"), "#42", and full GitHub URLs. + */ +export function parseGitHubIssueOrPRNumber(input: string): number | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + const numeric = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed + if (/^\d+$/.test(numeric)) { + return parseGitHubItemNumber(numeric) + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + + const match = matchGitHubItemPath(url) + if (!match) { + return null + } + + return parseGitHubItemNumber(match[4]) +} + +/** + * Parses an owner/repo slug plus issue/PR number from a GitHub URL. Returns + * null for anything that isn't a recognizable GitHub-shaped issue or pull URL. + */ +export function parseGitHubIssueOrPRLink(input: string): GitHubIssueOrPRLink | null { + const trimmed = input.trim() + if (!trimmed) { + return null + } + + let url: URL + try { + url = new URL(trimmed) + } catch { + return null + } + + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + return null + } + + const match = matchGitHubItemPath(url) + if (!match) { + return null + } + const number = parseGitHubItemNumber(match[4]) + if (number === null) { + return null + } + + return { + slug: { owner: match[1], repo: match[2] }, + type: match[3].toLowerCase() === 'pull' ? 'pr' : 'issue', + number + } +} diff --git a/src/shared/osc-title-scan-tail.test.ts b/src/shared/osc-title-scan-tail.test.ts new file mode 100644 index 00000000000..f6afef56f48 --- /dev/null +++ b/src/shared/osc-title-scan-tail.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest' +import { extractOscTitleScanTail } from './osc-title-scan-tail' + +describe('extractOscTitleScanTail', () => { + it('keeps incomplete OSC title candidates only', () => { + expect(extractOscTitleScanTail('\x1b]0;Codex work')).toBe('\x1b]0;Codex work') + expect(extractOscTitleScanTail('\x1b]2;Codex working\x1b')).toBe('\x1b]2;Codex working\x1b') + expect(extractOscTitleScanTail('\x1b]')).toBe('\x1b]') + expect(extractOscTitleScanTail('\x1b]1')).toBe('\x1b]1') + }) + + it('does not carry non-title OSC payloads into the title scanner', () => { + expect(extractOscTitleScanTail('\x1b]133;D;13')).toBe('') + expect(extractOscTitleScanTail('\x1b]7;file://host/tmp')).toBe('') + expect(extractOscTitleScanTail('\x1b]133;D;0\x07\x1b')).toBe('\x1b') + }) +}) diff --git a/src/shared/osc-title-scan-tail.ts b/src/shared/osc-title-scan-tail.ts index e37bed4007c..62e49cf9236 100644 --- a/src/shared/osc-title-scan-tail.ts +++ b/src/shared/osc-title-scan-tail.ts @@ -1,18 +1,29 @@ const OSC_TITLE_SCAN_TAIL_LIMIT = 4096 const OSC_TITLE_PREFIX_LENGTH = 4 +const OSC_TITLE_CODES = new Set(['0', '1', '2']) export function extractOscTitleScanTail(input: string): string { const lastOsc = input.lastIndexOf('\x1b]') if (lastOsc !== -1) { const suffix = input.slice(lastOsc) if (!suffix.includes('\x07') && !suffix.includes('\x1b\\')) { - return trimOscTitleScanTail(suffix) + return extractIncompleteTitleOscTail(suffix) } return input.endsWith('\x1b') ? '\x1b' : '' } return input.endsWith('\x1b') ? '\x1b' : '' } +function extractIncompleteTitleOscTail(suffix: string): string { + const parameterEnd = suffix.indexOf(';', 2) + if (parameterEnd === -1) { + const partialParameter = suffix.slice(2) + return ['', '0', '1', '2'].includes(partialParameter) ? trimOscTitleScanTail(suffix) : '' + } + const parameter = suffix.slice(2, parameterEnd) + return OSC_TITLE_CODES.has(parameter) ? trimOscTitleScanTail(suffix) : '' +} + function trimOscTitleScanTail(value: string): string { if (value.length <= OSC_TITLE_SCAN_TAIL_LIMIT) { return value diff --git a/src/shared/pty-delivery-diagnostics.test.ts b/src/shared/pty-delivery-diagnostics.test.ts new file mode 100644 index 00000000000..c7d8b6140ec --- /dev/null +++ b/src/shared/pty-delivery-diagnostics.test.ts @@ -0,0 +1,77 @@ +// Why: pins the freeze-report primitives — the breadcrumb ring must stay +// bounded and coalesce repeat events (a flood costs one slot per second, not +// unbounded memory), and pty-id redaction must keep the correlatable suffix +// while dropping the path-bearing worktree prefix. +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + createPtyDeliveryBreadcrumbRing, + redactPtyIdForDiagnostics +} from './pty-delivery-diagnostics' + +describe('pty delivery breadcrumb ring', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('coalesces same-kind events inside the window and separates them outside it', () => { + const ring = createPtyDeliveryBreadcrumbRing(10, 1_000) + ring.record('gate-mark', { id: 'a' }) + vi.advanceTimersByTime(200) + ring.record('gate-mark', { id: 'b' }) + vi.advanceTimersByTime(200) + ring.record('gate-mark') + + let entries = ring.snapshot() + expect(entries).toHaveLength(1) + expect(entries[0].repeats).toBe(3) + // The freshest detail wins so the report shows the latest actor. + expect(entries[0].detail).toEqual({ id: 'b' }) + + vi.advanceTimersByTime(2_000) + ring.record('gate-mark', { id: 'c' }) + entries = ring.snapshot() + expect(entries).toHaveLength(2) + expect(entries[1].repeats).toBeUndefined() + }) + + it('does not coalesce across different kinds and stays bounded at capacity', () => { + const ring = createPtyDeliveryBreadcrumbRing(5, 1_000) + ring.record('gate-mark') + ring.record('gate-unmark') + expect(ring.snapshot()).toHaveLength(2) + + for (let i = 0; i < 20; i++) { + vi.advanceTimersByTime(2_000) + ring.record(`kind-${i}`) + } + const entries = ring.snapshot() + expect(entries).toHaveLength(5) + expect(entries[4].kind).toBe('kind-19') + }) + + it('snapshot returns copies and reset empties the ring', () => { + const ring = createPtyDeliveryBreadcrumbRing(5, 1_000) + ring.record('watchdog-heal', { healCount: 1 }) + const entries = ring.snapshot() + entries[0].kind = 'tampered' + expect(ring.snapshot()[0].kind).toBe('watchdog-heal') + ring.reset() + expect(ring.snapshot()).toHaveLength(0) + }) +}) + +describe('redactPtyIdForDiagnostics', () => { + it('keeps the @@ suffix and drops the path-bearing worktree prefix', () => { + expect(redactPtyIdForDiagnostics('/Users/someone/repo@@ab12cd34')).toBe('…@@ab12cd34') + }) + + it('truncates long ids without a separator and passes short ids through', () => { + expect(redactPtyIdForDiagnostics('pty-1')).toBe('pty-1') + const long = 'x'.repeat(40) + expect(redactPtyIdForDiagnostics(long)).toBe(`…${'x'.repeat(12)}`) + }) +}) diff --git a/src/shared/pty-delivery-diagnostics.ts b/src/shared/pty-delivery-diagnostics.ts new file mode 100644 index 00000000000..1b8b5111763 --- /dev/null +++ b/src/shared/pty-delivery-diagnostics.ts @@ -0,0 +1,107 @@ +/** + * One-paste terminal freeze diagnostics: shared shapes for the breadcrumb + * rings kept in BOTH processes and for the per-pty delivery table main embeds + * in its debug snapshot. The goal is that a single console command captures + * enough history + state to attribute any frozen-terminal report without + * asking the user for more logs. + */ + +export type PtyDeliveryBreadcrumb = { + atMs: number + kind: string + detail?: Record + /** Same-kind events within the coalesce window fold into this counter. */ + repeats?: number +} + +export type PtyDeliveryBreadcrumbRing = { + record: (kind: string, detail?: PtyDeliveryBreadcrumb['detail']) => void + snapshot: () => PtyDeliveryBreadcrumb[] + reset: () => void +} + +const BREADCRUMB_RING_CAPACITY = 100 +const BREADCRUMB_COALESCE_MS = 1_000 + +// Why a ring with same-kind coalescing: breadcrumbs record rare transitions, +// but a pathological loop (marker flood, gate flapping) must cost one array +// slot + counter bump per second, never unbounded memory or GC churn. +export function createPtyDeliveryBreadcrumbRing( + capacity = BREADCRUMB_RING_CAPACITY, + coalesceMs = BREADCRUMB_COALESCE_MS +): PtyDeliveryBreadcrumbRing { + let entries: PtyDeliveryBreadcrumb[] = [] + return { + record(kind, detail) { + const now = Date.now() + const last = entries.at(-1) + if (last && last.kind === kind && now - last.atMs < coalesceMs) { + last.repeats = (last.repeats ?? 1) + 1 + last.atMs = now + if (detail !== undefined) { + last.detail = detail + } + return + } + entries.push(detail === undefined ? { atMs: now, kind } : { atMs: now, kind, detail }) + if (entries.length > capacity) { + entries = entries.slice(entries.length - capacity) + } + }, + snapshot() { + return entries.map((entry) => ({ ...entry })) + }, + reset() { + entries = [] + } + } +} + +// Why redact: daemon session ids embed worktree paths +// (`${worktreeId}@@${shortUuid}`). The `@@` suffix is unique enough to +// correlate a pty across the renderer/main sections of one report without +// shipping the user's filesystem layout. +export function redactPtyIdForDiagnostics(id: string): string { + const separatorIdx = id.lastIndexOf('@@') + if (separatorIdx !== -1) { + return `…${id.slice(separatorIdx)}` + } + return id.length <= 12 ? id : `…${id.slice(-12)}` +} + +export type PtyPerPtyDeliveryDiagnostics = { + id: string + sentChars: number + ackedChars: number + inFlightChars: number + pendingChars: number + hidden: boolean + visible: boolean + active: boolean + msSinceLastSend: number | null + msSinceLastAck: number | null +} + +export type PtyMainDeliveryDiagnostics = { + appVersion: string + mainUptimeMs: number + windowFocused: boolean | null + windowVisible: boolean | null + windowMinimized: boolean | null + msSinceLastPowerSuspend: number | null + msSinceLastPowerResume: number | null + perPty: PtyPerPtyDeliveryDiagnostics[] + breadcrumbs: PtyDeliveryBreadcrumb[] +} + +export const EMPTY_PTY_MAIN_DELIVERY_DIAGNOSTICS: PtyMainDeliveryDiagnostics = { + appVersion: '', + mainUptimeMs: 0, + windowFocused: null, + windowVisible: null, + windowMinimized: null, + msSinceLastPowerSuspend: null, + msSinceLastPowerResume: null, + perPty: [], + breadcrumbs: [] +} diff --git a/src/shared/pty-model-restore-marker.ts b/src/shared/pty-model-restore-marker.ts new file mode 100644 index 00000000000..a182226a0d0 --- /dev/null +++ b/src/shared/pty-model-restore-marker.ts @@ -0,0 +1,19 @@ +/** + * Out-of-band `pty:modelRestoreNeeded` (main → renderer) payload. + * + * Why a dedicated channel instead of an in-band sentinel chunk: an empty + * `pty:data` chunk is indistinguishable from a real chunk whose bytes were + * entirely stripped by renderer-side OSC-9999 cleaning, so an in-band marker + * could spuriously trigger full snapshot restores on visible panes. The + * marker is delivery machinery, not PTY data — remote-runtime transports + * never see it. + */ +export type PtyModelRestoreReason = 'hidden-drop' | 'unhide' | 'pending-cap' | 'delivery-heal' + +export type PtyModelRestoreNeededEvent = { + id: string + reason: PtyModelRestoreReason + /** Main's PTY output sequence at emit time — everything at or before this + * point is only recoverable from the model snapshot. */ + markerSeq?: number +} diff --git a/src/shared/pty-renderer-delivery-health.ts b/src/shared/pty-renderer-delivery-health.ts new file mode 100644 index 00000000000..987759ec464 --- /dev/null +++ b/src/shared/pty-renderer-delivery-health.ts @@ -0,0 +1,45 @@ +/** + * `pty:reportRendererDeliveryState` (renderer → main, invoke) payload/reply. + * + * Why invoke and renderer-initiated: field evidence (v1.4.121-rc.0 snapshot, + * 2026-07-06) proved a wedge where main→renderer push delivery (`pty:data`, + * `pty:requestDeliveryResync`, `pty:modelRestoreNeeded`) goes silently dead + * while renderer→main invoke IPC stays healthy. Every push-initiated recovery + * path (cumulative-ACK self-heal, solicited resync, wake relay) is unreachable + * in that state, so the delivery watchdog reports and heals over invoke — the + * direction proven alive. + */ +export type PtyRendererDeliveryStateReport = { + /** Cumulative chars received per PTY, counted at dispatcher enqueue — + * BEFORE parse-deferred ACK crediting. The gap between main's sentChars + * and this total is bytes provably lost in the push channel, distinct + * from bytes received but still queued for parsing. */ + receivedCharsByPty: Record + /** Cumulative processed (ACK-credited) chars per PTY — same totals the + * ACK path and resync response carry; merging them here is a free extra + * repair lane for the lost-ACK variant. */ + processedCharsByPty: Record + /** Set on the confirming tick: main may write off provably-lost bytes and + * answer with restore markers for the renderer to route locally. */ + heal?: boolean + /** `ipcRenderer.listenerCount('pty:data')` at heal time — discriminates + * "listener detached" from "channel dead" in field logs. */ + rendererPtyDataListenerCount?: number | null +} + +export type PtyDeliveryWriteOff = { + id: string + /** Main's PTY output sequence at write-off — everything at or before this + * is only recoverable from the model snapshot (pulled restore marker). */ + markerSeq?: number + writtenOffChars: number +} + +export type PtyRendererDeliveryHealthReply = { + inFlightTotalChars: number + inFlightPtyCount: number + /** null = no ACK received since main-side counters were (re)created. */ + msSinceLastAck: number | null + /** Present only on a heal report that actually wrote off lost bytes. */ + writtenOff?: PtyDeliveryWriteOff[] +} diff --git a/src/renderer/src/components/terminal-pane/bell-detector.test.ts b/src/shared/terminal-bell-detector.test.ts similarity index 93% rename from src/renderer/src/components/terminal-pane/bell-detector.test.ts rename to src/shared/terminal-bell-detector.test.ts index bd48ba98381..8f1ce7b0bfc 100644 --- a/src/renderer/src/components/terminal-pane/bell-detector.test.ts +++ b/src/shared/terminal-bell-detector.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createBellDetector } from './bell-detector' +import { createBellDetector } from './terminal-bell-detector' describe('createBellDetector', () => { it('skips ANSI chunks without losing later real bells', () => { diff --git a/src/renderer/src/components/terminal-pane/bell-detector.ts b/src/shared/terminal-bell-detector.ts similarity index 82% rename from src/renderer/src/components/terminal-pane/bell-detector.ts rename to src/shared/terminal-bell-detector.ts index 1afdb51e28c..cce08a7dbd0 100644 --- a/src/renderer/src/components/terminal-pane/bell-detector.ts +++ b/src/shared/terminal-bell-detector.ts @@ -2,6 +2,10 @@ * Stateful BEL detector that correctly ignores BEL (0x07) bytes * occurring inside OSC escape sequences. * + * Shared between the renderer transport processor and main's per-PTY + * side-effect tracker (docs/reference/terminal-side-effect-authority.md): + * bell semantics must not drift between the two parsing authorities. + * * Why stateful: PTY data arrives in arbitrary chunks, so an OSC sequence * may span multiple calls. The detector tracks in-progress escape state * across invocations so a BEL used as an OSC terminator is never @@ -17,7 +21,10 @@ * that ended mid-escape does not leak into the next stream. */ export type BellDetector = { - chunkContainsBell(data: string): boolean + /** `hints.containsOscIntroducer` lets a caller that already scanned for + * `\x1b]` (the title-extraction gate) share the result instead of paying + * a second includes() pass per chunk on the hot path. */ + chunkContainsBell(data: string, hints?: { containsOscIntroducer?: boolean }): boolean reset(): void } @@ -27,11 +34,11 @@ export function createBellDetector(): BellDetector { let pendingOscEscape = false return { - chunkContainsBell(data: string): boolean { + chunkContainsBell(data: string, hints: { containsOscIntroducer?: boolean } = {}): boolean { if (!inOsc && !pendingEscape && !data.includes('\x07')) { // Why: CSI/plain chunks with no BEL and no OSC start cannot affect // bell state; avoid walking every byte of normal terminal output. - if (!data.includes('\x1b]')) { + if (!(hints.containsOscIntroducer ?? data.includes('\x1b]'))) { pendingEscape = data.endsWith('\x1b') return false } diff --git a/src/renderer/src/lib/terminal-github-pr-link-detector.test.ts b/src/shared/terminal-github-pr-link-detector.test.ts similarity index 100% rename from src/renderer/src/lib/terminal-github-pr-link-detector.test.ts rename to src/shared/terminal-github-pr-link-detector.test.ts diff --git a/src/renderer/src/lib/terminal-github-pr-link-detector.ts b/src/shared/terminal-github-pr-link-detector.ts similarity index 91% rename from src/renderer/src/lib/terminal-github-pr-link-detector.ts rename to src/shared/terminal-github-pr-link-detector.ts index bd3cedbcc5b..ee7d92e60c6 100644 --- a/src/renderer/src/lib/terminal-github-pr-link-detector.ts +++ b/src/shared/terminal-github-pr-link-detector.ts @@ -1,3 +1,12 @@ +/** + * Chunk-boundary-safe GitHub PR URL scan over PTY output. + * + * Why shared: terminal-side-effect-authority.md (slice 3) makes main emit + * `pr-link` facts from its per-PTY tracker for local/SSH PTYs, while the + * renderer keeps byte-scanning for remote-runtime PTYs and the kill-switch-off + * path. Both paths must share the carry/dedupe semantics or links split across + * chunks would resolve differently per authority mode. + */ import type { RepoSlug } from './github-links' import { parseGitHubIssueOrPRLink } from './github-links' diff --git a/src/shared/terminal-osc133-command-finished.ts b/src/shared/terminal-osc133-command-finished.ts new file mode 100644 index 00000000000..e4b9cdb8129 --- /dev/null +++ b/src/shared/terminal-osc133-command-finished.ts @@ -0,0 +1,108 @@ +/** + * Chunk-boundary-safe OSC 133;D (command finished) scanner. + * + * Why shared: terminal-side-effect-authority.md (slice 3) makes main emit + * `command-finished` facts from its per-PTY tracker for local/SSH PTYs, while + * the renderer keeps byte-parsing for remote-runtime PTYs and the + * kill-switch-off path. The carry semantics (split prefixes, BEL/ST + * terminators, best-effort exit codes) must be identical in both. + */ + +type OscTerminator = { + index: number + length: number +} + +const OSC_133_PREFIX = '\x1b]133;' +const MAX_OSC_CARRY_LENGTH = 4096 + +function findOscTerminator(data: string, startIndex: number): OscTerminator | null { + const bel = data.indexOf('\x07', startIndex) + const st = data.indexOf('\x1b\\', startIndex) + + if (bel === -1 && st === -1) { + return null + } + if (bel !== -1 && (st === -1 || bel < st)) { + return { index: bel, length: 1 } + } + return { index: st, length: 2 } +} + +function parseBestEffortExitCode(value: string | undefined): number | null { + if (!value) { + return null + } + const parsed = Number.parseInt(value, 10) + return Number.isNaN(parsed) ? null : parsed +} + +function findPrefixCarry(data: string): string { + const maxCarryLength = Math.min(data.length, OSC_133_PREFIX.length - 1) + for (let length = maxCarryLength; length > 0; length -= 1) { + const suffix = data.slice(data.length - length) + if (OSC_133_PREFIX.startsWith(suffix)) { + return suffix + } + } + return '' +} + +export type Osc133CommandFinishedScanner = { + /** Feed one raw PTY chunk; fires once per complete OSC 133;D sequence. */ + scan: (data: string) => void + /** Drop the cross-chunk carry (transport teardown / parser reset). */ + reset: () => void +} + +export function createOsc133CommandFinishedScanner( + onCommandFinished: (bestEffortExitCode: number | null) => void, + /** OSC 133;C — the shell exec'd a command; the pane's foreground changed. */ + onCommandStarted?: () => void +): Osc133CommandFinishedScanner { + let carry = '' + + const handleOsc133 = (payload: string): void => { + const [sequence, exitCode] = payload.split(';') + if (sequence === 'C') { + onCommandStarted?.() + return + } + if (sequence === 'D') { + onCommandFinished(parseBestEffortExitCode(exitCode)) + } + } + + const scan = (data: string): void => { + let combined = carry + data + carry = '' + + while (combined.length > 0) { + const start = combined.indexOf(OSC_133_PREFIX) + if (start === -1) { + carry = findPrefixCarry(combined) + return + } + + 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) + } + return + } + + handleOsc133(combined.slice(payloadStart, terminator.index)) + combined = combined.slice(terminator.index + terminator.length) + } + } + + return { + scan, + reset() { + carry = '' + } + } +} diff --git a/src/shared/terminal-output-side-effects.test.ts b/src/shared/terminal-output-side-effects.test.ts new file mode 100644 index 00000000000..d9da1b23928 --- /dev/null +++ b/src/shared/terminal-output-side-effects.test.ts @@ -0,0 +1,222 @@ +// Why: slice 3 of terminal-side-effect-authority.md adds OSC 133;D +// command-finished and GitHub pr-link scanning to the shared tracker so main +// emits those facts for local/SSH PTYs. These tests pin the chunk-boundary +// carry, exit-code best-effort, dedupe, and synthetic-frame isolation rules. +import { describe, expect, it } from 'vitest' +import { + createTerminalTitleTracker, + type TerminalTitleTrackerCallbacks +} from './terminal-output-side-effects' + +const ESC = '\x1b' +const BEL = '\x07' +const ST = `${ESC}\\` + +type RecordedEvent = + | ['title', string] + | ['bell'] + | ['finished', number | null] + | ['pr', string, number] + | ['2031-subscribe'] + +function createRecordingTracker(overrides: TerminalTitleTrackerCallbacks = {}): { + events: RecordedEvent[] + tracker: ReturnType +} { + const events: RecordedEvent[] = [] + const tracker = createTerminalTitleTracker({ + onTitle: (normalized) => events.push(['title', normalized]), + onBell: () => events.push(['bell']), + onCommandFinished: (exitCode) => events.push(['finished', exitCode]), + onPrLink: (link) => events.push(['pr', link.url, link.number]), + onMode2031Subscribe: () => events.push(['2031-subscribe']), + ...overrides + }) + return { events, tracker } +} + +describe('createTerminalTitleTracker command-finished facts', () => { + it('emits command-finished with best-effort exit codes', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`before${ESC}]133;A${BEL}prompt${ESC}]133;B${BEL}`) + tracker.handleChunk(`${ESC}]133;C${BEL}running${ESC}]133;D;0${BEL}`) + tracker.handleChunk(`${ESC}]133;D;130${BEL}`) + tracker.handleChunk(`${ESC}]133;D;not-a-number${BEL}`) + tracker.handleChunk(`${ESC}]133;D${BEL}`) + + expect(events).toEqual([ + ['finished', 0], + ['finished', 130], + ['finished', null], + ['finished', null] + ]) + }) + + it('detects OSC 133;D split across chunk boundaries (BEL and ST terminated)', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`chunk${ESC}]133`) + tracker.handleChunk(';D;1') + expect(events).toEqual([]) + tracker.handleChunk(`30${BEL}rest`) + tracker.handleChunk(`${ESC}]133;D;7${ST}`) + + expect(events).toEqual([ + ['finished', 130], + ['finished', 7] + ]) + }) + + it('orders chunk facts titles → command-finished → bell', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}]0;zsh${BEL}${ESC}]133;D;0${BEL}done${BEL}`) + + expect(events).toEqual([['title', 'zsh'], ['finished', 0], ['bell']]) + }) +}) + +describe('createTerminalTitleTracker pr-link facts', () => { + it('emits one fact per PR URL including multiple links in one chunk', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk( + 'see https://github.com/acme/orca/pull/42 and https://github.com/acme/orca/pull/43 \r\n' + ) + + expect(events).toEqual([ + ['pr', 'https://github.com/acme/orca/pull/42', 42], + ['pr', 'https://github.com/acme/orca/pull/43', 43] + ]) + }) + + it('waits for a boundary when a URL splits across chunks and dedupes repeats', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk('PR: https://github.com/acme/orca/pull/4') + expect(events).toEqual([]) + tracker.handleChunk('2\r\n') + tracker.handleChunk('again https://github.com/acme/orca/pull/42\r\n') + + expect(events).toEqual([['pr', 'https://github.com/acme/orca/pull/42', 42]]) + }) + + it('skips the 133/URL scans entirely when no consumer is registered', () => { + // Mirrors headless serve: no pty:sideEffect consumer means no callbacks, + // so the scanners must not be created (no carry state, no scan cost). + const titles: string[] = [] + const tracker = createTerminalTitleTracker({ + onTitle: (normalized) => titles.push(normalized) + }) + + tracker.handleChunk(`${ESC}]133;D;0${BEL}https://github.com/acme/orca/pull/42\r\n`) + + expect(titles).toEqual([]) + }) +}) + +describe('createTerminalTitleTracker 2031-subscribe facts', () => { + it('emits a fact per chunk containing a DECSET 2031 subscribe, before the bell', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?2031hready${BEL}`) + + expect(events).toEqual([['2031-subscribe'], ['bell']]) + }) + + it('detects a subscribe split across chunk boundaries', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?20`) + tracker.handleChunk('31h') + + expect(events).toEqual([['2031-subscribe']]) + }) + + it('ignores DECSET 2031 unsubscribes', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`${ESC}[?2031l`) + + expect(events).toEqual([]) + }) + + it('skips the 2031 scan entirely when no consumer is registered', () => { + const { events, tracker } = createRecordingTracker({ onMode2031Subscribe: undefined }) + + tracker.handleChunk(`${ESC}[?2031h`) + + expect(events).toEqual([]) + }) +}) + +describe('createTerminalTitleTracker synthetic-frame isolation', () => { + it('never feeds synthetic frames to the 133/PR scanners', () => { + const { events, tracker } = createRecordingTracker() + + tracker.applySyntheticTitleFrame( + `${ESC}]0;⠋ Cursor Agent${BEL}${ESC}]133;D;0${BEL}https://github.com/acme/orca/pull/42\r\n` + ) + + expect(events).toEqual([['title', '⠋ Cursor Agent']]) + }) + + it('keeps a split 133 carry intact across an interleaved synthetic frame', () => { + const { events, tracker } = createRecordingTracker() + + tracker.handleChunk(`out${ESC}]133;D;`) + // An 80ms spinner tick lands between the two halves of the real OSC. + tracker.applySyntheticTitleFrame(`${ESC}]0;⠋ Cursor Agent${BEL}`) + tracker.handleChunk(`130${BEL}`) + + expect(events).toEqual([ + ['title', '⠋ Cursor Agent'], + ['finished', 130] + ]) + }) +}) + +describe('createTerminalTitleTracker transient-fact scanning suppression', () => { + it('skips bell/133/pr-link/2031 while suppressed but keeps title processing', () => { + const { events, tracker } = createRecordingTracker() + + tracker.setTransientFactScanningSuppressed(true) + tracker.handleChunk( + `${ESC}]0;zsh${BEL}${ESC}]133;D;0${BEL}ding${BEL}https://github.com/acme/orca/pull/42\r\n${ESC}[?2031h` + ) + + expect(events).toEqual([['title', 'zsh']]) + }) + + it('resets cross-chunk carry on un-suppress so a pre-gap half-open OSC cannot swallow bells', () => { + const { events, tracker } = createRecordingTracker() + + // The OSC terminator (and everything after) is lost in a delivery gap + // while scanning is suppressed. + tracker.handleChunk(`${ESC}]0;my long title without terminator`) + tracker.setTransientFactScanningSuppressed(true) + tracker.setTransientFactScanningSuppressed(false) + // Post-gap: a real standalone bell. A stale "inside OSC" state would + // swallow it. + tracker.handleChunk(`done${BEL}`) + + expect(events).toEqual([['bell']]) + }) + + it('a handoff scan seed re-primes a split sequence so its fact fires once, without a phantom bell', () => { + const { events, tracker } = createRecordingTracker() + + // The 133 intro was consumed before scan authority moved away; the carry + // is reset on un-suppress. The transport hands back the emulator's + // partial escape tail as a seed, so the continuation completes normally: + // one command-finished, and its terminator BEL is NOT a standalone bell. + tracker.handleChunk(`out${ESC}]133;D;`) + tracker.setTransientFactScanningSuppressed(true) + tracker.setTransientFactScanningSuppressed(false) + tracker.handleChunk(`${ESC}]133;D;`, { titleScanData: '' }) + tracker.handleChunk(`130${BEL}`) + + expect(events).toEqual([['finished', 130]]) + }) +}) diff --git a/src/shared/terminal-output-side-effects.ts b/src/shared/terminal-output-side-effects.ts new file mode 100644 index 00000000000..02bf28efa80 --- /dev/null +++ b/src/shared/terminal-output-side-effects.ts @@ -0,0 +1,326 @@ +/** + * Shared per-PTY terminal title side-effect tracking — the parser core behind + * both the renderer transport (`createPtyOutputProcessor`) and main's + * per-PTY tracker in `OrcaRuntimeService.onPtyData`. + * + * Why shared: docs/reference/terminal-side-effect-authority.md makes main the + * side-effect parser for every PTY whose bytes transit local main. Title + * semantics (all-titles ordering, cursor-agent literal drop, normalization, + * stale-working-title clearing) must not drift between the two paths. + */ + +import { + clearWorkingIndicators, + createAgentStatusTracker, + detectAgentStatusFromTitle, + extractAllOscTitles, + isCursorNativeAgentTitle, + normalizeTerminalTitle +} from './agent-detection' +import { createBellDetector } from './terminal-bell-detector' +import { scanMode2031Sequences } from './terminal-color-scheme-protocol' +import { + createTerminalGitHubPRLinkDetector, + type TerminalGitHubPRLink +} from './terminal-github-pr-link-detector' +import { createOsc133CommandFinishedScanner } from './terminal-osc133-command-finished' + +/** Ms of title-less output after a working title before it is cleared. */ +export const STALE_WORKING_TITLE_TIMEOUT_MS = 3000 + +// Braille spinner frame glyphs (U+2800–U+28FF) — the decorative animation +// class agents rotate through while working. Mirrors the range +// clearWorkingIndicators strips in agent-detection.ts. +// eslint-disable-next-line no-control-regex -- intentional unicode range +const BRAILLE_SPINNER_RE = /[\u2800-\u28FF]/g + +/** + * Strip decorative braille spinner frame glyphs for change comparisons. + * Two working titles that differ only by the animation frame (e.g. + * "⠋ Cursor Agent" vs "⠙ Cursor Agent") compare equal after stripping — + * the gate consumers use to avoid fan-out churn on spinner ticks. + */ +export function stripBrailleSpinnerGlyphs(title: string): string { + return title.replace(BRAILLE_SPINNER_RE, '').trim() +} + +/** Provenance for title/idle facts. `staleWorkingTitleClear` marks facts + * synthesized by the 3s stale-working-title timer rather than observed + * bytes — consumers must not treat them as genuine task completions. */ +export type TerminalTitleFactMeta = { + staleWorkingTitleClear?: boolean +} + +export type TerminalTitleTrackerCallbacks = { + /** + * Fired once per observed OSC title, in byte order — including the + * synthesized cleared title when the stale-working timer fires. + */ + onTitle?: (normalizedTitle: string, rawTitle: string, meta?: TerminalTitleFactMeta) => void + onAgentBecameIdle?: (title: string, meta?: TerminalTitleFactMeta) => void + onAgentBecameWorking?: () => void + onAgentExited?: () => void + /** + * Fired once per chunk containing a real BEL (OSC-aware, escape state kept + * across chunks), after the chunk's title facts — the renderer drain order. + */ + onBell?: () => void + /** + * Fired per complete OSC 133;D (chunk-boundary-safe) with the sequence's + * best-effort exit code — mirrors the renderer terminal-command-lifecycle + * semantics so the fact path drops stale agent rows exactly like byte mode. + */ + onCommandFinished?: (bestEffortExitCode: number | null) => void + /** Fired once per newly observed GitHub PR URL (chunk-boundary-safe, + * deduplicated per tracker like the renderer detector). */ + onPrLink?: (link: TerminalGitHubPRLink) => void + /** + * Fired per chunk containing a DECSET 2031 subscribe (chunk-boundary-safe). + * Lets hidden-delivery-gated renderer views answer the color-scheme query + * without byte access; the reply itself stays with the view. + */ + onMode2031Subscribe?: () => void +} + +export type TerminalTitleTracker = { + /** Feed one raw PTY chunk; titles are applied synchronously in byte order. */ + handleChunk: (data: string, options?: { titleScanData?: string }) => void + /** + * Apply a main-fabricated OSC title/BEL frame (agent hook spinner frames). + * Parsed statelessly — never through the chunk bell detector — so a + * synthetic tick landing between two real chunks that split an OSC cannot + * corrupt the cross-chunk escape state into phantom or swallowed bells. + */ + applySyntheticTitleFrame: (frame: string) => void + /** + * Seed the last-known title for a tracker created mid-session (app relaunch + * with persisted/snapshot titles). No-ops once any title has been observed + * or seeded — live state always wins. Fires no callbacks. + */ + seedInitialTitle: (rawTitle: string) => void + /** Last title surfaced through onTitle, after normalization. */ + getLastNormalizedTitle: () => string | null + /** + * While suppressed, handleChunk skips the four transient-fact scanners + * (bell/133/pr-link/2031) — a thinning transport holds scan authority for + * this PTY and relays their facts itself; feeding the (possibly gapped) + * delivered bytes here would mint phantom or duplicate facts. Title + * processing is unaffected. Un-suppressing resets the scanners' cross-chunk + * carry: their last-fed byte predates the gap. + */ + setTransientFactScanningSuppressed: (suppressed: boolean) => void + /** Cancel the stale-title timer and clear accumulated tracker state. */ + dispose: () => void +} + +export function createTerminalTitleTracker( + callbacks: TerminalTitleTrackerCallbacks, + options: { initialTitle?: string } = {} +): TerminalTitleTracker { + const { + onTitle, + onAgentBecameIdle, + onAgentBecameWorking, + onAgentExited, + onBell, + onCommandFinished, + onPrLink, + onMode2031Subscribe + } = callbacks + const bellDetector = onBell ? createBellDetector() : null + // Why: created only when a consumer exists (like the bell detector) so + // headless serve never pays the per-chunk 133/URL scans. + const commandFinishedScanner = onCommandFinished + ? createOsc133CommandFinishedScanner(onCommandFinished) + : null + let prLinkDetector = onPrLink ? createTerminalGitHubPRLinkDetector() : null + let transientFactScanningSuppressed = false + // Why: a DECSET 2031 subscribe can be split across PTY chunks; carry a + // bounded tail between chunks so split sequences still match. + let mode2031ScanTail = '' + // Why: seed both the emitted-title memory (stale-title probe) and the agent + // tracker so a mid-session tracker behaves as if it had observed the pane's + // last live title — parity with the renderer processor's seeding. + let lastEmittedTitle: string | null = + options.initialTitle !== undefined ? normalizeTerminalTitle(options.initialTitle) : null + let staleTitleTimer: ReturnType | null = null + // Why: set while the stale timer's cleared title flows through the agent + // tracker so the resulting idle callback carries timer provenance — the + // renderer must not turn a stale clear into a task-complete notification. + let applyingStaleWorkingTitleClear = false + const agentTracker = + onAgentBecameIdle || onAgentBecameWorking || onAgentExited + ? createAgentStatusTracker( + (title) => { + onAgentBecameIdle?.( + title, + applyingStaleWorkingTitleClear ? { staleWorkingTitleClear: true } : undefined + ) + }, + onAgentBecameWorking, + onAgentExited, + options.initialTitle + ) + : null + + function clearStaleTitleTimer(): void { + if (staleTitleTimer) { + clearTimeout(staleTitleTimer) + staleTitleTimer = null + } + } + + function applyObservedTitle(rawTitle: string): void { + // Why: cursor-agent re-emits its bare native title many times per turn + // while still working; letting it through would stomp Orca's synthesized + // "⠋ Cursor Agent" spinner state back to agentless within a second. + if (isCursorNativeAgentTitle(rawTitle)) { + return + } + lastEmittedTitle = normalizeTerminalTitle(rawTitle) + onTitle?.(lastEmittedTitle, rawTitle) + agentTracker?.handleTitle(rawTitle) + } + + function handleChunk(data: string, options: { titleScanData?: string } = {}): void { + const titleScanData = options.titleScanData ?? data + // Why: this is main's per-chunk hot path — scan for the OSC introducer + // once and share the result with the bell detector's fast-path gate. + const containsOscIntroducer = data.includes('\x1b]') + // Why: the bell detector must consume EVERY chunk so OSC sequences that + // span chunk boundaries keep their escape state, even when the chunk has + // no title. The fact itself is surfaced after the chunk's titles, the + // renderer drain's order (payloads → titles → bell). While suppressed it + // must consume NONE: the delivered bytes may be gapped. + const containsBell = + bellDetector && !transientFactScanningSuppressed + ? bellDetector.chunkContainsBell(data, { containsOscIntroducer }) + : false + // Why: feed EVERY OSC title in the chunk in byte order, never just the + // last one. node-pty plus the main-process batch window commonly coalesce + // multiple title updates into a single payload; a last-title reader drops + // intra-chunk working→idle transitions (issue #1083). + const titles = titleScanData.includes('\x1b]') ? extractAllOscTitles(titleScanData) : [] + if (titles.length > 0) { + clearStaleTitleTimer() + for (const title of titles) { + applyObservedTitle(title) + } + } else if ( + // Why: agents that exit without resetting their title leave a stale + // working spinner behind. Any title-less output while the last title + // classifies as working restarts a 3s timer that rewrites the title to + // its cleared form — the renderer transport's stale-title semantics. + data.length > 0 && + lastEmittedTitle !== null && + detectAgentStatusFromTitle(lastEmittedTitle) === 'working' + ) { + clearStaleTitleTimer() + staleTitleTimer = setTimeout(() => { + staleTitleTimer = null + if (lastEmittedTitle && detectAgentStatusFromTitle(lastEmittedTitle) === 'working') { + const cleared = clearWorkingIndicators(lastEmittedTitle) + lastEmittedTitle = cleared + // Why: tag timer-synthesized facts. Main's timer is unthrottled + // (unlike the renderer timers that previously damped this path in + // hidden windows), so a merely-paused agent must be distinguishable + // from a genuine working→idle completion downstream. + applyingStaleWorkingTitleClear = true + try { + onTitle?.(cleared, cleared, { staleWorkingTitleClear: true }) + agentTracker?.handleTitle(cleared) + } finally { + applyingStaleWorkingTitleClear = false + } + } + }, STALE_WORKING_TITLE_TIMEOUT_MS) + } + // Per-chunk fact order: titles → command-finished → pr-link → + // 2031-subscribe → bell. The bell stays last (the renderer drain's + // order); the byte scanners keep their own cross-chunk carry so split + // sequences/URLs still resolve. + if (!transientFactScanningSuppressed) { + commandFinishedScanner?.scan(data) + if (prLinkDetector) { + for (const link of prLinkDetector(data)) { + onPrLink?.(link) + } + } + if (onMode2031Subscribe) { + const mode2031Scan = scanMode2031Sequences(mode2031ScanTail, data) + mode2031ScanTail = mode2031Scan.tail + if (mode2031Scan.subscribe) { + onMode2031Subscribe() + } + } + } + if (containsBell) { + onBell?.() + } + } + + function applySyntheticTitleFrame(frame: string): void { + // Why: synthetic frames have an exact main-fabricated shape, so they are + // parsed statelessly here. Feeding them through handleChunk would run the + // stateful bell detector: a tick landing while a REAL OSC is split across + // two chunks would consume the pending escape state, minting a phantom + // bell from the continuation chunk or swallowing a real one. + const titles = extractAllOscTitles(frame) + if (titles.length > 0) { + clearStaleTitleTimer() + for (const title of titles) { + applyObservedTitle(title) + } + } + // The deliberate permission BEL rides outside the OSC title sequence. A + // FRESH detector instance keeps the OSC-terminator-vs-bell semantics + // while guaranteeing zero interaction with the chunk detector's state. + // Synthetic frames never reach the 133/PR-link scanners: fabricated bytes + // contain neither and must not perturb their cross-chunk carry state. + if (onBell && createBellDetector().chunkContainsBell(frame)) { + onBell() + } + } + + return { + handleChunk, + applySyntheticTitleFrame, + seedInitialTitle(rawTitle: string): void { + // Why: the cursor-agent literal drop applies to seeds too — restoring + // the bare native title would stomp synthesized spinner state exactly + // like emitting it live would. + if (lastEmittedTitle !== null || !rawTitle || isCursorNativeAgentTitle(rawTitle)) { + return + } + lastEmittedTitle = normalizeTerminalTitle(rawTitle) + agentTracker?.seedTitle(rawTitle) + }, + getLastNormalizedTitle: () => lastEmittedTitle, + setTransientFactScanningSuppressed(suppressed: boolean): void { + if (suppressed === transientFactScanningSuppressed) { + return + } + transientFactScanningSuppressed = suppressed + if (!suppressed) { + // Cross-chunk carry predates the suppressed (gapped) span — a stale + // half-open OSC would swallow real bells; a stale 133/2031 tail or + // URL fragment would mint phantom facts. The PR-link dedup memory is + // lost with the recreate; a re-printed link may re-fire (consumers + // treat pr-link as a latest-association update). + bellDetector?.reset() + commandFinishedScanner?.reset() + mode2031ScanTail = '' + if (prLinkDetector) { + prLinkDetector = createTerminalGitHubPRLinkDetector() + } + } + }, + dispose(): void { + clearStaleTitleTimer() + agentTracker?.reset() + bellDetector?.reset() + commandFinishedScanner?.reset() + mode2031ScanTail = '' + } + } +} diff --git a/src/shared/terminal-reply-query-extraction.ts b/src/shared/terminal-reply-query-extraction.ts new file mode 100644 index 00000000000..0ff95b943aa --- /dev/null +++ b/src/shared/terminal-reply-query-extraction.ts @@ -0,0 +1,160 @@ +// Terminal-output scanning for reply-eliciting query sequences (DSR/CPR, +// DA1/DA2, DECRQM, XTGETTCAP-adjacent CSI queries, OSC 10/11 color probes). +// Shared because both sides must salvage queries out of bytes they are about +// 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 { parseTerminalOscColorQuery } from './terminal-osc-color-reply' + +export const HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS = 64 + +export type ExtractedRendererQueryData = { + statelessQueryData: string + statefulQueryData: string + oscColorQueryData: string + pending: string +} + +export function extractHiddenStartupRendererQueryData( + data: string, + pending: string +): ExtractedRendererQueryData { + const input = pending + data + let statelessQueryData = '' + let statefulQueryData = '' + let oscColorQueryData = '' + let offset = 0 + + while (offset < input.length) { + const candidateIndex = input.indexOf('\x1b', offset) + if (candidateIndex === -1) { + break + } + if (candidateIndex + 1 >= input.length) { + return { + statelessQueryData, + statefulQueryData, + oscColorQueryData, + pending: input.slice(candidateIndex) + } + } + if (input.startsWith('\x1b[', candidateIndex)) { + const finalByteIndex = findCsiFinalByteIndex(input, candidateIndex + 2) + if (finalByteIndex === -1) { + return { + statelessQueryData, + statefulQueryData, + oscColorQueryData, + pending: input.slice( + candidateIndex, + candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS + ) + } + } + const sequence = input.slice(candidateIndex, finalByteIndex + 1) + if (isStatelessRendererReplyCsiQuery(sequence)) { + statelessQueryData += sequence + } else if (isStatefulRendererReplyCsiQuery(sequence)) { + statefulQueryData += sequence + } + offset = finalByteIndex + 1 + continue + } + + if (input.startsWith('\x1b]', candidateIndex)) { + const query = parseTerminalOscColorQuery(input, candidateIndex) + if (query.kind === 'partial') { + return { + statelessQueryData, + statefulQueryData, + oscColorQueryData, + pending: input.slice( + candidateIndex, + candidateIndex + HIDDEN_STARTUP_RENDERER_QUERY_PENDING_CHARS + ) + } + } + if (query.kind === 'none') { + offset = candidateIndex + 2 + continue + } + oscColorQueryData += input.slice(candidateIndex, query.endIndex) + offset = query.endIndex + continue + } + + if (parseTerminalOscColorQuery(input, candidateIndex).kind === 'partial') { + return { + statelessQueryData, + statefulQueryData, + oscColorQueryData, + pending: input.slice(candidateIndex) + } + } + + { + offset = candidateIndex + 1 + continue + } + } + + return { statelessQueryData, statefulQueryData, oscColorQueryData, pending: '' } +} + +export function containsCsiRendererQuery(data: string): boolean { + let offset = data.indexOf('\x1b[') + while (offset !== -1) { + const finalByteIndex = findCsiFinalByteIndex(data, offset + 2) + if (finalByteIndex === -1) { + return false + } + const sequence = data.slice(offset, finalByteIndex + 1) + if (isStatelessRendererReplyCsiQuery(sequence) || isStatefulRendererReplyCsiQuery(sequence)) { + return true + } + offset = data.indexOf('\x1b[', finalByteIndex + 1) + } + return false +} + +export function containsStatefulRendererQuery(data: string): boolean { + let offset = data.indexOf('\x1b[') + while (offset !== -1) { + const finalByteIndex = findCsiFinalByteIndex(data, offset + 2) + if (finalByteIndex === -1) { + return false + } + const sequence = data.slice(offset, finalByteIndex + 1) + if (isStatefulRendererReplyCsiQuery(sequence)) { + return true + } + offset = data.indexOf('\x1b[', finalByteIndex + 1) + } + return false +} + +export function findCsiFinalByteIndex(data: string, offset: number): number { + for (let index = offset; index < data.length; index++) { + const code = data.charCodeAt(index) + if (code >= 0x40 && code <= 0x7e) { + return index + } + } + return -1 +} + +export function isStatelessRendererReplyCsiQuery(sequence: string): boolean { + if (sequence.endsWith('c')) { + return true + } + return ( + sequence === '\x1b[5n' || + sequence === '\x1b[>q' || + sequence === '\x1b[14t' || + sequence === '\x1b[16t' + ) +} + +export function isStatefulRendererReplyCsiQuery(sequence: string): boolean { + return sequence === '\x1b[6n' || (sequence.startsWith('\x1b[?') && sequence.endsWith('$p')) +} diff --git a/src/shared/terminal-restore-parity-fixture.ts b/src/shared/terminal-restore-parity-fixture.ts new file mode 100644 index 00000000000..5444b701ec2 --- /dev/null +++ b/src/shared/terminal-restore-parity-fixture.ts @@ -0,0 +1,284 @@ +// Renderer-parity headless terminal + snapshot/replay mirrors for the garble +// differential fuzz suites (headless-emulator-fidelity.fuzz.test.ts and +// hidden-reveal-reconciliation.fuzz.test.ts). Lives in src/shared because the +// main-side and renderer-side fuzz suites both consume it and neither +// tsconfig (tsconfig.node.json / tsconfig.tc.web.json) includes the other +// side's sources. +import { Terminal } from '@xterm/headless' +import { SerializeAddon } from '@xterm/addon-serialize' +import { Unicode11Addon } from '@xterm/addon-unicode11' +import { activateOrcaTerminalUnicodeProvider } from './terminal-unicode-provider' +import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT } from './terminal-scrollback-policy' +import { + readSavedCursorRegister, + serializeWithAbsoluteCursor +} from './terminal-serialize-absolute-cursor' + +export type ParityTerminal = { + terminal: Terminal + serializeAddon: SerializeAddon +} + +/** Builds an @xterm/headless terminal configured exactly like the renderer + * pane where buffer state is concerned: scrollback + kitty vtExtensions from + * buildDefaultTerminalOptions (pane-terminal-options.ts), Unicode11Addon + * (pane-dom-creation.ts) and the Orca ZWJ provider (pane-lifecycle.ts). + * Font/cursor/render options are omitted — they never alter buffer cells. */ +export function createRendererParityTerminal(dims: { cols: number; rows: number }): ParityTerminal { + const terminal = new Terminal({ + cols: dims.cols, + rows: dims.rows, + scrollback: DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT, + allowProposedApi: true, + vtExtensions: { kittyKeyboard: true } + }) + const serializeAddon = new SerializeAddon() + terminal.loadAddon(serializeAddon) + terminal.loadAddon(new Unicode11Addon()) + activateOrcaTerminalUnicodeProvider(terminal) + return { terminal, serializeAddon } +} + +export function writeToTerminal(terminal: Terminal, data: string): Promise { + return new Promise((resolve) => terminal.write(data, resolve)) +} + +export async function writeChunksToTerminal(terminal: Terminal, chunks: string[]): Promise { + for (const chunk of chunks) { + await writeToTerminal(terminal, chunk) + } +} + +/** Bottom-anchored visible screen rows (baseY, not viewportY — scroll intent + * is enforced separately by the production restore path). */ +export function visibleRows(terminal: Terminal): string[] { + const buffer = terminal.buffer.active + const rows: string[] = [] + for (let y = 0; y < terminal.rows; y++) { + rows.push(buffer.getLine(buffer.baseY + y)?.translateToString(true) ?? '') + } + return rows +} + +// xterm attribute color modes (Attributes CM_* in xterm's buffer model). +const COLOR_MODE_P16 = 16777216 +const COLOR_MODE_P256 = 33554432 + +/** Known-legitimate serializer normalization: SerializeAddon re-emits palette + * indices 0-15 written via 38;5;N / 48;5;N as classic SGR 30-37/90-97, so a + * restored cell reports CM_P16 where the live cell reported CM_P256. Both + * modes resolve through the same 16 theme slots — no visual difference. */ +function canonicalColorMode(mode: number, color: number): number { + return mode === COLOR_MODE_P256 && color >= 0 && color < 16 ? COLOR_MODE_P16 : mode +} + +/** Per-cell descriptor rows so SGR runs that shift cells are caught even + * when the text matches. Encodes only VISUALLY EFFECTIVE state: + * - glyph cells: char, width, fg, bg, all attribute flags; + * - blank cells (null cells and spaces render identically): width, bg, + * underline/strikethrough (drawn across blanks), and fg only when inverse + * swaps it into the cell background. SerializeAddon legitimately skips + * null cells with cursor motion, dropping their invisible fg/bold/italic + * state, and may materialize a skipped run as plain spaces — neither can + * be seen, so neither may fail the garble gate. + * Trailing default blanks are trimmed: the serializer does not re-emit + * pristine cells past the last written column. */ +export function visibleRowStyles(terminal: Terminal): string[] { + const buffer = terminal.buffer.active + const out: string[] = [] + for (let y = 0; y < terminal.rows; y++) { + const line = buffer.getLine(buffer.baseY + y) + const cells: string[] = [] + for (let x = 0; line && x < line.length; x++) { + const cell = line.getCell(x) + if (!cell) { + continue + } + const chars = cell.getChars() + const fgMode = canonicalColorMode(cell.getFgColorMode(), cell.getFgColor()) + const bgMode = canonicalColorMode(cell.getBgColorMode(), cell.getBgColor()) + if (chars === '' || chars === ' ') { + const blankFlags = [cell.isUnderline(), cell.isStrikethrough()] + .map((flag) => (flag ? '1' : '0')) + .join('') + const inverseFg = cell.isInverse() ? `·if${fgMode}:${cell.getFgColor()}` : '' + cells.push( + `▯·w${cell.getWidth()}·b${bgMode}:${cell.getBgColor()}·${blankFlags}${inverseFg}` + ) + continue + } + const flags = [ + cell.isBold(), + cell.isDim(), + cell.isItalic(), + cell.isUnderline(), + cell.isInverse(), + cell.isStrikethrough() + ] + .map((flag) => (flag ? '1' : '0')) + .join('') + cells.push( + `${chars}·w${cell.getWidth()}·f${fgMode}:${cell.getFgColor()}·b${bgMode}:${cell.getBgColor()}·${flags}` + ) + } + const defaultBlank = `▯·w1·b0:-1·00` + while (cells.length > 0 && cells.at(-1) === defaultBlank) { + cells.pop() + } + out.push(cells.join('|')) + } + return out +} + +export function cursorPosition(terminal: Terminal): { x: number; y: number } { + return { x: terminal.buffer.active.cursorX, y: terminal.buffer.active.cursorY } +} + +/** KNOWN UPSTREAM BUG predicate (@xterm/addon-serialize 0.15.0-beta.287): + * null cells touching a soft-wrap boundary do not round-trip. Two confirmed + * variants (see the skipped repros in headless-emulator-fidelity.fuzz.test.ts): + * - V1 (cell loss): a wrapped continuation row starting with a NULL cell + * (only erasure creates those — typed spaces have chars ' ') passes the + * addon's wrap-validity ternary (SerializeAddon.ts ~L214 binds as + * `(chars && isDoubleWidth) ? ...`), so the blank is skipped with CUF — + * which clamps at the right margin instead of crossing the wrap boundary, + * overwriting the previous row's last cell and shifting the tail left. + * - V2 (filler artifact): a wrapped pair whose SOURCE row is entirely null + * takes the forced-wrap "magic" path, whose cleanup emits `ESC[0C`; CSI + * param 0 means 1, so the erase lands one cell right and the first filler + * '-' stays visible on the restored screen. + * The fuzz suites use this predicate to tolerate (and count) exactly these + * divergences without masking unknown ones. */ +export function bufferHasSerializeHostileWrappedRow(terminal: Terminal): boolean { + const buffer = terminal.buffer.active + for (let y = 1; y < buffer.length; y++) { + const line = buffer.getLine(y) + if (!line?.isWrapped) { + continue + } + if (line.getCell(0)?.getChars() === '') { + return true + } + const previous = buffer.getLine(y - 1) + let previousIsAllNull = previous !== undefined + for (let x = 0; previous && x < previous.length; x++) { + if (previous.getCell(x)?.getChars() !== '') { + previousIsAllNull = false + break + } + } + if (previousIsAllNull) { + return true + } + } + return false +} + +/** Full normal-buffer text with trailing blank rows trimmed (SerializeAddon + * restores content rows; both sides may differ only in trailing blanks). */ +export function normalBufferRowsTrimmed(terminal: Terminal): string[] { + const buffer = terminal.buffer.normal + const rows: string[] = [] + for (let y = 0; y < buffer.length; y++) { + rows.push(buffer.getLine(y)?.translateToString(true) ?? '') + } + while (rows.length > 0 && rows.at(-1) === '') { + rows.pop() + } + return rows +} + +// Mirror of applyMainBufferSnapshot's clear preamble (pty-connection.ts): +// normal-buffer restores wipe screen+scrollback+home; alt-screen restores +// clear only the alt screen so the normal buffer's scrollback survives. +export const SNAPSHOT_REPLAY_PREAMBLE_NORMAL = '\x1b[2J\x1b[3J\x1b[H' +export const SNAPSHOT_REPLAY_PREAMBLE_ALT = '\x1b[0m\x1b[?1049h\x1b[2J\x1b[H' + +// Twin of POST_REPLAY_LIVE_SNAPSHOT_RESET (layout-serialization.ts) — the +// renderer suite pins equality against the real constant so drift fails fast. +export const POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY = '\x1b[0 q\x1b[?25h\x1b[?1004l' + +export type ParityMainSnapshot = { + data: string + scrollbackAnsi?: string + cols: number + rows: number + seq: number + alternateScreen: boolean + pendingDeliveryStartSeq?: number + /** Mirror of TerminalSnapshot.pendingEscapeTailAnsi: the trailing + * incomplete escape of the hidden byte stream. The restorer writes it + * LAST, after its post-replay resets (Bug E fix). */ + pendingEscapeTailAnsi?: string +} + +/** Mirror of the production main-buffer snapshot the renderer restore path + * consumes: HeadlessEmulator.getSnapshot (snapshotAnsi normalization + + * rehydrateSequences + absolute-cursor/DECSC epilogue) composed exactly like + * OrcaRuntime.serializeHeadlessTerminalBuffer (normal buffer separated from + * an active alt frame). The renderer fuzz cannot import + * HeadlessEmulator itself — tsconfig.tc.web.json excludes src/main/daemon. */ +export function buildParityMainBufferSnapshot( + parity: ParityTerminal, + seq: number, + opts: { + pendingDeliveryStartSeq?: number + scrollbackRows?: number + /** The hidden byte stream's trailing incomplete escape, exactly as the + * emulator's ingest tracker would have accumulated it. */ + pendingEscapeTail?: string + } = {} +): ParityMainSnapshot { + const { terminal } = parity + const alternateScreen = terminal.buffer.active.type === 'alternate' + const scrollback = opts.scrollbackRows ?? DESKTOP_TERMINAL_SCROLLBACK_ROWS_DEFAULT + // Same composition as HeadlessEmulator.getSnapshot: absolute-cursor CUP for + // the wrap-pending relative-restore defect plus the DECSC register epilogue. + let snapshotAnsi = serializeWithAbsoluteCursor( + parity.serializeAddon, + terminal, + { scrollback }, + readSavedCursorRegister(terminal) + ) + let scrollbackAnsi: string | undefined + if (alternateScreen) { + // Why: HeadlessEmulator splits the normal buffer from the active alt frame; + // rehydrateSequences owns the transition between them. + const marker = '\x1b[?1049h' + const start = snapshotAnsi.lastIndexOf(marker) + if (start !== -1) { + scrollbackAnsi = snapshotAnsi.slice(0, start) + snapshotAnsi = snapshotAnsi.slice(start + marker.length) + } + } + const seqs: string[] = [] + if (alternateScreen) { + seqs.push('\x1b[0m\x1b[?1049h') + } + if (terminal.modes.bracketedPasteMode) { + seqs.push('\x1b[?2004h') + } + // Why normal-buffer-only: HeadlessEmulator.getModes reports + // applicationCursor false while the alternate buffer is active, so the + // production rehydrate omits ?1h for alt-screen snapshots. + if (!alternateScreen && terminal.modes.applicationCursorKeysMode) { + seqs.push('\x1b[?1h') + } + // Mouse-mode rehydrate omitted: TerminalMouseModeMirror is main-only and + // mouse reporting is input encoding — it cannot alter rendered output. + const snapshot: ParityMainSnapshot = { + data: seqs.join('') + snapshotAnsi, + cols: terminal.cols, + rows: terminal.rows, + seq, + alternateScreen, + ...(scrollbackAnsi !== undefined ? { scrollbackAnsi } : {}) + } + if (opts.pendingDeliveryStartSeq !== undefined) { + snapshot.pendingDeliveryStartSeq = opts.pendingDeliveryStartSeq + } + if (opts.pendingEscapeTail) { + snapshot.pendingEscapeTailAnsi = opts.pendingEscapeTail + } + return snapshot +} diff --git a/src/shared/terminal-scrollback-policy.test.ts b/src/shared/terminal-scrollback-policy.test.ts index 77bbec61c94..26187b11f77 100644 --- a/src/shared/terminal-scrollback-policy.test.ts +++ b/src/shared/terminal-scrollback-policy.test.ts @@ -6,7 +6,9 @@ import { DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN, legacyTerminalScrollbackBytesToRows, normalizeDesktopTerminalScrollbackRows, - normalizeDesktopTerminalSnapshotRows + normalizeDesktopTerminalSnapshotRows, + TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS, + terminalOutputBacklogCapChars } from './terminal-scrollback-policy' describe('terminal scrollback policy', () => { @@ -35,6 +37,18 @@ describe('terminal scrollback policy', () => { expect(normalizeDesktopTerminalSnapshotRows(100_000)).toBe(50_000) }) + it('scales the output backlog cap with scrollback rows above a 2 MB floor', () => { + // Default and small scrollbacks stay on the floor; large scrollbacks get + // proportionally more so the cap never drops lines scrollback would keep. + expect(terminalOutputBacklogCapChars(undefined)).toBe(TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS) + expect(terminalOutputBacklogCapChars(5_000)).toBe(TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS) + expect(terminalOutputBacklogCapChars('garbage')).toBe(TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS) + expect(terminalOutputBacklogCapChars(25_000)).toBe(3_000_000) + expect(terminalOutputBacklogCapChars(50_000)).toBe(6_000_000) + // Values beyond the settings max clamp like the setting itself does. + expect(terminalOutputBacklogCapChars(1_000_000)).toBe(6_000_000) + }) + it('migrates legacy decimal MB buckets by intent, not byte-to-row math', () => { expect(legacyTerminalScrollbackBytesToRows(undefined)).toBe(5_000) expect(legacyTerminalScrollbackBytesToRows(0)).toBe(5_000) diff --git a/src/shared/terminal-scrollback-policy.ts b/src/shared/terminal-scrollback-policy.ts index 22296036cc8..3c831234113 100644 --- a/src/shared/terminal-scrollback-policy.ts +++ b/src/shared/terminal-scrollback-policy.ts @@ -28,6 +28,23 @@ export function normalizeDesktopTerminalScrollbackRows(value: unknown): number { return clampRows(value, DESKTOP_TERMINAL_SCROLLBACK_ROWS_MIN) } +// Why the backlog cap scales with scrollback: pending-output caps exist to +// bound memory while a starved display catches up, but a user who raised +// scrollback to 50k rows can retain more history than the flat 2 MB floor — +// dropping at the floor would discard lines their scrollback would have kept. +// 120 chars/row ≈ 80-col text plus escape-sequence overhead; the cap is a +// memory bound, not an exact retention guarantee. +export const TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS = 2 * 1024 * 1024 +const OUTPUT_BACKLOG_CHARS_PER_SCROLLBACK_ROW = 120 + +export function terminalOutputBacklogCapChars(scrollbackRows: unknown): number { + const rows = normalizeDesktopTerminalScrollbackRows(scrollbackRows) + return Math.max( + TERMINAL_OUTPUT_BACKLOG_MIN_CAP_CHARS, + rows * OUTPUT_BACKLOG_CHARS_PER_SCROLLBACK_ROW + ) +} + export function normalizeDesktopTerminalSnapshotRows(value: unknown): number | undefined { if (!isFiniteNumber(value)) { return undefined diff --git a/src/shared/terminal-serialize-absolute-cursor.ts b/src/shared/terminal-serialize-absolute-cursor.ts new file mode 100644 index 00000000000..18902b27426 --- /dev/null +++ b/src/shared/terminal-serialize-absolute-cursor.ts @@ -0,0 +1,95 @@ +// Why this module exists: @xterm/addon-serialize restores the cursor with +// RELATIVE moves (CUD/CUB) computed from where it assumes replay leaves the +// cursor. When the final content row is filled exactly to the right margin, +// replay leaves the fresh terminal wrap-pending (internal x == cols), so the +// relative math lands one column short of the real cursor. Every Orca buffer +// snapshot that will be replayed into another terminal must therefore end +// with an absolute CUP derived from the SOURCE terminal's authoritative +// cursor position. Snapshot producers that also need the VT100 DECSC +// saved-cursor register carried across the restore compose it here too. + +type SerializeCursorTerminal = { + cols: number + rows: number + buffer: { active: { cursorX: number; cursorY: number } } +} + +type BufferSerializer = { + serialize: (opts?: TOpts) => string +} + +/** VT100 DECSC saved-cursor register (0-based, viewport-relative row). */ +export type SavedCursorRegister = { x: number; y: number } + +// xterm keeps the DECSC register on each Buffer (savedY is absolute: +// ybase-included). It is not exposed through the public API, so snapshot +// producers read the core buffer directly — `_core.buffer` is the ACTIVE +// buffer, so an alt-screen TUI yields the alternate screen's own register, +// matching the one a post-restore DECRC would consult. +type TerminalWithSavedCursorCore = SerializeCursorTerminal & { + _core?: { buffer?: { savedX?: number; savedY?: number; ybase?: number } } +} + +/** Reads the source terminal's active-buffer DECSC register, or null when it + * is unavailable or indistinguishable from the never-saved default. */ +export function readSavedCursorRegister( + terminal: SerializeCursorTerminal +): SavedCursorRegister | null { + const core = (terminal as TerminalWithSavedCursorCore)._core?.buffer + if ( + typeof core?.savedX !== 'number' || + typeof core.savedY !== 'number' || + typeof core.ybase !== 'number' + ) { + return null + } + // savedY is absolute; DECRC restores it relative to the ybase current at + // restore time, clamping at the top — mirror that clamp here. savedX can be + // cols (DECSC during wrap-pending); CUP cannot re-create pending, so clamp. + const y = Math.min(Math.max(core.savedY - core.ybase, 0), terminal.rows - 1) + const x = Math.min(Math.max(core.savedX, 0), terminal.cols - 1) + if (x === 0 && y === 0) { + // Home is xterm's never-saved default: a fresh restore terminal already + // sends DECRC to home, and skipping the injection avoids overwriting the + // fresh terminal's default saved SGR/charset when nothing was ever saved. + return null + } + return { x, y } +} + +export function serializeWithAbsoluteCursor( + serializer: BufferSerializer, + terminal: SerializeCursorTerminal, + opts?: TOpts, + savedCursor?: SavedCursorRegister | null +): string { + const serialized = serializer.serialize(opts) + // Why skip empty snapshots: several callers treat '' as "nothing to + // restore" (e.g. shutdown layout capture drops empty buffers); a bare CUP + // would turn every idle pane into a persisted snapshot. + if (serialized.length === 0) { + return serialized + } + const { cursorX, cursorY } = terminal.buffer.active + // Why skip wrap-pending sources (cursorX == cols): plain replay already + // reproduces that state exactly, while CUP would clamp to the last column + // and clear the pending-wrap flag, changing how the next byte renders. + // The remaining bounds checks are defensive: never emit a clamping CUP. + // The saved-cursor injection is skipped with it — it moves the cursor, so + // it may only ride along when the absolute CUP restores the position after. + if (cursorX < 0 || cursorX >= terminal.cols || cursorY < 0 || cursorY >= terminal.rows) { + return serialized + } + // Why the DECSC injection: the serialized screen cannot carry the VT100 + // saved-cursor register, so a hidden DECSC followed by a post-reveal DECRC + // restored to home and clobbered live cells (Bug D in + // notes/garble-fuzz-divergences.md). Re-establish the register by saving at + // the source's saved position, then CUP back to the real cursor. Saved SGR/ + // charset are not carried — the synthetic ESC 7 saves the serializer's + // final pen, a deliberate position-only fidelity trade. + const savedRestore = savedCursor ? `\x1b[${savedCursor.y + 1};${savedCursor.x + 1}H\x1b7` : '' + // cursorY is viewport-relative (0 at the buffer's base row), which is the + // same coordinate space CUP addresses after replay; scrollback length + // differences between source and destination do not shift it. + return `${serialized}${savedRestore}\x1b[${cursorY + 1};${cursorX + 1}H` +} diff --git a/src/shared/terminal-side-effect-facts.ts b/src/shared/terminal-side-effect-facts.ts new file mode 100644 index 00000000000..1a546324006 --- /dev/null +++ b/src/shared/terminal-side-effect-facts.ts @@ -0,0 +1,56 @@ +/** + * Derived terminal side-effect facts carried on the `pty:sideEffect` channel + * (main → renderer). Events are facts, not decisions: main parses every + * local-daemon/SSH PTY byte exactly once and emits what it observed; the + * renderer store handler owns notification/unread policy. + * See docs/reference/terminal-side-effect-authority.md. + */ + +import type { TerminalGitHubPRLink } from './terminal-github-pr-link-detector' + +/** Why tagged: stale-clear facts come from main's unthrottled 3s timer, not + * observed bytes. Renderer policy clears title/cache state from them but + * must not schedule task-complete notifications or unread attention — a + * merely-paused agent (>3s silent mid-task) is not a completion. */ +export type TerminalSideEffectFact = + | { kind: 'title'; normalizedTitle: string; rawTitle: string; staleWorkingTitleClear?: boolean } + | { kind: 'bell' } + | { kind: 'agent-working' } + | { kind: 'agent-idle'; title: string; staleWorkingTitleClear?: boolean } + | { kind: 'agent-exited' } + /** OSC 133;D — foreground shell command exited (exit code best-effort). */ + | { kind: 'command-finished'; exitCode: number | null } + /** Carries the parsed link so the renderer store consumer never re-parses + * the URL (parse drift would break the per-PTY dedupe contract). */ + | { kind: 'pr-link'; link: TerminalGitHubPRLink } + /** Command Code output scrape (that CLI lacks hooks). Working seeds the + * agent-status row immediately; done is a hint the renderer settle-checks + * against its live status row before completing the turn. */ + | { kind: 'command-code-working'; prompt: string } + | { kind: 'command-code-done'; prompt: string } + /** DECSET 2031 color-scheme subscribe observed in the byte stream. Emitted + * so hidden-delivery-gated views (whose bytes never arrive) can still send + * the theme reply — the reply stays renderer-side because query authority + * belongs to the view (model/view contract invariant 6). */ + | { kind: '2031-subscribe' } + +export type TerminalSideEffectBatch = { + ptyId: string + /** PTY output byte sequence at emission. Replay batches carry the sequence + * their title state was current at, so the handler can drop a replay title + * older than the last live title fact it applied. */ + seq: number + /** Facts from one chunk, in byte order: titles in sequence, then bell. + * Command Code scrape facts trail the chunk's parser facts — their policy + * (status-row seeding) never interacts with title/bell ordering. */ + facts: TerminalSideEffectFact[] + /** True for (re)attach snapshots. Replay batches restore title state only — + * attention facts (bell, agent transitions) never replay. */ + replay?: boolean + /** Main-known attribution from runtime leaf/PTY records (same resolution as + * agent-status events). Absent when main has no binding for the PTY yet. */ + worktreeId?: string + tabId?: string + paneKey?: string + connectionId?: string | null +} diff --git a/src/shared/terminal-stream-protocol.test.ts b/src/shared/terminal-stream-protocol.test.ts index 58a8a0458a5..07de145031f 100644 --- a/src/shared/terminal-stream-protocol.test.ts +++ b/src/shared/terminal-stream-protocol.test.ts @@ -123,6 +123,21 @@ describe('terminal-stream-protocol', () => { expect(unsubscribe?.streamId).toBe(12) }) + it('round-trips output acknowledgement frames', () => { + const ack = decodeTerminalStreamFrame( + encodeTerminalStreamFrame({ + opcode: TerminalStreamOpcode.Ack, + streamId: 12, + seq: 4, + payload: encodeTerminalStreamJson({ bytes: 4096 }) + }) + ) + + expect(ack?.opcode).toBe(TerminalStreamOpcode.Ack) + expect(ack?.streamId).toBe(12) + expect(ack && decodeTerminalStreamJson(ack.payload)).toEqual({ bytes: 4096 }) + }) + it('rejects unknown frame versions and opcodes', () => { const encoded = encodeTerminalStreamFrame({ opcode: TerminalStreamOpcode.Output, diff --git a/src/shared/terminal-stream-protocol.ts b/src/shared/terminal-stream-protocol.ts index 2f8f4e04247..0011463babe 100644 --- a/src/shared/terminal-stream-protocol.ts +++ b/src/shared/terminal-stream-protocol.ts @@ -14,7 +14,10 @@ export enum TerminalStreamOpcode { Subscribe = 9, Unsubscribe = 10, SnapshotRequest = 11, - Metadata = 12 + Metadata = 12, + // Why 13: Metadata=12 shipped to mobile clients in v1.4.120; Ack (branch-only + // remote-multiplex flow control) renumbers to stay wire-compatible. + Ack = 13 } export type TerminalStreamFrame = { @@ -94,6 +97,7 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode { value === TerminalStreamOpcode.Subscribe || value === TerminalStreamOpcode.Unsubscribe || value === TerminalStreamOpcode.SnapshotRequest || - value === TerminalStreamOpcode.Metadata + value === TerminalStreamOpcode.Metadata || + value === TerminalStreamOpcode.Ack ) } diff --git a/src/shared/terminal-view-attributes.test.ts b/src/shared/terminal-view-attributes.test.ts new file mode 100644 index 00000000000..17c77c08d09 --- /dev/null +++ b/src/shared/terminal-view-attributes.test.ts @@ -0,0 +1,119 @@ +/** + * View-attribute bridge (terminal-query-authority.md §View-attribute bridge): + * the XParseColor mirrors must match the bundled xterm grammar exactly — + * main's replies for hidden PTYs must be byte-identical to a visible + * renderer xterm's. + */ +import { describe, expect, it } from 'vitest' +import { + formatXColorRgbSpec, + parseXColorSpec, + terminalViewAttributesEqual, + validateTerminalViewAttributes, + type TerminalViewAttributes, + type TerminalViewRgb +} from './terminal-view-attributes' + +describe('parseXColorSpec', () => { + // Scaling fixtures mirror XParseColor.parseColor: h|hh|hhh|hhhh channels + // scale from their base (15/255/4095/65535) to 8 bit. + it.each([ + ['rgb:f/f/f', [255, 255, 255]], + ['rgb:0/8/f', [0, 136, 255]], + ['rgb:ff/00/80', [255, 0, 128]], + ['rgb:fff/000/888', [255, 0, 136]], + ['rgb:ffff/0000/8888', [255, 0, 136]], + ['RGB:FF/00/80', [255, 0, 128]], + ['#abc', [0xa0, 0xb0, 0xc0]], + ['#aabbcc', [0xaa, 0xbb, 0xcc]], + ['#aaabbbccc', [0xaa, 0xbb, 0xcc]], + ['#aaaabbbbcccc', [0xaa, 0xbb, 0xcc]] + ])('parses %s like xterm', (spec, expected) => { + expect(parseXColorSpec(spec)).toEqual(expected) + }) + + it.each([ + ['', 'empty'], + ['red', 'named colors (xterm rejects them too)'], + ['rgb:ff/ff', 'missing channel'], + ['rgb:ggg/000/000', 'non-hex'], + ['#abcd', 'hash length 4 is not a valid xparsecolor width'], + ['rgbi:1/1/1', 'rgbi is unsupported'] + ])('rejects %s — %s', (spec) => { + expect(parseXColorSpec(spec)).toBeNull() + }) +}) + +describe('formatXColorRgbSpec', () => { + it('reports 16-bit channels by doubling the 8-bit byte (toRgbString parity)', () => { + expect(formatXColorRgbSpec([0x1e, 0x1e, 0x2e])).toBe('rgb:1e1e/1e1e/2e2e') + expect(formatXColorRgbSpec([0, 8, 255])).toBe('rgb:0000/0808/ffff') + }) +}) + +describe('validateTerminalViewAttributes', () => { + const valid = (): TerminalViewAttributes => ({ + foreground: [1, 2, 3], + background: [4, 5, 6], + cursor: [7, 8, 9], + ansi: Array.from({ length: 256 }, (_, i) => [i % 256, 0, 0] as TerminalViewRgb), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: true + }) + + it('accepts and normalizes a well-formed payload', () => { + const attrs = validateTerminalViewAttributes(valid()) + expect(attrs).not.toBeNull() + expect(attrs?.ansi).toHaveLength(256) + expect(attrs?.colorSchemeMode).toBe('dark') + }) + + it.each([ + ['null payload', null], + ['missing foreground', { ...valid(), foreground: undefined }], + ['short triple', { ...valid(), background: [1, 2] }], + ['out-of-range channel', { ...valid(), cursor: [0, 0, 300] }], + ['non-integer channel', { ...valid(), cursor: [0, 0, 1.5] }], + ['short palette', { ...valid(), ansi: valid().ansi.slice(0, 16) }], + ['bad palette entry', { ...valid(), ansi: [...valid().ansi.slice(0, 255), 'red'] }], + ['bad mode', { ...valid(), colorSchemeMode: 'auto' }], + ['bad cursor style', { ...valid(), cursorStyle: 'beam' }], + ['non-boolean blink', { ...valid(), cursorBlink: 1 }] + ])('rejects %s', (_label, payload) => { + expect(validateTerminalViewAttributes(payload)).toBeNull() + }) +}) + +describe('terminalViewAttributesEqual', () => { + // The store's idempotence gate: a deep-equal snapshot from a fresh renderer + // process must compare equal so the re-push never fans out as a theme apply. + const snapshot = (): TerminalViewAttributes => ({ + foreground: [1, 2, 3], + background: [4, 5, 6], + cursor: [7, 8, 9], + ansi: Array.from({ length: 256 }, (_, i) => [i % 256, 0, 0] as TerminalViewRgb), + colorSchemeMode: 'dark', + cursorStyle: 'block', + cursorBlink: true + }) + + it('treats two independently built identical snapshots as equal', () => { + expect(terminalViewAttributesEqual(snapshot(), snapshot())).toBe(true) + }) + + it.each([ + ['foreground', { ...snapshot(), foreground: [1, 2, 4] as TerminalViewRgb }], + ['background', { ...snapshot(), background: [0, 0, 0] as TerminalViewRgb }], + ['cursor', { ...snapshot(), cursor: [7, 8, 10] as TerminalViewRgb }], + [ + 'an ansi entry', + { ...snapshot(), ansi: snapshot().ansi.map((rgb, i) => (i === 200 ? [9, 9, 9] : rgb)) } + ], + ['colorSchemeMode', { ...snapshot(), colorSchemeMode: 'light' as const }], + ['cursorStyle', { ...snapshot(), cursorStyle: 'bar' as const }], + ['cursorBlink', { ...snapshot(), cursorBlink: false }] + ])('detects a change in %s', (_label, changed) => { + expect(terminalViewAttributesEqual(snapshot(), changed as TerminalViewAttributes)).toBe(false) + }) +}) diff --git a/src/shared/terminal-view-attributes.ts b/src/shared/terminal-view-attributes.ts new file mode 100644 index 00000000000..fa587a17b5c --- /dev/null +++ b/src/shared/terminal-view-attributes.ts @@ -0,0 +1,188 @@ +/** + * Phase 5 slice 2 (docs/reference/terminal-query-authority.md §View-attribute + * bridge): payload contract for the renderer→main `pty:terminalViewAttributes` + * push, plus main/renderer mirrors of xterm's XParseColor color-spec grammar + * so main's responder replies byte-identically to a visible renderer xterm. + */ + +/** 8-bit-per-channel RGB triple — the same resolution xterm's theme service + * stores internally (`color.toColorRGB`). */ +export type TerminalViewRgb = [number, number, number] + +export const TERMINAL_VIEW_ANSI_COLOR_COUNT = 256 + +export type TerminalViewCursorStyle = 'bar' | 'block' | 'underline' + +/** One app-global snapshot of the renderer's composed terminal appearance — + * per-pane font zoom never affects these, and terminalColorOverrides / + * cursor settings are global, so one push covers all PTYs. */ +export type TerminalViewAttributes = { + foreground: TerminalViewRgb + background: TerminalViewRgb + /** Already blended over the background (xterm ThemeService blends the + * cursor color's alpha at theme-set time, e.g. terminalCursorOpacity). */ + cursor: TerminalViewRgb + /** Full 256-entry palette: theme's 16 named colors + extendedAnsi/default + * tail, exactly as the renderer ThemeService resolves them. */ + ansi: TerminalViewRgb[] + /** Resolved APP color-scheme mode (the 2031/997 flip source). NOT the DSR + * ?996n answer: that is computed from background/foreground relative + * luminance like a visible xterm (_reportColorScheme), and the two can + * disagree (e.g. dark terminal theme in light app mode). */ + colorSchemeMode: 'dark' | 'light' + cursorStyle: TerminalViewCursorStyle + cursorBlink: boolean +} + +// Mirror of @xterm XParseColor RGB_REX: r/g/b channels in 1-4 hex digits. +const X_RGB_SPEC_RE = + /^([\da-f])\/([\da-f])\/([\da-f])$|^([\da-f]{2})\/([\da-f]{2})\/([\da-f]{2})$|^([\da-f]{3})\/([\da-f]{3})\/([\da-f]{3})$|^([\da-f]{4})\/([\da-f]{4})\/([\da-f]{4})$/ +const X_HASH_SPEC_RE = /^[\da-f]+$/ + +/** Mirror of xterm's XParseColor `parseColor` (the grammar the renderer + * accepts for OSC 4/10/11/12 SET payloads): `rgb:h/h/h`..`rgb:hhhh/hhhh/hhhh` + * and `#RGB|#RRGGBB|#RRRGGGBBB|#RRRRGGGGBBBB`. Anything else (named colors, + * rgbi:) is rejected exactly like the renderer rejects it. */ +export function parseXColorSpec(spec: string): TerminalViewRgb | null { + if (!spec) { + return null + } + let low = spec.toLowerCase() + if (low.startsWith('rgb:')) { + low = low.slice(4) + const m = X_RGB_SPEC_RE.exec(low) + if (m) { + const base = m[1] ? 15 : m[4] ? 255 : m[7] ? 4095 : 65535 + return [ + Math.round((Number.parseInt(m[1] || m[4] || m[7] || m[10], 16) / base) * 255), + Math.round((Number.parseInt(m[2] || m[5] || m[8] || m[11], 16) / base) * 255), + Math.round((Number.parseInt(m[3] || m[6] || m[9] || m[12], 16) / base) * 255) + ] + } + return null + } + if (low.startsWith('#')) { + low = low.slice(1) + if (X_HASH_SPEC_RE.exec(low) && [3, 6, 9, 12].includes(low.length)) { + const adv = low.length / 3 + const result: TerminalViewRgb = [0, 0, 0] + for (let i = 0; i < 3; ++i) { + const c = Number.parseInt(low.slice(adv * i, adv * i + adv), 16) + result[i] = adv === 1 ? c << 4 : adv === 2 ? c : adv === 3 ? c >> 4 : c >> 8 + } + return result + } + } + return null +} + +function padChannelTo16Bit(value: number): string { + const hex = value.toString(16) + const byte = hex.length < 2 ? `0${hex}` : hex + // Why doubled: xterm reports 16-bit channels by repeating the 8-bit byte + // (XParseColor.toRgbString with bits=16) — pinned reply-format parity. + return byte + byte +} + +/** Mirror of xterm's `toRgbString(color, 16)` — the exact channel format a + * visible renderer xterm uses in OSC 4/10/11/12 query replies. */ +export function formatXColorRgbSpec(rgb: TerminalViewRgb): string { + return `rgb:${padChannelTo16Bit(rgb[0])}/${padChannelTo16Bit(rgb[1])}/${padChannelTo16Bit(rgb[2])}` +} + +function rgbEqual(a: TerminalViewRgb, b: TerminalViewRgb): boolean { + return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] +} + +/** Value equality over the whole snapshot. Lets main's store treat a + * re-push of identical attributes (fresh renderer process: second window, + * reload, macOS re-activation) as a no-op instead of a theme apply. */ +export function terminalViewAttributesEqual( + a: TerminalViewAttributes, + b: TerminalViewAttributes +): boolean { + if (a === b) { + return true + } + if ( + !rgbEqual(a.foreground, b.foreground) || + !rgbEqual(a.background, b.background) || + !rgbEqual(a.cursor, b.cursor) || + a.colorSchemeMode !== b.colorSchemeMode || + a.cursorStyle !== b.cursorStyle || + a.cursorBlink !== b.cursorBlink || + a.ansi.length !== b.ansi.length + ) { + return false + } + for (let i = 0; i < a.ansi.length; i++) { + if (!rgbEqual(a.ansi[i], b.ansi[i])) { + return false + } + } + return true +} + +function isRgbChannel(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 0 && value <= 255 +} + +function validateRgbTriple(value: unknown): TerminalViewRgb | null { + if (!Array.isArray(value) || value.length !== 3) { + return null + } + const [r, g, b] = value + if (!isRgbChannel(r) || !isRgbChannel(g) || !isRgbChannel(b)) { + return null + } + return [r, g, b] +} + +/** IPC-boundary validation for the `pty:terminalViewAttributes` push. Returns + * a normalized copy or null — main must never store a malformed palette (a + * wrong color reply is worse than silence, the OSC-11 lesson). */ +export function validateTerminalViewAttributes(payload: unknown): TerminalViewAttributes | null { + if (typeof payload !== 'object' || payload === null) { + return null + } + const candidate = payload as Record + const foreground = validateRgbTriple(candidate.foreground) + const background = validateRgbTriple(candidate.background) + const cursor = validateRgbTriple(candidate.cursor) + if (!foreground || !background || !cursor) { + return null + } + if (!Array.isArray(candidate.ansi) || candidate.ansi.length !== TERMINAL_VIEW_ANSI_COLOR_COUNT) { + return null + } + const ansi: TerminalViewRgb[] = [] + for (const entry of candidate.ansi) { + const triple = validateRgbTriple(entry) + if (!triple) { + return null + } + ansi.push(triple) + } + if (candidate.colorSchemeMode !== 'dark' && candidate.colorSchemeMode !== 'light') { + return null + } + if ( + candidate.cursorStyle !== 'bar' && + candidate.cursorStyle !== 'block' && + candidate.cursorStyle !== 'underline' + ) { + return null + } + if (typeof candidate.cursorBlink !== 'boolean') { + return null + } + return { + foreground, + background, + cursor, + ansi, + colorSchemeMode: candidate.colorSchemeMode, + cursorStyle: candidate.cursorStyle, + cursorBlink: candidate.cursorBlink + } +} diff --git a/src/shared/terminal-webgl-diagnostics.ts b/src/shared/terminal-webgl-diagnostics.ts new file mode 100644 index 00000000000..f63c34f1807 --- /dev/null +++ b/src/shared/terminal-webgl-diagnostics.ts @@ -0,0 +1,26 @@ +/** + * Lib-safe sink for WebGL renderer breadcrumbs (context loss/restore, atlas + * resets). The renderer breadcrumb ring lives in the components layer + * (terminal-freeze-breadcrumbs.ts), which may import from lib/pane-manager but + * not the reverse. This indirection lets lib-layer WebGL code record a crumb + * without a backward import: components registers the recorder at startup; + * until then (and in non-renderer contexts) recording is a silent no-op. + */ + +export type WebglDiagnosticRecorder = ( + kind: string, + detail?: Record +) => void + +let recorder: WebglDiagnosticRecorder | null = null + +export function setTerminalWebglDiagnosticRecorder(next: WebglDiagnosticRecorder | null): void { + recorder = next +} + +export function recordTerminalWebglDiagnostic( + kind: string, + detail?: Record +): void { + recorder?.(kind, detail) +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 75f23a7c306..b9df77778c2 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2722,6 +2722,28 @@ export type GlobalSettings = { * does not surface commands from other worktrees. Defaults to true. * Disable to revert to shared global shell history. */ terminalScopeHistoryByWorktree: boolean + /** Kill switch for hidden terminal view parking — unmounting long-hidden + * terminal panes while a pane-less watcher keeps PTY side effects alive. + * Defaults to true; `false` disables parking entirely. + * See docs/reference/terminal-hidden-view-parking.md. */ + terminalHiddenViewParking?: boolean + /** Kill switch for main-process terminal side-effect authority: when true + * (default), local-daemon/SSH PTY title/bell/agent facts are consumed from + * the `pty:sideEffect` channel and renderer byte parsers stay unregistered + * for those PTYs; `false` restores renderer byte parsing. + * See docs/reference/terminal-side-effect-authority.md. */ + terminalMainSideEffectAuthority?: boolean + /** Kill switch for main's hidden-delivery gate (Phase 4): when true + * (default) AND terminalMainSideEffectAuthority is on, main drops PTY byte + * delivery to hidden renderer views after model ingestion; reveal restores + * from the model snapshot. `false` restores hidden byte delivery. */ + terminalHiddenDeliveryGate?: boolean + /** Kill switch for the main model query responder (Phase 5): when true + * (default) AND both Phase-4 gate switches are on, main answers terminal + * queries (DA1/CPR/DECRPM, …) embedded in hidden-dropped chunks from the + * runtime emulator. `false` silences the responder without changing drops. + * See docs/reference/terminal-query-authority.md. */ + terminalModelQueryAuthority?: boolean /** Which agent to pre-select in the new-workspace composer. * - null: auto (first detected agent) * - 'blank': blank terminal (no agent launched) diff --git a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts index 90fa83b8741..7e856c8dfae 100644 --- a/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-hidden-pressure-scenario.ts @@ -1,8 +1,12 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' import { expect } from '@stablyai/playwright-test' import { randomUUID } from 'node:crypto' -import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { rmSync } from 'node:fs' import path from 'node:path' +import { + type HiddenPressureOutputMode, + writePressureOutputScript +} from './artificial-opencode-hidden-pressure-script' import { ensureTerminalVisible, getActiveWorktreeId, @@ -47,13 +51,13 @@ type HiddenPressureDeps Promise releaseTerminalAckGate: (page: Page) => Promise resetTerminalPtyOutputDebug: (page: Page) => Promise - waitForMainPtyPressureBacklog: (page: Page) => Promise writeInteractivePromptScript: (scriptPath: string, runId: string) => void } +// Why: the renderer hidden-skip counters are gone with the skip grammar — +// withheld hidden output is observed via main's delivery-drop counters only. type HiddenPressureDebug = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number + hiddenRendererMode2031ReplyCount: number } type HiddenPressureMeasurement = { @@ -66,66 +70,45 @@ type HiddenPressureMainSnapshot = { peakPendingChars: number peakRendererInFlightChars: number ackGatedFlushSkipCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryGatedPtyCount: number +} + +type HiddenPressureSchedulerSnapshot = { + peakQueuedChars: number + droppedBacklogCount: number } type HiddenPressureAckGate = { heldAckChars: number } -// Why: this is a throughput/drain metric — the time to switch back and replay the -// full 8MB+ held backlog into xterm, measured through repeated (expensive) terminal -// serialization polls. The real responsiveness guards are the typing-latency -// asserts above (median/worst), which hold. Under 8MB of in-flight backpressure on -// a loaded OSS runner the drain-plus-poll overhead was seen at ~3.2s, so keep a -// ceiling with headroom that still catches an order-of-magnitude regression. -const MAX_HIDDEN_RESTORE_LATENCY_MS = 4_000 +// Why: restore still has to finish promptly, but parallel Electron workers on +// Linux CI can overshoot the 1s product target without a responsiveness regression. +// Main relaxed this to 4s for drain-plus-poll overhead on loaded OSS runners; +// this branch KEEPS the strict budget — the background keep-tail global budget +// bounds the aggregate a reveal drains, so a slow restore here is a regression. +const MAX_HIDDEN_RESTORE_LATENCY_MS = 1_500 +// Why: Phase-4 hidden-delivery gate contract — hidden PTY bytes are dropped in +// main after model ingestion, so renderer-delivery pressure must stay FAR +// below the old 2 MB ACK-backpressure target instead of reaching it. +const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 // Why: in this hidden real-PTY pressure case, maxTimerDriftMs and worst-key // latency catch the same isolated CI starvation spike; median remains strict. const MAX_HIDDEN_PRESSURE_TIMER_DRIFT_MS = 3_000 -export function pressureOutputScript(runId: string): string { - return ` -const paneIndex = process.argv[2] ?? '0' -const targetChars = Number(process.argv[3] ?? '0') -const delayMs = Number(process.argv[4] ?? '0') -const header = 'OPENCODE_PRESSURE_START_${runId}_' + paneIndex + '\\n' -const chunkBody = '#'.repeat(8192) -let written = 0 -process.stdout.write(header) -function writeMore() { - let canContinue = true - while (canContinue && written < targetChars) { - const frame = String(written).padStart(8, '0') - const chunk = '\\x1b[?2026h\\x1b[1;1Hpressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x1b[?2026l\\n' - written += chunk.length - canContinue = process.stdout.write(chunk) - } - if (written < targetChars) { - process.stdout.once('drain', writeMore) - return - } - process.stdout.write('OPENCODE_PRESSURE_DONE_${runId}_' + paneIndex + '\\n') -} -setTimeout(writeMore, Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0) -` -} - -export function writePressureOutputScript(scriptPath: string, runId: string): void { - mkdirSync(path.dirname(scriptPath), { recursive: true }) - writeFileSync(scriptPath, pressureOutputScript(runId)) -} - export async function runHiddenRealPtyPressureScenario< TMeasurement extends HiddenPressureMeasurement, TDebug extends HiddenPressureDebug, TMainPressure extends HiddenPressureMainSnapshot, TAckGate extends HiddenPressureAckGate, - TScheduler + TScheduler extends HiddenPressureSchedulerSnapshot >({ deps, annotationSuffix, hiddenPaneCount, pressureOutputChars, + pressureOutputMode = 'tui', pressureStartDelayMs, testInfo, testRepoPath, @@ -135,6 +118,7 @@ export async function runHiddenRealPtyPressureScenario< annotationSuffix?: string hiddenPaneCount: number pressureOutputChars: number + pressureOutputMode?: HiddenPressureOutputMode pressureStartDelayMs: number testInfo: TestInfo testRepoPath: string @@ -164,7 +148,7 @@ export async function runHiddenRealPtyPressureScenario< `.orca-opencode-hidden-pressure-load-${runId}.mjs` ) deps.writeInteractivePromptScript(typingScriptPath, runId) - writePressureOutputScript(pressureScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, pressureOutputMode) await deps.resetTerminalPtyOutputDebug(orcaPage) await deps.holdTerminalAckGate( @@ -182,7 +166,11 @@ export async function runHiddenRealPtyPressureScenario< await switchToTypingWorkspace(orcaPage, firstWorktreeId) const typingPtyId = await waitForActivePanePtyId(orcaPage) - const pressureBeforeTyping = await deps.waitForMainPtyPressureBacklog(orcaPage) + // Why: under the Phase-4 hidden-delivery gate the hidden panes' bytes are + // dropped in main after model ingestion, so renderer-delivery pressure + // never builds. Wait for the gate to drop at least one pane's worth of + // output instead of the old 2 MB ACK-backpressure target. + await waitForMainHiddenDeliveryDrops(orcaPage, deps, pressureOutputChars) const measurement = await deps.measureTypingDuringLoad( orcaPage, typingScriptPath, @@ -190,6 +178,7 @@ export async function runHiddenRealPtyPressureScenario< runId ) const debug = await deps.readTerminalPtyOutputDebug(orcaPage) + const scheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) const mainPressure = await deps.readMainPtyPressureDebug(orcaPage) const ackGate = await deps.readTerminalAckGateDebug(orcaPage) deps.annotateTypingMeasurement( @@ -198,19 +187,26 @@ export async function runHiddenRealPtyPressureScenario< hiddenPanes.length + 1, measurement, debug, - await deps.readTerminalOutputSchedulerDebug(orcaPage), + scheduler, mainPressure, ackGate ) - expect(debug?.hiddenRendererSkipCount ?? 0).toBe(0) - expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0) - expect(pressureBeforeTyping.peakPendingChars).toBeGreaterThan(0) - expect(pressureBeforeTyping.ackGatedFlushSkipCount).toBeGreaterThan(0) - expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual(8 * 1024 * 1024) - expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(0) - // Why: median is the robust responsiveness guard — it proves typing stays - // instant even while hidden PTYs replay 8MB+ of ACK-backpressured output. + // Hidden-delivery contract (all pressure modes): bytes never reach the + // renderer — main's drop counter is the withheld-output signal (the + // renderer skip counters were deleted with the skip grammar) — and main's + // renderer-delivery pressure must stay clearly below the old 2 MB + // backpressure target. + expect(mainPressure?.hiddenDeliveryDroppedChars ?? 0).toBeGreaterThanOrEqual( + pressureOutputChars + ) + expect(mainPressure?.peakRendererInFlightChars ?? 0).toBeLessThan( + MAIN_RENDERER_PRESSURE_TARGET_CHARS + ) + // Why: the renderer scheduler queue must stay ~empty (no hidden bytes to + // queue) and must never drop a backlog — strict, per the gate contract. + expect(scheduler?.peakQueuedChars ?? 0).toBeLessThan(pressureOutputChars) + expect(scheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) expect(measurement.medianLatencyMs).toBeLessThan(75) // Why: worst *single-key echo* under 8MB synthetic backpressure lands behind // whichever flush it collides with, so on a contended OSS shard it is @@ -230,9 +226,11 @@ export async function runHiddenRealPtyPressureScenario< type: `opencode-hidden-real-pty-restore${annotationSuffix ?? ''}`, description: `panes=${hiddenPanes.length + 1} restore=${restoreLatencyMs.toFixed( 1 - )}ms hiddenSkippedChars=${debug?.hiddenRendererSkippedChars ?? 0} mainPeakInFlightChars=${ - mainPressure?.peakRendererInFlightChars ?? 0 - } heldAckChars=${ackGate?.heldAckChars ?? 0}` + )}ms hiddenDeliveryDroppedChars=${ + mainPressure?.hiddenDeliveryDroppedChars ?? 0 + } mainPeakInFlightChars=${mainPressure?.peakRendererInFlightChars ?? 0} heldAckChars=${ + ackGate?.heldAckChars ?? 0 + }` }) expect(restoreLatencyMs).toBeLessThan(MAX_HIDDEN_RESTORE_LATENCY_MS) } finally { @@ -248,6 +246,22 @@ export async function runHiddenRealPtyPressureScenario< } } +// Why: replaces the old waitForMainPtyPressureBacklog premise — the Phase-4 +// gate drops hidden bytes in main, so renderer-delivery pressure never builds; +// readiness is the gate reporting one pane's worth of dropped output. +async function waitForMainHiddenDeliveryDrops( + orcaPage: Page, + deps: { readMainPtyPressureDebug: (page: Page) => Promise }, + pressureOutputChars: number +): Promise { + await expect + .poll( + async () => (await deps.readMainPtyPressureDebug(orcaPage))?.hiddenDeliveryDroppedChars ?? 0, + { timeout: 30_000, message: 'Main hidden-delivery gate did not drop hidden PTY output' } + ) + .toBeGreaterThanOrEqual(pressureOutputChars) +} + async function measureHiddenOutputRestoreLatency( orcaPage: Page, worktreeId: string, diff --git a/tests/e2e/artificial-opencode-hidden-pressure-script.ts b/tests/e2e/artificial-opencode-hidden-pressure-script.ts new file mode 100644 index 00000000000..1eb94b3de89 --- /dev/null +++ b/tests/e2e/artificial-opencode-hidden-pressure-script.ts @@ -0,0 +1,52 @@ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' + +export type HiddenPressureOutputMode = 'tui' | 'plain' | 'title' | 'latin' | 'rich-model' + +export function pressureOutputScript(runId: string, mode: HiddenPressureOutputMode): string { + const headerPrefix = mode === 'tui' || mode === 'rich-model' ? '\\x1b[0m' : '' + const donePrefix = mode === 'tui' || mode === 'rich-model' ? '\\x1b[0m' : '' + const chunkExpression = + mode === 'plain' + ? "'plain pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\n'" + : mode === 'latin' + ? "'latin pressure café déjà vu São Tomé Żubrówka pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\n'" + : mode === 'title' + ? "'\\x1b]0;title pressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x07'" + : mode === 'rich-model' + ? "'\\x1b[?2026h\\x1b[?1049h\\x1b[2J\\x1b[H\\x1b[?25l\\x1b[2;36m╭────────────────────────────────────────╮\\x1b[0m\\r\\n\\x1b[2;36m│ rich model pane=' + paneIndex + ' frame=' + frame + ' 😀 ███░ │\\x1b[0m\\r\\n\\x1b[2;36m│ ' + chunkBody + ' │\\x1b[0m\\r\\n\\x1b[2;36m╰────────────────────────────────────────╯\\x1b[0m\\x1b[6;4H\\x1b[?25h\\x1b[?2026l\\n'" + : "'\\x1b[?2026h\\x1b[1;1Hpressure pane=' + paneIndex + ' frame=' + frame + ' ' + chunkBody + '\\x1b[?2026l\\n'" + return ` +const paneIndex = process.argv[2] ?? '0' +const targetChars = Number(process.argv[3] ?? '0') +const delayMs = Number(process.argv[4] ?? '0') +const header = '${headerPrefix}OPENCODE_PRESSURE_START_${runId}_' + paneIndex + '\\n' +const chunkBody = '#'.repeat(8192) +let written = 0 +process.stdout.write(header) +function writeMore() { + let canContinue = true + while (canContinue && written < targetChars) { + const frame = String(written).padStart(8, '0') + const chunk = ${chunkExpression} + written += chunk.length + canContinue = process.stdout.write(chunk) + } + if (written < targetChars) { + process.stdout.once('drain', writeMore) + return + } + process.stdout.write('${donePrefix}OPENCODE_PRESSURE_DONE_${runId}_' + paneIndex + '\\n') +} +setTimeout(writeMore, Number.isFinite(delayMs) && delayMs > 0 ? delayMs : 0) +` +} + +export function writePressureOutputScript( + scriptPath: string, + runId: string, + mode: HiddenPressureOutputMode +): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync(scriptPath, pressureOutputScript(runId, mode)) +} diff --git a/tests/e2e/artificial-opencode-main-pressure-scenario.ts b/tests/e2e/artificial-opencode-main-pressure-scenario.ts index da2bd0f9a0f..1c13a5d26a0 100644 --- a/tests/e2e/artificial-opencode-main-pressure-scenario.ts +++ b/tests/e2e/artificial-opencode-main-pressure-scenario.ts @@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto' import { rmSync } from 'node:fs' import path from 'node:path' import { sendToTerminal } from './helpers/terminal' -import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-scenario' +import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-script' import { annotateScrollMeasurement, getResponsiveScrollPath, @@ -42,6 +42,7 @@ type MainPressureSchedulerSnapshot = { // Why: peak queued chars is noisy at the byte level on CI, but a coarse cap // still catches renderer queue growth that dropped-backlog/latency checks miss. const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 5 * 1024 * 1024 +const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 type MainPressureDeps< TMeasurement, @@ -124,7 +125,7 @@ export async function runMainPressureScenario< const pressureScriptPath = path.join(testRepoPath, `.orca-opencode-pressure-load-${runId}.mjs`) await seedActiveTerminalScrollback(orcaPage, typingPane.ptyId, scrollRunId) deps.writeInteractivePromptScript(typingScriptPath, runId) - writePressureOutputScript(pressureScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, 'tui') await deps.resetTerminalPtyOutputDebug(orcaPage) await deps.holdTerminalAckGate( orcaPage, @@ -272,7 +273,9 @@ function expectMainPressureAndTyping { + await page.evaluate(() => { + const store = window.__store + const state = store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const textarea = pane?.container.querySelector('.xterm-helper-textarea') + if (!pane || !textarea) { + throw new Error('Active terminal input is unavailable') + } + pane.terminal.focus() + textarea.focus() + }) +} + +export async function focusPane(page: Page, paneKey: string): Promise { + const separator = paneKey.indexOf(':') + const tabId = paneKey.slice(0, separator) + const leafId = paneKey.slice(separator + 1) + await page.evaluate( + ({ tabId, leafId }) => { + const manager = window.__paneManagers?.get(tabId) + const pane = manager?.getPanes?.().find((candidate) => candidate.leafId === leafId) + if (!manager || !pane) { + throw new Error(`Unable to focus pane ${tabId}:${leafId}`) + } + manager.setActivePane?.(pane.id, { focus: true }) + }, + { tabId, leafId } + ) +} + +export async function ensureActiveWorktreePaneLoad( + page: Page, + paneCount: number +): Promise { + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + let snapshot = await waitForPaneIdentitySnapshot(page, 1) + while (snapshot.panes.length < paneCount) { + await splitActiveTerminalPane(page, snapshot.panes.length % 2 === 0 ? 'horizontal' : 'vertical') + snapshot = await waitForPaneIdentitySnapshot(page, snapshot.panes.length + 1) + } + return snapshot.panes.slice(0, paneCount).map((pane) => ({ + paneKey: `${snapshot.tabId}:${pane.leafId}`, + ptyId: pane.ptyId ?? '' + })) +} + +export async function waitForMarkerLatency( + page: Page, + marker: string, + timeoutMs: number +): Promise { + const start = performance.now() + while (performance.now() - start < timeoutMs) { + if ((await getTerminalContent(page, 12_000)).includes(marker)) { + return performance.now() - start + } + await page.waitForTimeout(5) + } + throw new Error(`Timed out waiting for terminal marker ${marker}`) +} + +export async function getTerminalContentForPtyId( + page: Page, + ptyId: string, + charLimit = 12_000 +): Promise { + return page.evaluate( + ({ ptyId, charLimit }) => { + for (const manager of window.__paneManagers?.values() ?? []) { + for (const pane of manager.getPanes?.() ?? []) { + if (pane.container?.dataset?.ptyId === ptyId) { + return (pane.serializeAddon?.serialize?.() ?? '').slice(-charLimit) + } + } + } + return '' + }, + { ptyId, charLimit } + ) +} + +export async function waitForTerminalOutputForPtyId( + page: Page, + ptyId: string, + expected: string, + timeoutMs: number +): Promise { + await expect + .poll(async () => (await getTerminalContentForPtyId(page, ptyId)).includes(expected), { + timeout: timeoutMs, + message: `Terminal PTY ${ptyId} did not contain "${expected}"` + }) + .toBe(true) +} diff --git a/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts new file mode 100644 index 00000000000..c6a9d6e35a4 --- /dev/null +++ b/tests/e2e/artificial-opencode-revisit-pressure-scenario.ts @@ -0,0 +1,311 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { expect } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { rmSync } from 'node:fs' +import path from 'node:path' +import { writePressureOutputScript } from './artificial-opencode-hidden-pressure-script' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' + +type RevisitPressurePane = { paneKey: string; ptyId: string } + +type RevisitPressureMeasurement = { + medianLatencyMs: number + worstLatencyMs: number + maxTimerDriftMs: number +} + +// Why: the renderer hidden-skip counters were deleted with the skip grammar; +// only the mode-2031 fact-reply counter still exists renderer-side. +type RevisitPressureDebug = { hiddenRendererMode2031ReplyCount: number } + +type RevisitPressureSchedulerSnapshot = { + peakQueuedChars: number + droppedBacklogCount: number +} + +type RevisitPressureMainSnapshot = { + peakPendingChars: number + peakRendererInFlightChars: number + ackGatedFlushSkipCount: number +} + +type RevisitPressureAckGate = { heldAckChars: number } + +type RevisitPressureDeps< + TMeasurement extends RevisitPressureMeasurement, + TDebug extends RevisitPressureDebug, + TScheduler extends RevisitPressureSchedulerSnapshot, + TMainPressure extends RevisitPressureMainSnapshot, + TAckGate extends RevisitPressureAckGate +> = { + annotateTypingMeasurement: ( + testInfo: TestInfo, + type: string, + paneCount: number, + measurement: TMeasurement, + debug: TDebug | null, + scheduler: TScheduler | null, + mainPressure: TMainPressure | null, + ackGate: TAckGate | null + ) => void + ensureActiveWorktreePaneLoad: (page: Page, paneCount: number) => Promise + focusPane: (page: Page, paneKey: string) => Promise + holdTerminalAckGate: (page: Page, ptyIds: string[]) => Promise + measureTypingDuringLoad: ( + page: Page, + scriptPath: string, + ptyId: string, + runId: string + ) => Promise + readMainPtyPressureDebug: (page: Page) => Promise + readTerminalAckGateDebug: (page: Page) => Promise + readTerminalOutputSchedulerDebug: (page: Page) => Promise + readTerminalPtyOutputDebug: (page: Page) => Promise + releaseTerminalAckGate: (page: Page) => Promise + resetTerminalPtyOutputDebug: (page: Page) => Promise + waitForMainPtyPressureBacklog: (page: Page) => Promise + writeInteractivePromptScript: (scriptPath: string, runId: string) => void +} + +export async function runRendererBackpressureRevisitScenario< + TMeasurement extends RevisitPressureMeasurement, + TDebug extends RevisitPressureDebug, + TScheduler extends RevisitPressureSchedulerSnapshot, + TMainPressure extends RevisitPressureMainSnapshot, + TAckGate extends RevisitPressureAckGate +>({ + backgroundPaneCount, + deps, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + mainRendererPressureTargetChars, + pressureOutputChars, + orcaPage, + testInfo, + testRepoPath +}: { + backgroundPaneCount: number + deps: RevisitPressureDeps + maxMedianKeyLatencyMs: number + maxRendererSchedulerQueuedChars: number + maxTimerDriftMs: number + maxWorstKeyLatencyMs: number + mainRendererPressureTargetChars: number + pressureOutputChars: number + orcaPage: Page + testInfo: TestInfo + testRepoPath: string +}): Promise { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== firstWorktreeId) + expect(Boolean(secondWorktreeId), 'renderer backpressure revisit needs a second worktree').toBe( + true + ) + if (!secondWorktreeId) { + return + } + + const runId = randomUUID() + const typingPtyReadyMarker = `OPENCODE_REVISIT_TYPING_PTY_READY_${runId}` + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const typingPtyId = await waitForActivePanePtyId(orcaPage) + await sendToTerminal(orcaPage, typingPtyId, `printf '\\n${typingPtyReadyMarker}\\n'\r`) + await waitForMarkerLatency(orcaPage, typingPtyReadyMarker, 10_000) + + await switchToWorktree(orcaPage, firstWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const panes = await deps.ensureActiveWorktreePaneLoad(orcaPage, backgroundPaneCount + 1) + const [revisitPane, ...loadPanes] = panes + await deps.focusPane(orcaPage, revisitPane.paneKey) + + const typingScriptPath = path.join(testRepoPath, `.orca-revisit-typing-${runId}.mjs`) + const pressureScriptPath = path.join(testRepoPath, `.orca-revisit-pressure-${runId}.mjs`) + const revisitMarker = `OPENCODE_REVISIT_READY_${runId}` + const pressureDoneMarker = `OPENCODE_PRESSURE_DONE_${runId}_0` + deps.writeInteractivePromptScript(typingScriptPath, runId) + writePressureOutputScript(pressureScriptPath, runId, 'tui') + await deps.resetTerminalPtyOutputDebug(orcaPage) + await deps.holdTerminalAckGate( + orcaPage, + loadPanes.map((pane) => pane.ptyId) + ) + try { + await startRealPtyPressureCommands({ + loadPanes, + orcaPage, + pressureOutputChars, + pressureScriptPath + }) + const pressureBeforeSwitch = await deps.waitForMainPtyPressureBacklog(orcaPage) + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const measurement = await deps.measureTypingDuringLoad( + orcaPage, + typingScriptPath, + typingPtyId, + runId + ) + const duringPressure = await deps.readMainPtyPressureDebug(orcaPage) + const ackGate = await deps.readTerminalAckGateDebug(orcaPage) + const scheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) + const hiddenDebug = await deps.readTerminalPtyOutputDebug(orcaPage) + deps.annotateTypingMeasurement( + testInfo, + 'opencode-main-pressure-worktree-revisit-typing', + panes.length + 1, + measurement, + hiddenDebug, + scheduler, + duringPressure, + ackGate + ) + + expectPressureStayedBounded({ + ackGate, + mainRendererPressureTargetChars, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + measurement, + pressureBeforeSwitch, + scheduler, + duringPressure + }) + + await switchToWorktree(orcaPage, firstWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await deps.focusPane(orcaPage, revisitPane.paneKey) + await sendToTerminal(orcaPage, revisitPane.ptyId, `printf '\\n${revisitMarker}\\n'\r`) + const revisitLatencyMs = await waitForMarkerLatency(orcaPage, revisitMarker, 10_000) + testInfo.annotations.push({ + type: 'opencode-main-pressure-worktree-revisit-marker', + description: `panes=${panes.length + 1} revisit=${revisitLatencyMs.toFixed( + 1 + )}ms heldAckChars=${ackGate?.heldAckChars ?? 0}` + }) + expect(revisitLatencyMs).toBeLessThan(maxWorstKeyLatencyMs) + + await deps.releaseTerminalAckGate(orcaPage) + await deps.focusPane(orcaPage, loadPanes[0]?.paneKey ?? revisitPane.paneKey) + const pressureDrainLatencyMs = await waitForMarkerLatency(orcaPage, pressureDoneMarker, 20_000) + const finalScheduler = await deps.readTerminalOutputSchedulerDebug(orcaPage) + testInfo.annotations.push({ + type: 'opencode-main-pressure-worktree-revisit-drain', + description: `panes=${panes.length + 1} drain=${pressureDrainLatencyMs.toFixed( + 1 + )}ms rendererPeakQueuedChars=${finalScheduler?.peakQueuedChars ?? 0} rendererDroppedBacklogs=${ + finalScheduler?.droppedBacklogCount ?? 0 + }` + }) + expect(finalScheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) + expect(finalScheduler?.peakQueuedChars ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual( + maxRendererSchedulerQueuedChars + ) + } finally { + await deps.releaseTerminalAckGate(orcaPage) + await sendToTerminal(orcaPage, typingPtyId, '\x03').catch(() => undefined) + await sendToTerminal(orcaPage, revisitPane.ptyId, '\x03').catch(() => undefined) + await Promise.all( + loadPanes.map((pane) => sendToTerminal(orcaPage, pane.ptyId, '\x03').catch(() => undefined)) + ) + rmSync(typingScriptPath, { force: true }) + rmSync(pressureScriptPath, { force: true }) + } +} + +async function startRealPtyPressureCommands({ + loadPanes, + orcaPage, + pressureOutputChars, + pressureScriptPath +}: { + loadPanes: RevisitPressurePane[] + orcaPage: Page + pressureOutputChars: number + pressureScriptPath: string +}): Promise { + await Promise.all( + loadPanes.map((pane, paneIndex) => + sendToTerminal( + orcaPage, + pane.ptyId, + `node ${JSON.stringify(pressureScriptPath)} ${paneIndex} ${pressureOutputChars}\r` + ) + ) + ) +} + +async function waitForMarkerLatency( + page: Page, + marker: string, + timeoutMs: number +): Promise { + const start = performance.now() + while (performance.now() - start < timeoutMs) { + if ((await getTerminalContent(page, 12_000)).includes(marker)) { + return performance.now() - start + } + await page.waitForTimeout(5) + } + throw new Error(`Timed out waiting for terminal marker ${marker}`) +} + +function expectPressureStayedBounded({ + ackGate, + mainRendererPressureTargetChars, + maxMedianKeyLatencyMs, + maxRendererSchedulerQueuedChars, + maxTimerDriftMs, + maxWorstKeyLatencyMs, + measurement, + pressureBeforeSwitch, + scheduler, + duringPressure +}: { + ackGate: RevisitPressureAckGate | null + mainRendererPressureTargetChars: number + maxMedianKeyLatencyMs: number + maxRendererSchedulerQueuedChars: number + maxTimerDriftMs: number + maxWorstKeyLatencyMs: number + measurement: TMeasurement + pressureBeforeSwitch: RevisitPressureMainSnapshot + scheduler: RevisitPressureSchedulerSnapshot | null + duringPressure: RevisitPressureMainSnapshot | null +}): void { + expect(pressureBeforeSwitch.peakPendingChars).toBeGreaterThan(0) + expect(pressureBeforeSwitch.ackGatedFlushSkipCount).toBeGreaterThan(0) + expect(duringPressure?.peakRendererInFlightChars ?? 0).toBeGreaterThanOrEqual( + mainRendererPressureTargetChars + ) + expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(0) + expect(scheduler?.droppedBacklogCount ?? Number.POSITIVE_INFINITY).toBe(0) + expect(scheduler?.peakQueuedChars ?? Number.POSITIVE_INFINITY).toBeLessThanOrEqual( + maxRendererSchedulerQueuedChars + ) + expect(measurement.medianLatencyMs).toBeLessThan(maxMedianKeyLatencyMs) + expect(measurement.worstLatencyMs).toBeLessThan(maxWorstKeyLatencyMs) + expect(measurement.maxTimerDriftMs).toBeLessThan(maxTimerDriftMs) +} diff --git a/tests/e2e/artificial-opencode-terminal-load.spec.ts b/tests/e2e/artificial-opencode-terminal-load.spec.ts index d1e8cbec3aa..99e31cd96db 100644 --- a/tests/e2e/artificial-opencode-terminal-load.spec.ts +++ b/tests/e2e/artificial-opencode-terminal-load.spec.ts @@ -12,22 +12,23 @@ import { waitForSessionReady } from './helpers/store' import { - getTerminalContent, sendToTerminal, - splitActiveTerminalPane, waitForActivePanePtyId, - waitForActiveTerminalManager, - waitForPaneIdentitySnapshot + waitForActiveTerminalManager } from './helpers/terminal' +import { + ensureActiveWorktreePaneLoad, + focusActiveTerminalInput, + focusPane, + waitForMarkerLatency, + waitForTerminalOutputForPtyId +} from './artificial-opencode-pane-interactions' import { runHiddenRealPtyPressureScenario } from './artificial-opencode-hidden-pressure-scenario' +import type { HiddenPressureOutputMode } from './artificial-opencode-hidden-pressure-script' import { runMainPressureScenario } from './artificial-opencode-main-pressure-scenario' +import { runRendererBackpressureRevisitScenario } from './artificial-opencode-revisit-pressure-scenario' import { startSyntheticOpenCodeInjection } from './artificial-opencode-synthetic-injection' -type TerminalLoadPane = { - paneKey: string - ptyId: string -} - type TypingMeasurement = { latencies: number[] medianLatencyMs: number @@ -55,9 +56,10 @@ type SyntheticOpenCodeWindow = Window & { } } +// Why: the renderer hidden-skip grammar is deleted — hidden bytes are dropped +// in main (gate) or ride the background queue. Only the mode-2031 fact-reply +// counter still has a renderer-side producer. type TerminalPtyOutputDebugSnapshot = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number hiddenRendererMode2031ReplyCount: number } @@ -98,6 +100,12 @@ type MainPtyPressureDebugSnapshot = { peakRendererInFlightChars: number peakMaxRendererInFlightCharsByPty: number ackGatedFlushSkipCount: number + // Phase-4 hidden-delivery gate: bytes dropped in main after model ingestion. + hiddenDeliveryGatedPtyCount: number + deliveryInterestPtyCount: number + hiddenDeliveryDroppedChars: number + hiddenDeliveryDroppedChunks: number + pendingDroppedChars: number } const KEY_LATENCY_SAMPLES = 'abcdefghijklmnop' @@ -110,6 +118,7 @@ const HIDDEN_PRESSURE_START_DELAY_MS = 1200 const DEFAULT_FRAME_COUNT = 180 const DEFAULT_FRAME_INTERVAL_MS = 6 const TIMER_SAMPLE_MS = 16 +const MAIN_RENDERER_PRESSURE_TARGET_CHARS = 2 * 1024 * 1024 // Why: these are regression budgets, not observed baselines. Repeated local // 100-pane OpenCode-scale runs are below 50ms worst-key latency; keep enough // CI headroom while still failing changes that make typing visibly sluggish. @@ -126,6 +135,7 @@ const MAX_WORST_KEY_LATENCY_UNDER_LOAD_MS = 3_000 // without visible typing lag. Keep this as a smoke gate, not a CPU lottery. const MAX_TIMER_DRIFT_MS = 250 const MAX_SCROLL_LATENCY_MS = 150 +const MAX_RENDERER_SCHEDULER_QUEUED_CHARS = 3 * 1024 * 1024 function readPositiveInt(name: string, fallback: number): number { const raw = process.env[name] @@ -212,111 +222,6 @@ function writeInteractivePromptScript(scriptPath: string, runId: string): void { writeFileSync(scriptPath, interactivePromptScript(runId)) } -async function focusActiveTerminalInput(page: Page): Promise { - await page.evaluate(() => { - const store = window.__store - const state = store?.getState() - const worktreeId = state?.activeWorktreeId - const tabId = - state?.activeTabType === 'terminal' - ? state.activeTabId - : worktreeId - ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) - : null - const manager = tabId ? window.__paneManagers?.get(tabId) : null - const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null - const textarea = pane?.container.querySelector('.xterm-helper-textarea') - if (!pane || !textarea) { - throw new Error('Active terminal input is unavailable') - } - pane.terminal.focus() - textarea.focus() - }) -} - -async function focusPane(page: Page, paneKey: string): Promise { - const separator = paneKey.indexOf(':') - const tabId = paneKey.slice(0, separator) - const leafId = paneKey.slice(separator + 1) - await page.evaluate( - ({ tabId, leafId }) => { - const manager = window.__paneManagers?.get(tabId) - const pane = manager?.getPanes?.().find((candidate) => candidate.leafId === leafId) - if (!manager || !pane) { - throw new Error(`Unable to focus pane ${tabId}:${leafId}`) - } - manager.setActivePane?.(pane.id, { focus: true }) - }, - { tabId, leafId } - ) -} - -async function ensureActiveWorktreePaneLoad( - page: Page, - paneCount: number -): Promise { - await ensureTerminalVisible(page) - await waitForActiveTerminalManager(page, 30_000) - let snapshot = await waitForPaneIdentitySnapshot(page, 1) - while (snapshot.panes.length < paneCount) { - await splitActiveTerminalPane(page, snapshot.panes.length % 2 === 0 ? 'horizontal' : 'vertical') - snapshot = await waitForPaneIdentitySnapshot(page, snapshot.panes.length + 1) - } - return snapshot.panes.slice(0, paneCount).map((pane) => ({ - paneKey: `${snapshot.tabId}:${pane.leafId}`, - ptyId: pane.ptyId ?? '' - })) -} - -async function waitForMarkerLatency( - page: Page, - marker: string, - timeoutMs: number -): Promise { - const start = performance.now() - while (performance.now() - start < timeoutMs) { - if ((await getTerminalContent(page, 12_000)).includes(marker)) { - return performance.now() - start - } - await page.waitForTimeout(5) - } - throw new Error(`Timed out waiting for terminal marker ${marker}`) -} - -async function getTerminalContentForPtyId( - page: Page, - ptyId: string, - charLimit = 12_000 -): Promise { - return page.evaluate( - ({ ptyId, charLimit }) => { - for (const manager of window.__paneManagers?.values() ?? []) { - for (const pane of manager.getPanes?.() ?? []) { - if (pane.container?.dataset?.ptyId === ptyId) { - return (pane.serializeAddon?.serialize?.() ?? '').slice(-charLimit) - } - } - } - return '' - }, - { ptyId, charLimit } - ) -} - -async function waitForTerminalOutputForPtyId( - page: Page, - ptyId: string, - expected: string, - timeoutMs: number -): Promise { - await expect - .poll(async () => (await getTerminalContentForPtyId(page, ptyId)).includes(expected), { - timeout: timeoutMs, - message: `Terminal PTY ${ptyId} did not contain "${expected}"` - }) - .toBe(true) -} - function median(values: number[]): number { const sorted = [...values].sort((a, b) => a - b) return sorted[Math.floor(sorted.length / 2)] ?? 0 @@ -429,7 +334,7 @@ async function waitForMainPtyPressureBacklog(page: Page): Promise { lastSnapshot = await readMainPtyPressureDebug(page) return ( - (lastSnapshot?.peakRendererInFlightChars ?? 0) >= 8 * 1024 * 1024 && + (lastSnapshot?.peakRendererInFlightChars ?? 0) >= MAIN_RENDERER_PRESSURE_TARGET_CHARS && (lastSnapshot?.peakPendingChars ?? 0) > 0 && (lastSnapshot?.ackGatedFlushSkipCount ?? 0) > 0 ) @@ -456,14 +361,12 @@ function annotateTypingMeasurement( mainPressure: MainPtyPressureDebugSnapshot | null = null, ackGate: TerminalPtyAckGateSnapshot | null = null ): void { - const hiddenSkipSummary = debug - ? ` hiddenSkips=${debug.hiddenRendererSkipCount} hiddenSkippedChars=${debug.hiddenRendererSkippedChars} mode2031Replies=${debug.hiddenRendererMode2031ReplyCount}` - : '' + const mode2031Summary = debug ? ` mode2031Replies=${debug.hiddenRendererMode2031ReplyCount}` : '' const schedulerSummary = scheduler ? ` deferredForegroundEnqueue=${scheduler.deferredForegroundEnqueueCount} deferredForegroundWrite=${scheduler.deferredForegroundWriteCount} scheduledDrains=${scheduler.scheduledDrainCount} rendererQueuedTerminals=${scheduler.queuedTerminalCount} rendererQueuedChars=${scheduler.queuedChars} rendererPeakQueuedTerminals=${scheduler.peakQueuedTerminalCount} rendererPeakQueuedChars=${scheduler.peakQueuedChars} rendererPeakQueuedCharsByTerminal=${scheduler.peakQueuedCharsByTerminal} rendererDroppedBacklogs=${scheduler.droppedBacklogCount}` : '' const mainPressureSummary = mainPressure - ? ` mainPendingPtys=${mainPressure.pendingPtyCount} mainPendingChars=${mainPressure.pendingChars} mainMaxPendingChars=${mainPressure.maxPendingCharsByPty} mainInFlightPtys=${mainPressure.rendererInFlightPtyCount} mainInFlightChars=${mainPressure.rendererInFlightChars} mainMaxInFlightChars=${mainPressure.maxRendererInFlightCharsByPty} mainActivePtys=${mainPressure.activeRendererPtyCount} mainFlushScheduled=${mainPressure.flushScheduled} mainPeakPendingChars=${mainPressure.peakPendingChars} mainPeakMaxPendingChars=${mainPressure.peakMaxPendingCharsByPty} mainPeakInFlightChars=${mainPressure.peakRendererInFlightChars} mainPeakMaxInFlightChars=${mainPressure.peakMaxRendererInFlightCharsByPty} mainAckGatedFlushSkips=${mainPressure.ackGatedFlushSkipCount}` + ? ` mainPendingPtys=${mainPressure.pendingPtyCount} mainPendingChars=${mainPressure.pendingChars} mainMaxPendingChars=${mainPressure.maxPendingCharsByPty} mainInFlightPtys=${mainPressure.rendererInFlightPtyCount} mainInFlightChars=${mainPressure.rendererInFlightChars} mainMaxInFlightChars=${mainPressure.maxRendererInFlightCharsByPty} mainActivePtys=${mainPressure.activeRendererPtyCount} mainFlushScheduled=${mainPressure.flushScheduled} mainPeakPendingChars=${mainPressure.peakPendingChars} mainPeakMaxPendingChars=${mainPressure.peakMaxPendingCharsByPty} mainPeakInFlightChars=${mainPressure.peakRendererInFlightChars} mainPeakMaxInFlightChars=${mainPressure.peakMaxRendererInFlightCharsByPty} mainAckGatedFlushSkips=${mainPressure.ackGatedFlushSkipCount} mainHiddenGatedPtys=${mainPressure.hiddenDeliveryGatedPtyCount} mainHiddenDroppedChars=${mainPressure.hiddenDeliveryDroppedChars} mainPendingDroppedChars=${mainPressure.pendingDroppedChars}` : '' const ackGateSummary = ackGate ? ` heldAckPtys=${ackGate.heldAckCount} heldAckChars=${ackGate.heldAckChars} gatedAckPtys=${ackGate.gatedPtyCount}` @@ -476,7 +379,7 @@ function annotateTypingMeasurement( 1 )}ms maxTimerDrift=${measurement.maxTimerDriftMs.toFixed(1)}ms samples=${measurement.latencies .map((value) => value.toFixed(1)) - .join(',')}${hiddenSkipSummary}${schedulerSummary}${mainPressureSummary}${ackGateSummary}` + .join(',')}${mode2031Summary}${schedulerSummary}${mainPressureSummary}${ackGateSummary}` }) } @@ -535,8 +438,7 @@ async function measureCrossWorkspaceTypingDuringHiddenLoad({ scheduler, mainPressure ) - expect(debug?.hiddenRendererSkipCount ?? 0).toBe(0) - expect(debug?.hiddenRendererSkippedChars ?? 0).toBe(0) + expect(scheduler?.rendererDroppedBacklogs ?? 0).toBe(0) expect(measurement.medianLatencyMs).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS) expect(measurement.worstLatencyMs).toBeLessThan(MAX_WORST_KEY_LATENCY_UNDER_LOAD_MS) expect(measurement.maxTimerDriftMs).toBeLessThan(MAX_TIMER_DRIFT_MS) @@ -571,26 +473,28 @@ async function runConfiguredMainPressureScenario({ maxScrollLatencyMs: MAX_SCROLL_LATENCY_MS, maxTimerDriftMs: MAX_TIMER_DRIFT_MS, maxWorstKeyLatencyMs: MAX_WORST_KEY_LATENCY_UNDER_LOAD_MS, - deps: { - annotateTypingMeasurement, - ensureActiveWorktreePaneLoad, - focusPane, - holdTerminalAckGate, - measureTypingDuringLoad, - readMainPtyPressureDebug, - readTerminalAckGateDebug, - readTerminalOutputSchedulerDebug, - readTerminalPtyOutputDebug, - releaseTerminalAckGate, - resetTerminalPtyOutputDebug, - waitForActiveWorktree, - waitForMainPtyPressureBacklog, - waitForSessionReady, - writeInteractivePromptScript - } + deps: terminalLoadScenarioDeps }) } +const terminalLoadScenarioDeps = { + annotateTypingMeasurement, + ensureActiveWorktreePaneLoad, + focusPane, + holdTerminalAckGate, + measureTypingDuringLoad, + readMainPtyPressureDebug, + readTerminalAckGateDebug, + readTerminalOutputSchedulerDebug, + readTerminalPtyOutputDebug, + releaseTerminalAckGate, + resetTerminalPtyOutputDebug, + waitForActiveWorktree, + waitForMainPtyPressureBacklog, + waitForSessionReady, + writeInteractivePromptScript +} + test.describe('Artificial OpenCode terminal load', () => { test.describe.configure({ mode: 'serial' }) @@ -690,6 +594,25 @@ test.describe('Artificial OpenCode terminal load', () => { }) }) + test('keeps renderer backpressure bounded across worktree revisit', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + await runRendererBackpressureRevisitScenario({ + backgroundPaneCount: PRESSURE_BACKGROUND_PANES, + deps: terminalLoadScenarioDeps, + mainRendererPressureTargetChars: MAIN_RENDERER_PRESSURE_TARGET_CHARS, + maxMedianKeyLatencyMs: MAX_MEDIAN_KEY_LATENCY_MS, + maxRendererSchedulerQueuedChars: MAX_RENDERER_SCHEDULER_QUEUED_CHARS, + maxTimerDriftMs: MAX_TIMER_DRIFT_MS, + maxWorstKeyLatencyMs: MAX_WORST_KEY_LATENCY_MS, + orcaPage, + pressureOutputChars: PRESSURE_OUTPUT_CHARS, + testInfo, + testRepoPath + }) + }) + for (const paneCount of SCALE_PRESSURE_PANES) { test(`keeps active interactions responsive at ${paneCount} ACK-backpressured OpenCode PTYs`, async ({ orcaPage, @@ -770,7 +693,8 @@ test.describe('Artificial OpenCode terminal load', () => { testRepoPath: string, testInfo: TestInfo, hiddenPaneCount: number, - annotationSuffix?: string + annotationSuffix?: string, + pressureOutputMode?: HiddenPressureOutputMode ): Promise { await runHiddenRealPtyPressureScenario({ orcaPage, @@ -778,35 +702,56 @@ test.describe('Artificial OpenCode terminal load', () => { annotationSuffix, hiddenPaneCount, pressureOutputChars: PRESSURE_OUTPUT_CHARS, + pressureOutputMode, + // Why: the 10s codex startup renderer-query window is deleted — every + // pressure mode measures steady-state model restore with one delay. pressureStartDelayMs: HIDDEN_PRESSURE_START_DELAY_MS, testInfo, - deps: { - annotateTypingMeasurement, - ensureActiveWorktreePaneLoad, - holdTerminalAckGate, - measureTypingDuringLoad, - readMainPtyPressureDebug, - readTerminalAckGateDebug, - readTerminalOutputSchedulerDebug, - readTerminalPtyOutputDebug, - releaseTerminalAckGate, - resetTerminalPtyOutputDebug, - waitForMainPtyPressureBacklog, - writeInteractivePromptScript - } + deps: terminalLoadScenarioDeps + }) + } + const hiddenPressureCases: { + title: string + suffix?: string + mode?: HiddenPressureOutputMode + }[] = [ + { title: 'keeps typing responsive while hidden real PTYs are ACK-backpressured' }, + // Why: "withholds renderer delivery" — hidden bytes are dropped in main by + // the delivery gate; the renderer no longer skip-scans chunks (Phase 6). + { + title: 'withholds renderer delivery for plain hidden PTY output while preserving restore', + suffix: '-plain', + mode: 'plain' + }, + { + title: 'withholds renderer delivery for Latin hidden PTY output while preserving restore', + suffix: '-latin', + mode: 'latin' + }, + { + title: + 'withholds renderer delivery for title-only hidden PTY output while preserving restore', + suffix: '-title', + mode: 'title' + }, + { + title: 'restores rich hidden model output under ACK-backpressured PTY output', + suffix: '-rich-model', + mode: 'rich-model' + } + ] + for (const hiddenPressureCase of hiddenPressureCases) { + test(hiddenPressureCase.title, async ({ orcaPage, testRepoPath }, testInfo) => { + await runConfiguredHiddenRealPtyPressureScenario( + orcaPage, + testRepoPath, + testInfo, + HIDDEN_PRESSURE_PANES, + hiddenPressureCase.suffix, + hiddenPressureCase.mode + ) }) } - test('keeps typing responsive while hidden real PTYs are ACK-backpressured', async ({ - orcaPage, - testRepoPath - }, testInfo) => { - await runConfiguredHiddenRealPtyPressureScenario( - orcaPage, - testRepoPath, - testInfo, - HIDDEN_PRESSURE_PANES - ) - }) for (const paneCount of SCALE_HIDDEN_PRESSURE_PANES) { test(`keeps hidden restore responsive with ${paneCount} ACK-backpressured real PTYs`, async ({ orcaPage, diff --git a/tests/e2e/global-setup.ts b/tests/e2e/global-setup.ts index da3e58cf3b5..6cddd6399ba 100644 --- a/tests/e2e/global-setup.ts +++ b/tests/e2e/global-setup.ts @@ -26,11 +26,11 @@ export default function globalSetup(): void { // ── 1. Build the Electron app ────────────────────────────────────── if (process.env.SKIP_BUILD && existsSync(outMain)) { - console.log('[e2e] SKIP_BUILD set and out/main/index.js exists — skipping build') + console.error('[e2e] SKIP_BUILD set and out/main/index.js exists — skipping build') } else { // Why: --mode e2e is the build-time signal that exposes window.__store; // the explicit env var keeps older local overrides working too. - console.log('[e2e] Building Electron app with electron-vite build --mode e2e...') + console.error('[e2e] Building Electron app with electron-vite build --mode e2e...') execSync('npx electron-vite build --mode e2e', { env: { ...process.env, VITE_EXPOSE_STORE: 'true' }, cwd: root, @@ -39,13 +39,13 @@ export default function globalSetup(): void { // when healthy; global setup should not fail before specs can run. timeout: ELECTRON_E2E_BUILD_TIMEOUT_MS }) - console.log('[e2e] Build complete.') + console.error('[e2e] Build complete.') } if (process.env.ORCA_E2E_SSH_LOCALHOST === '1' || process.env.ORCA_E2E_SSH_DOCKER === '1') { // Why: the SSH specs deploy Orca's relay from out/relay. The // normal Electron E2E build does not produce that bundle, so build it only // for explicit SSH runs. - console.log('[e2e] Building SSH relay bundle for SSH E2E...') + console.error('[e2e] Building SSH relay bundle for SSH E2E...') execSync('pnpm run build:relay', { cwd: root, stdio: 'inherit', @@ -90,9 +90,9 @@ export default function globalSetup(): void { cwd: testRepoDir, stdio: 'pipe' }) - console.log(`[e2e] Secondary worktree created at ${worktreeDir}`) + console.error(`[e2e] Secondary worktree created at ${worktreeDir}`) // Write the test repo path so the fixture can read it writeFileSync(TEST_REPO_PATH_FILE, testRepoDir) - console.log(`[e2e] Test repo created at ${testRepoDir}`) + console.error(`[e2e] Test repo created at ${testRepoDir}`) } diff --git a/tests/e2e/global-teardown.ts b/tests/e2e/global-teardown.ts index 61d4a2315d7..abb797878af 100644 --- a/tests/e2e/global-teardown.ts +++ b/tests/e2e/global-teardown.ts @@ -32,7 +32,7 @@ export default function globalTeardown(): void { } rmSync(testRepoDir, { recursive: true, force: true }) - console.log(`[e2e] Cleaned up test repo at ${testRepoDir}`) + console.error(`[e2e] Cleaned up test repo at ${testRepoDir}`) } rmSync(TEST_REPO_PATH_FILE, { force: true }) diff --git a/tests/e2e/helpers/orca-app.ts b/tests/e2e/helpers/orca-app.ts index 0ed8ed57370..eb49dfeba5e 100644 --- a/tests/e2e/helpers/orca-app.ts +++ b/tests/e2e/helpers/orca-app.ts @@ -21,23 +21,14 @@ import { type ElectronApplication, type TestInfo } from '@stablyai/playwright-test' -import { - existsSync, - mkdtempSync, - mkdirSync, - readFileSync, - realpathSync, - rmSync, - writeFileSync -} from 'node:fs' -import { execSync } from 'node:child_process' -import { randomUUID } from 'node:crypto' +import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' import { TEST_REPO_PATH_FILE } from '../global-setup' import { cleanupE2EDaemons, closeElectronAppForE2E } from './electron-process-shutdown' import { getOrcaElectronLaunchArgs } from './electron-launch-args' import { getE2ECompletedOnboardingProfile } from './e2e-completed-onboarding-profile' +import { createSeededTestRepo, isValidGitRepo } from './seeded-test-repo' type OrcaTestFixtures = { electronApp: ElectronApplication @@ -51,6 +42,14 @@ type OrcaTestFixtures = { // Why: most E2E specs need a ready project before assertions start. Golden // first-run specs opt out so they can prove the zero-project onboarding path. seedTestRepo: boolean + // Why: spec-scoped launch env. Mutating process.env at spec module scope + // leaks into other specs when a worker reloads files without replaying the + // first spec's afterAll; per-test launch env cannot leak. + orcaAppExtraEnv: Record + // Why: spec-scoped Chromium switches (e.g. --enable-precise-memory-info for + // memory benchmarks). Prepended before the main entry so Electron forwards + // them to Chromium without affecting other specs' launches. + orcaAppExtraArgs: string[] // Why: a few IPC repro specs need to launch the Electron app with a scoped // PATH/token environment. Keep this fixture-owned so tests never mutate the // developer's shell or already-running Orca instance. @@ -122,62 +121,6 @@ function forwardElectronProcessLogs(app: ElectronApplication, testInfo: TestInfo }) } -function isValidGitRepo(repoPath: string): boolean { - if (!repoPath || !existsSync(repoPath)) { - return false - } - - try { - return ( - execSync('git rev-parse --is-inside-work-tree', { - cwd: repoPath, - stdio: 'pipe', - encoding: 'utf8' - }).trim() === 'true' - ) - } catch { - return false - } -} - -function createSeededTestRepo(): string { - // Why: realpathSync so the seeded path matches the store's repo.path on - // macOS, where os.tmpdir() (/var/...) symlinks to /private/var/... and the - // app canonicalizes repo.path via `git rev-parse --show-toplevel` on add. - const testRepoDir = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-repo-'))) - - execSync('git init', { cwd: testRepoDir, stdio: 'pipe' }) - execSync('git config user.email "e2e@test.local"', { cwd: testRepoDir, stdio: 'pipe' }) - execSync('git config user.name "E2E Test"', { cwd: testRepoDir, stdio: 'pipe' }) - - writeFileSync( - path.join(testRepoDir, 'README.md'), - '# Orca E2E Test Repo\n\nThis repo was created automatically for Playwright tests.\n' - ) - writeFileSync(path.join(testRepoDir, 'CLAUDE.md'), '# CLAUDE.md\n\nTest instructions for E2E.\n') - writeFileSync( - path.join(testRepoDir, 'package.json'), - `${JSON.stringify({ name: 'orca-e2e-test', version: '0.0.0', private: true }, null, 2)}\n` - ) - writeFileSync(path.join(testRepoDir, '.gitignore'), 'node_modules/\n') - mkdirSync(path.join(testRepoDir, 'src'), { recursive: true }) - writeFileSync(path.join(testRepoDir, 'src', 'index.ts'), 'export const hello = "world"\n') - - execSync('git add -A', { cwd: testRepoDir, stdio: 'pipe' }) - execSync('git commit -m "Initial commit for E2E tests"', { cwd: testRepoDir, stdio: 'pipe' }) - - // Why: worker-scoped fixture fallbacks can run in parallel; UUIDs avoid - // colliding on the same temp repo/worktree when workers start together. - const worktreeDir = path.join(testRepoDir, '..', `orca-e2e-worktree-${randomUUID()}`) - execSync(`git worktree add "${worktreeDir}" -b e2e-secondary`, { - cwd: testRepoDir, - stdio: 'pipe' - }) - - writeFileSync(TEST_REPO_PATH_FILE, testRepoDir) - return testRepoDir -} - /** * Extended Playwright test with Orca-specific fixtures. * @@ -203,7 +146,11 @@ export const test = base.extend({ ], // Test-scoped: one Electron app per test - electronApp: async ({ dismissOnboarding, launchEnv }, provideFixture, testInfo) => { + electronApp: async ( + { dismissOnboarding, launchEnv, orcaAppExtraEnv, orcaAppExtraArgs }, + provideFixture, + testInfo + ) => { const mainPath = path.join(process.cwd(), 'out', 'main', 'index.js') const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-userdata-')) @@ -240,7 +187,7 @@ export const test = base.extend({ mkdirSync(recordVideoDir, { recursive: true }) } const app = await electron.launch({ - args: getOrcaElectronLaunchArgs(mainPath, headful), + args: [...orcaAppExtraArgs, ...getOrcaElectronLaunchArgs(mainPath, headful)], ...(slowMo > 0 ? { slowMo } : {}), ...(recordVideoDir ? { recordVideo: { dir: recordVideoDir } } : {}), // Why: keep NODE_ENV=development so window.__store is exposed and @@ -265,7 +212,8 @@ export const test = base.extend({ !cleanEnv.ORCA_RELAY_PATH ? { ORCA_RELAY_PATH: path.join(process.cwd(), 'out', 'relay') } : {}), - ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }) + ...(headful ? { ORCA_E2E_HEADFUL: '1' } : { ORCA_E2E_HEADLESS: '1' }), + ...orcaAppExtraEnv } }) forwardElectronProcessLogs(app, testInfo) @@ -281,6 +229,8 @@ export const test = base.extend({ dismissOnboarding: [true, { option: true }], seedTestRepo: [true, { option: true }], launchEnv: [{}, { option: true }], + orcaAppExtraEnv: [{}, { option: true }], + orcaAppExtraArgs: [[], { option: true }], // Test-scoped: grab the first BrowserWindow, add the test repo, and wait // until the session is fully ready with a worktree active. diff --git a/tests/e2e/helpers/seeded-test-repo.ts b/tests/e2e/helpers/seeded-test-repo.ts new file mode 100644 index 00000000000..3ae984193fd --- /dev/null +++ b/tests/e2e/helpers/seeded-test-repo.ts @@ -0,0 +1,67 @@ +/** + * Seeded git repo for Orca E2E fixtures: creation and validity checks for the + * disposable test repo (plus its secondary worktree) that specs operate on. + */ + +import { existsSync, mkdirSync, mkdtempSync, realpathSync, writeFileSync } from 'node:fs' +import { execSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import os from 'node:os' +import path from 'node:path' +import { TEST_REPO_PATH_FILE } from '../global-setup' + +export function isValidGitRepo(repoPath: string): boolean { + if (!repoPath || !existsSync(repoPath)) { + return false + } + + try { + return ( + execSync('git rev-parse --is-inside-work-tree', { + cwd: repoPath, + stdio: 'pipe', + encoding: 'utf8' + }).trim() === 'true' + ) + } catch { + return false + } +} + +export function createSeededTestRepo(): string { + // Why: realpathSync so the seeded path matches the store's repo.path on + // macOS, where os.tmpdir() (/var/...) symlinks to /private/var/... and the + // app canonicalizes repo.path via `git rev-parse --show-toplevel` on add. + const testRepoDir = realpathSync(mkdtempSync(path.join(os.tmpdir(), 'orca-e2e-repo-'))) + + execSync('git init', { cwd: testRepoDir, stdio: 'pipe' }) + execSync('git config user.email "e2e@test.local"', { cwd: testRepoDir, stdio: 'pipe' }) + execSync('git config user.name "E2E Test"', { cwd: testRepoDir, stdio: 'pipe' }) + + writeFileSync( + path.join(testRepoDir, 'README.md'), + '# Orca E2E Test Repo\n\nThis repo was created automatically for Playwright tests.\n' + ) + writeFileSync(path.join(testRepoDir, 'CLAUDE.md'), '# CLAUDE.md\n\nTest instructions for E2E.\n') + writeFileSync( + path.join(testRepoDir, 'package.json'), + `${JSON.stringify({ name: 'orca-e2e-test', version: '0.0.0', private: true }, null, 2)}\n` + ) + writeFileSync(path.join(testRepoDir, '.gitignore'), 'node_modules/\n') + mkdirSync(path.join(testRepoDir, 'src'), { recursive: true }) + writeFileSync(path.join(testRepoDir, 'src', 'index.ts'), 'export const hello = "world"\n') + + execSync('git add -A', { cwd: testRepoDir, stdio: 'pipe' }) + execSync('git commit -m "Initial commit for E2E tests"', { cwd: testRepoDir, stdio: 'pipe' }) + + // Why: worker-scoped fixture fallbacks can run in parallel; UUIDs avoid + // colliding on the same temp repo/worktree when workers start together. + const worktreeDir = path.join(testRepoDir, '..', `orca-e2e-worktree-${randomUUID()}`) + execSync(`git worktree add "${worktreeDir}" -b e2e-secondary`, { + cwd: testRepoDir, + stdio: 'pipe' + }) + + writeFileSync(TEST_REPO_PATH_FILE, testRepoDir) + return testRepoDir +} diff --git a/tests/e2e/helpers/terminal-input-probes.ts b/tests/e2e/helpers/terminal-input-probes.ts new file mode 100644 index 00000000000..82fdd2678c4 --- /dev/null +++ b/tests/e2e/helpers/terminal-input-probes.ts @@ -0,0 +1,290 @@ +/** + * Layer-discriminating input probes for frozen-terminal repro specs. + * + * The field failure (Discord #performance / GitHub #2836 family) is a pane + * that shows content while keystrokes silently vanish. Both drop layers are + * silent today: + * - renderer: transport.sendInput returns false when `!connected || !ptyId` + * (pty-transport.ts) + * - main: pty:write drops when `ptyOwnership` misses the id or the provider + * lookup fails (src/main/ipc/pty.ts writePtyInput) + * + * Direct `window.api.pty.write` bypasses the renderer transport, so: + * direct dead → MAIN-side drop (ownership/provider routing) + * direct alive, transport dead → RENDERER transport unbound + * The ownership-rebuild probe invokes pty:listSessions, which repopulates + * `ptyOwnership` as a side effect — input reviving after it is a smoking gun + * for the missing-ownership drop path. + * + * Two probe families: + * - Page-based (Playwright CDP): for specs whose renderer never crashes. + * - Main-process-based (webContents.executeJavaScript): for post-crash + * phases — a crashed target severs Playwright's CDP session even though + * the app recovers, so the harness must drive the renderer from main. + */ + +import { expect, type ElectronApplication, type Page } from '@stablyai/playwright-test' +import { sendToTerminal, waitForTerminalOutput } from './terminal' + +// ─── Page-based probes (healthy CDP session) ──────────────────────── + +export async function probeDirectWrite( + page: Page, + ptyId: string, + marker: string, + timeoutMs = 10_000 +): Promise { + // \x03\x15 = ETX+NAK (interrupt + kill-line) so a TUI or half-typed line on + // the shell doesn't swallow the probe — same trick discoverActivePtyId uses. + await sendToTerminal(page, ptyId, `\x03\x15echo ${marker}\r`) + try { + await waitForTerminalOutput(page, marker, timeoutMs) + return true + } catch { + return false + } +} + +/** Probe the full chain: focus the visible xterm and type through the keyboard. */ +export async function probeKeyboardType( + page: Page, + marker: string, + timeoutMs = 10_000 +): Promise { + await page.locator('.xterm:visible').first().click() + await page.keyboard.type(`echo ${marker}`, { delay: 20 }) + await page.keyboard.press('Enter') + try { + // Any appearance of the marker proves the roundtrip: xterm does not local- + // echo, so typed characters only render after the PTY echoes them back. + await waitForTerminalOutput(page, marker, timeoutMs) + return true + } catch { + return false + } +} + +export async function probeOwnershipRebuildRevival( + page: Page, + ptyId: string, + marker: string +): Promise { + await page.evaluate(async () => { + await window.api.pty.listSessions() + }) + return probeDirectWrite(page, ptyId, marker) +} + +export async function getStorePtyIds(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return [] + } + return Object.values(store.getState().ptyIdsByTabId).flat() + }) +} + +// ─── Main-process-based probes (post-renderer-crash) ──────────────── + +async function mainRendererEval( + electronApp: ElectronApplication, + expression: string +): Promise { + return electronApp.evaluate(async ({ BrowserWindow }, expr) => { + const win = BrowserWindow.getAllWindows()[0] + if (!win || win.isDestroyed() || win.webContents.isDestroyed()) { + throw new Error('no live window for executeJavaScript probe') + } + return (await win.webContents.executeJavaScript(expr, true)) as T + }, expression) as Promise +} + +export async function mainRendererStoreReady(electronApp: ElectronApplication): Promise { + try { + return await mainRendererEval( + electronApp, + `Boolean(window.__store && window.__store.getState().workspaceSessionReady === true)` + ) + } catch { + // executeJavaScript rejects while the document is loading or the window + // is mid-recovery; callers poll, so a false here is just "not yet". + return false + } +} + +export async function mainGetStorePtyIds(electronApp: ElectronApplication): Promise { + try { + return await mainRendererEval( + electronApp, + `(() => { + const store = window.__store + if (!store) { return [] } + return Object.values(store.getState().ptyIdsByTabId).flat() + })()` + ) + } catch { + return [] + } +} + +/** Serialize every mounted pane's buffer; marker search doesn't need per-pane precision. */ +export async function mainGetAllTerminalContent(electronApp: ElectronApplication): Promise { + try { + return await mainRendererEval( + electronApp, + `(() => { + const managers = window.__paneManagers + if (!managers) { return '' } + let combined = '' + for (const manager of managers.values()) { + for (const pane of manager.getPanes?.() ?? []) { + combined += '\\n' + (pane.serializeAddon?.serialize?.() ?? '') + } + } + return combined.slice(-8000) + })()` + ) + } catch { + return '' + } +} + +export async function mainWaitForPaneMounted( + electronApp: ElectronApplication, + timeoutMs = 30_000 +): Promise { + await expect + .poll( + async () => { + try { + return await mainRendererEval( + electronApp, + `(() => { + const managers = window.__paneManagers + if (!managers) { return 0 } + let count = 0 + for (const manager of managers.values()) { + count += (manager.getPanes?.() ?? []).length + } + return count + })()` + ) + } catch { + return 0 + } + }, + { timeout: timeoutMs, message: 'no terminal pane mounted after renderer recovery' } + ) + .toBeGreaterThan(0) +} + +async function mainWaitForMarker( + electronApp: ElectronApplication, + marker: string, + timeoutMs: number +): Promise { + try { + await expect + .poll(async () => (await mainGetAllTerminalContent(electronApp)).includes(marker), { + timeout: timeoutMs + }) + .toBe(true) + return true + } catch { + return false + } +} + +/** + * Full-chain probe without CDP: xterm's input() feeds terminal.onData → + * transport.sendInput → pty:write, the identical path keystrokes take past + * the DOM keyboard layer (which the pre-crash Playwright baseline covers). + * Why input() and not paste(): bracketed paste mode would wrap the payload + * and make the shell insert the control chars literally instead of executing. + */ +export async function mainProbeTransportPaste( + electronApp: ElectronApplication, + marker: string, + timeoutMs = 10_000 +): Promise { + try { + const fed = await mainRendererEval( + electronApp, + `(() => { + const managers = window.__paneManagers + if (!managers) { return false } + for (const manager of managers.values()) { + const pane = manager.getActivePane?.() ?? (manager.getPanes?.() ?? [])[0] + if (pane?.terminal?.input) { + pane.terminal.input('\\x03\\x15echo ${marker}\\r', true) + return true + } + } + return false + })()` + ) + if (!fed) { + return false + } + } catch { + return false + } + return mainWaitForMarker(electronApp, marker, timeoutMs) +} + +export async function mainProbeDirectWrite( + electronApp: ElectronApplication, + ptyId: string, + marker: string, + timeoutMs = 10_000 +): Promise { + try { + await mainRendererEval( + electronApp, + `window.api.pty.write(${JSON.stringify(ptyId)}, ${JSON.stringify(`\x03\x15echo ${marker}\r`)})` + ) + } catch { + return false + } + return mainWaitForMarker(electronApp, marker, timeoutMs) +} + +export async function mainProbeOwnershipRebuildRevival( + electronApp: ElectronApplication, + ptyId: string, + marker: string +): Promise { + try { + await mainRendererEval(electronApp, `window.api.pty.listSessions()`) + } catch { + return false + } + return mainProbeDirectWrite(electronApp, ptyId, marker) +} + +// ─── Failure report ───────────────────────────────────────────────── + +/** + * Assemble the failure report for a reproduced frozen pane. Kept in one place + * so every repro spec reports the same layer discrimination. + */ +export function buildFrozenPaneReport( + context: string, + probes: { + directAlive: boolean + transportAlive: boolean + revivedByOwnershipRebuild: boolean + ptyIds: string[] + terminalTail: string + } +): string { + return [ + `REPRODUCED frozen terminal (${context}):`, + ` direct pty:write probe alive: ${probes.directAlive} (false ⇒ MAIN-side drop: ptyOwnership/provider)`, + ` transport (onData→sendInput) probe alive: ${probes.transportAlive} (false with direct alive ⇒ RENDERER transport unbound)`, + ` revived by pty:listSessions ownership rebuild: ${probes.revivedByOwnershipRebuild}`, + ` pane ptyIds: ${JSON.stringify(probes.ptyIds)}`, + ` terminal tail:\n${probes.terminalTail.slice(-600)}` + ].join('\n') +} diff --git a/tests/e2e/renderer-crash-recovery-terminal-input.spec.ts b/tests/e2e/renderer-crash-recovery-terminal-input.spec.ts new file mode 100644 index 00000000000..621842e54e8 --- /dev/null +++ b/tests/e2e/renderer-crash-recovery-terminal-input.spec.ts @@ -0,0 +1,172 @@ +/** + * Repro spec for the frozen-terminal-after-renderer-recovery report + * (Discord #performance, GitHub #2836 family). + * + * Field evidence: pane shows content, shell is alive, daemon output.log does + * not grow while typing — i.e. keystrokes silently vanish somewhere between + * xterm and the PTY. This spec forces the suspected trigger — renderer + * process death followed by the automatic recovery reload + * (createMainWindow.ts scheduleRendererRecovery) — then discriminates which + * layer drops input via the probes in helpers/terminal-input-probes.ts. + * + * Post-crash phases are driven from the MAIN process: a crashed target + * severs Playwright's CDP session even though the app recovers, so page.* + * calls can never observe the recovered renderer (verified empirically — + * main saw did-finish-load while every window handle stayed dead). + */ + +import type { ElectronApplication } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { + discoverActivePtyId, + waitForActiveTerminalManager, + waitForPaneCount +} from './helpers/terminal' +import { + buildFrozenPaneReport, + mainGetAllTerminalContent, + mainGetStorePtyIds, + mainProbeDirectWrite, + mainProbeOwnershipRebuildRevival, + mainProbeTransportPaste, + mainRendererStoreReady, + mainWaitForPaneMounted, + probeKeyboardType +} from './helpers/terminal-input-probes' + +// Why 2, not more: the renderer recovery circuit breaker allows 3 recoveries +// per 60s window (DEFAULT_RENDERER_RECOVERY_MAX_RECOVERIES); a third forced +// crash risks tripping it and testing the breaker instead of the reattach. +const CRASH_CYCLES = 2 + +// Recovery = 250ms reload timer + full document reload + session hydration. +const RECOVERY_TIMEOUT_MS = 60_000 + +type CrashProbe = { + processGone: { reason: string; exitCode: number } | null + recoveredLoads: number +} + +declare global { + // eslint-disable-next-line no-var -- main-process global probe for this spec + var __crashProbe: CrashProbe | undefined +} + +/** + * Observe the crash/recovery lifecycle from the MAIN process. Re-arming + * replaces the probe object, so counts reset per cycle; stale listeners from + * a previous arm write only to their own superseded probe object. + */ +async function armMainProcessCrashProbe(electronApp: ElectronApplication): Promise { + await electronApp.evaluate(({ BrowserWindow }) => { + const probe: CrashProbe = { processGone: null, recoveredLoads: 0 } + globalThis.__crashProbe = probe + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.on('render-process-gone', (_event, details) => { + probe.processGone = { reason: details.reason, exitCode: details.exitCode ?? -1 } + }) + win.webContents.on('did-finish-load', () => { + probe.recoveredLoads += 1 + }) + } + }) +} + +async function readMainProcessCrashProbe(electronApp: ElectronApplication): Promise { + return electronApp.evaluate( + () => globalThis.__crashProbe ?? { processGone: null, recoveredLoads: 0 } + ) +} + +async function waitForRendererRecovery(electronApp: ElectronApplication): Promise { + await expect + .poll(async () => (await readMainProcessCrashProbe(electronApp)).recoveredLoads, { + timeout: RECOVERY_TIMEOUT_MS, + message: + 'Main process never observed a recovery reload (did-finish-load) after the forced crash — scheduleRendererRecovery did not fire' + }) + .toBeGreaterThan(0) + await expect + .poll(async () => mainRendererStoreReady(electronApp), { + timeout: RECOVERY_TIMEOUT_MS, + message: 'Recovered renderer never reached workspaceSessionReady' + }) + .toBe(true) + await mainWaitForPaneMounted(electronApp) +} + +test.describe('Renderer crash recovery keeps terminal input alive', () => { + test('typing still reaches the PTY after forced renderer crash + auto-reload', async ({ + orcaPage, + electronApp + }) => { + test.setTimeout(300_000) + + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + await waitForPaneCount(orcaPage, 1, 30_000) + + // Baseline: both the DOM keyboard layer (Playwright-driven, only possible + // pre-crash) and the PTY roundtrip must work before we crash anything, + // otherwise a post-crash failure would be uninterpretable. + const baselinePtyId = await discoverActivePtyId(orcaPage) + expect( + await probeKeyboardType(orcaPage, 'KB_BASELINE_OK'), + 'baseline keyboard input must reach the PTY before any crash is forced' + ).toBe(true) + + for (let cycle = 0; cycle < CRASH_CYCLES; cycle++) { + await armMainProcessCrashProbe(electronApp) + await electronApp.evaluate(({ BrowserWindow }) => { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.forcefullyCrashRenderer() + } + }) + + await expect + .poll(async () => (await readMainProcessCrashProbe(electronApp)).processGone?.reason, { + timeout: 15_000, + message: 'forcefullyCrashRenderer never produced a render-process-gone event' + }) + .toBeTruthy() + + await waitForRendererRecovery(electronApp) + + // Daemon-backed sessions survive renderer death by design, so the pane + // should reattach to the same session rather than spawn a fresh shell. + const postPtyIds = await mainGetStorePtyIds(electronApp) + + const transportAlive = await mainProbeTransportPaste(electronApp, `PASTE_POST_${cycle}_OK`) + const directAlive = + postPtyIds.length > 0 && + (await mainProbeDirectWrite(electronApp, postPtyIds[0], `DIRECT_POST_${cycle}_OK`)) + + if (!transportAlive || !directAlive) { + const revived = + postPtyIds.length > 0 && + (await mainProbeOwnershipRebuildRevival( + electronApp, + postPtyIds[0], + `REVIVED_POST_${cycle}_OK` + )) + const crashReason = + (await readMainProcessCrashProbe(electronApp)).processGone?.reason ?? 'unknown' + throw new Error( + buildFrozenPaneReport( + `crash cycle ${cycle}, baseline ptyId ${baselinePtyId}, crash reason ${crashReason}`, + { + directAlive, + transportAlive, + revivedByOwnershipRebuild: revived, + ptyIds: postPtyIds, + terminalTail: await mainGetAllTerminalContent(electronApp) + } + ) + ) + } + } + }) +}) diff --git a/tests/e2e/restart-restore-terminal-input.spec.ts b/tests/e2e/restart-restore-terminal-input.spec.ts new file mode 100644 index 00000000000..2ded33f0c0a --- /dev/null +++ b/tests/e2e/restart-restore-terminal-input.spec.ts @@ -0,0 +1,259 @@ +/** + * Repro spec for the "starts frozen right after an update" report + * (Discord #performance, GitHub #2836 family). + * + * Field evidence: after an app update + relaunch the terminal pane shows + * restored content but typing produces nothing — daemon output.log never + * grows. Restore paints the persisted buffer synchronously BEFORE the + * deferred PTY reattach runs (use-terminal-pane-lifecycle.ts → + * pty-connection.ts), and every reattach-failure branch swallows into null, + * so a failed/stalled attach leaves a live-looking, input-dead pane. + * + * Existing coverage (daemon-live-session-preservation.spec.ts, + * terminal-restart-persistence.spec.ts) asserts restored CONTENT after + * relaunch, but never that INPUT still works. These tests close that gap for + * three real relaunch shapes: + * 1. clean restart with a live daemon session (the update model) + * 2. daemon wedged (SIGSTOP) across the relaunch — the attach stalls while + * restore has already painted; input must recover once the daemon does + * 3. daemon killed between launches — cold-restore + fresh spawn must yield + * a typeable pane + */ + +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import type { ElectronApplication, Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { TEST_REPO_PATH_FILE } from './global-setup' +import { + discoverActivePtyId, + execInTerminal, + getTerminalContent, + waitForActiveTerminalManager, + waitForPaneCount, + waitForTerminalOutput +} from './helpers/terminal' +import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' +import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart' +import { + buildFrozenPaneReport, + getStorePtyIds, + probeDirectWrite, + probeKeyboardType, + probeOwnershipRebuildRevival +} from './helpers/terminal-input-probes' +import { PROTOCOL_VERSION } from '../../src/main/daemon/types' +import { PTY_SESSION_ID_SEPARATOR } from '../../src/shared/pty-session-id-format' + +function readDaemonPid(userDataDir: string): number { + const raw = readFileSync( + path.join(userDataDir, 'daemon', `daemon-v${PROTOCOL_VERSION}.pid`), + 'utf8' + ) + const parsed = JSON.parse(raw) as { pid?: unknown } + if (typeof parsed.pid !== 'number') { + throw new Error(`Daemon pid file did not contain a numeric pid: ${raw}`) + } + return parsed.pid +} + +function seededRepoPathOrSkip(): string { + const repoPath = existsSync(TEST_REPO_PATH_FILE) + ? readFileSync(TEST_REPO_PATH_FILE, 'utf-8').trim() + : '' + test.skip(!repoPath || !existsSync(repoPath), 'Global setup did not produce a seeded test repo') + return repoPath +} + +async function bootstrapFirstLaunch( + page: Page, + repoPath: string +): Promise<{ ptyId: string; marker: string }> { + await attachRepoAndOpenTerminal(page, repoPath) + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) + const ptyId = await discoverActivePtyId(page) + // Why: the separator only appears in daemon session ids. If e2e silently + // ran the local provider, these tests would exercise the wrong restore path. + expect(ptyId, 'expected a daemon-backed PTY session').toContain(PTY_SESSION_ID_SEPARATOR) + const marker = `RESTORE_INPUT_PRE_${Date.now()}` + await execInTerminal(page, ptyId, `echo ${marker}`) + await waitForTerminalOutput(page, marker) + return { ptyId, marker } +} + +async function settleRestoredLaunch(page: Page): Promise { + await waitForSessionReady(page) + await waitForActiveWorktree(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneCount(page, 1, 30_000) +} + +/** + * The shared assertion: a restored pane must accept input. Reports layer + * discrimination when it doesn't. + */ +async function expectRestoredPaneAcceptsInput(page: Page, context: string): Promise { + const ptyIds = await getStorePtyIds(page) + const kbAlive = await probeKeyboardType(page, 'KB_RESTORED_OK', 15_000) + const directAlive = + ptyIds.length > 0 && (await probeDirectWrite(page, ptyIds[0], 'DIRECT_RESTORED_OK', 15_000)) + if (!kbAlive || !directAlive) { + const revived = + ptyIds.length > 0 && + (await probeOwnershipRebuildRevival(page, ptyIds[0], 'REVIVED_RESTORED_OK')) + throw new Error( + buildFrozenPaneReport(context, { + directAlive, + transportAlive: kbAlive, + revivedByOwnershipRebuild: revived, + ptyIds, + terminalTail: await getTerminalContent(page) + }) + ) + } +} + +test.describe.configure({ mode: 'serial' }) + +test('restored pane accepts typing after a clean restart with a live daemon session', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + test.setTimeout(300_000) + const repoPath = seededRepoPathOrSkip() + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + const { marker } = await bootstrapFirstLaunch(first.page, repoPath) + const daemonPid = readDaemonPid(session.userDataDir) + + await session.close(firstApp) + firstApp = null + + const second = await session.launch() + secondApp = second.app + await settleRestoredLaunch(second.page) + await waitForTerminalOutput(second.page, marker, 15_000) + expect(readDaemonPid(session.userDataDir), 'daemon must survive the restart').toBe(daemonPid) + + await expectRestoredPaneAcceptsInput(second.page, 'clean restart, live daemon session') + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) + +test('restored pane recovers input after the daemon un-wedges', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + test.skip(process.platform === 'win32', 'SIGSTOP/SIGCONT are POSIX-only') + test.setTimeout(300_000) + const repoPath = seededRepoPathOrSkip() + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + let stoppedDaemonPid: number | null = null + try { + const first = await session.launch() + firstApp = first.app + const { marker } = await bootstrapFirstLaunch(first.page, repoPath) + const daemonPid = readDaemonPid(session.userDataDir) + + await session.close(firstApp) + firstApp = null + + // Wedge the daemon: it stays alive (socket exists, sessions retained) but + // cannot accept or answer — the shape of a busy/hung daemon during launch. + process.kill(daemonPid, 'SIGSTOP') + stoppedDaemonPid = daemonPid + + const second = await session.launch() + secondApp = second.app + await settleRestoredLaunch(second.page) + + // Field-fidelity check, not a hard gate: does the pane paint restored + // content while its PTY attach cannot complete? That visible-but-dead + // window is exactly what the reporter sees at startup. + const paintedWhileWedged = (await getTerminalContent(second.page)).includes(marker) + + // The relaunching app may classify the stopped daemon as unreachable and + // kill+replace it (daemon hardening) — then SIGCONT throws ESRCH and the + // old sessions are gone. Both shapes must leave the pane typeable, so + // record which one we're in and keep probing. + let daemonReplacedWhileWedged = false + try { + process.kill(daemonPid, 'SIGCONT') + } catch { + daemonReplacedWhileWedged = true + } + stoppedDaemonPid = null + + await expectRestoredPaneAcceptsInput( + second.page, + `daemon wedged during relaunch (painted while wedged: ${paintedWhileWedged}, ` + + `wedged daemon killed+replaced by relaunch: ${daemonReplacedWhileWedged})` + ) + } finally { + if (stoppedDaemonPid !== null) { + try { + process.kill(stoppedDaemonPid, 'SIGCONT') + } catch { + // daemon already gone + } + } + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) + +test('cold-restored pane accepts typing after the daemon died between launches', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set. +{}, testInfo) => { + test.skip(process.platform === 'win32', 'POSIX signal semantics keep this deterministic') + test.setTimeout(300_000) + const repoPath = seededRepoPathOrSkip() + const session = createRestartSession(testInfo) + let firstApp: ElectronApplication | null = null + let secondApp: ElectronApplication | null = null + try { + const first = await session.launch() + firstApp = first.app + await bootstrapFirstLaunch(first.page, repoPath) + const daemonPid = readDaemonPid(session.userDataDir) + + await session.close(firstApp) + firstApp = null + + // The daemon dies uncleanly between runs (crash, reboot, force-kill). The + // persisted session now references sessions no living daemon holds. + process.kill(daemonPid, 'SIGKILL') + + const second = await session.launch() + secondApp = second.app + await settleRestoredLaunch(second.page) + + await expectRestoredPaneAcceptsInput(second.page, 'daemon killed between launches') + } finally { + if (secondApp) { + await session.close(secondApp) + } + if (firstApp) { + await session.close(firstApp) + } + await session.dispose() + } +}) diff --git a/tests/e2e/ssh-docker-relay-perf.spec.ts b/tests/e2e/ssh-docker-relay-perf.spec.ts index c8e3beab6e5..f36f44e9445 100644 --- a/tests/e2e/ssh-docker-relay-perf.spec.ts +++ b/tests/e2e/ssh-docker-relay-perf.spec.ts @@ -3,6 +3,8 @@ import { test, expect } from './helpers/orca-app' import { ensureTerminalVisible, waitForActiveWorktree, waitForSessionReady } from './helpers/store' import { execInTerminal, + focusLastTerminalPane, + splitActiveTerminalPane, waitForActivePanePtyId, waitForActiveTerminalManager, waitForTerminalOutput @@ -18,6 +20,7 @@ const RUN_DOCKER_SSH = process.env.ORCA_E2E_SSH_DOCKER === '1' const KEY_LATENCY_SAMPLES = 'abcdefghij' const MAX_MEDIAN_KEY_LATENCY_MS = 500 const MAX_WORST_KEY_LATENCY_MS = 2_000 +const MIN_HELD_SSH_ACK_CHARS = 256 * 1024 type TypingMeasurement = { latencies: number[] @@ -25,6 +28,20 @@ type TypingMeasurement = { worstLatencyMs: number } +type SshPtyAckGateSnapshot = { + gatedPtyCount: number + heldAckCount: number + heldAckChars: number +} + +type SshPtyAckGateWindow = Window & { + __terminalPtyAckGate?: { + hold: (ptyIds: string[]) => void + release: () => void + snapshot: () => SshPtyAckGateSnapshot + } +} + type ConnectedDockerRemote = { targetId: string repoId: string @@ -56,6 +73,22 @@ function remoteTypingLoadScript(runId: string): string { ].join(';') } +function remoteBackgroundFloodScript(runId: string): string { + return [ + "process.stdin.setEncoding('utf8')", + 'if (process.stdin.isTTY) process.stdin.setRawMode(true)', + 'process.stdin.resume()', + `process.stdout.write('REMOTE_ACK_FLOOD_READY_${runId}\\n')`, + 'let frame = 0', + 'let timer = null', + "const chunk = 'R'.repeat(8192)", + 'function stop() { if (timer) clearInterval(timer); process.exit(0) }', + "function start() { if (timer) return; timer = setInterval(() => { frame += 1; process.stdout.write('REMOTE_ACK_FLOOD_' + frame + '_' + chunk + '\\n') }, 2) }", + "process.stdin.on('data', (chunk) => { if (chunk.includes(String.fromCharCode(3))) stop(); if (chunk.includes('g')) start() })", + "process.on('SIGINT', stop)" + ].join(';') +} + async function connectDockerRemote( page: Page, target: DockerSshRelayTarget @@ -148,6 +181,28 @@ async function measureRemoteTyping( } } +async function holdSshPtyAckGate(page: Page, ptyIds: string[]): Promise { + await page.evaluate((heldPtyIds) => { + const gate = (window as SshPtyAckGateWindow).__terminalPtyAckGate + if (!gate) { + throw new Error('terminal PTY ACK gate is unavailable') + } + gate.hold(heldPtyIds) + }, ptyIds) +} + +async function releaseSshPtyAckGate(page: Page): Promise { + await page.evaluate(() => { + ;(window as SshPtyAckGateWindow).__terminalPtyAckGate?.release() + }) +} + +async function readSshPtyAckGate(page: Page): Promise { + return page.evaluate( + () => (window as SshPtyAckGateWindow).__terminalPtyAckGate?.snapshot() ?? null + ) +} + async function stopRemoteLoad(page: Page, ptyId: string): Promise { await page.evaluate((targetPtyId) => window.api.pty.write(targetPtyId, '\x03'), ptyId) } @@ -207,6 +262,84 @@ test.describe('Docker SSH relay perf', () => { } }) + test('keeps active remote typing responsive while a background SSH PTY stream is ACK-stalled', async ({ + orcaPage + }, testInfo) => { + test.slow() + let target: DockerSshRelayTarget | null = null + let backgroundPtyId: string | null = null + let activePtyId: string | null = null + try { + target = startDockerSshRelayTarget(testInfo) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await connectDockerRemote(orcaPage, target) + await ensureTerminalVisible(orcaPage, 45_000) + await waitForActiveTerminalManager(orcaPage, 60_000) + backgroundPtyId = await waitForActivePanePtyId(orcaPage, 60_000) + + const runId = String(Date.now()) + await execInTerminal( + orcaPage, + backgroundPtyId, + `node -e ${shellQuote(remoteBackgroundFloodScript(runId))}` + ) + await waitForTerminalOutput(orcaPage, `REMOTE_ACK_FLOOD_READY_${runId}`, 30_000, 80_000) + await holdSshPtyAckGate(orcaPage, [backgroundPtyId]) + await orcaPage.evaluate((ptyId) => window.api.pty.write(ptyId, 'g'), backgroundPtyId) + + await splitActiveTerminalPane(orcaPage, 'vertical') + await focusLastTerminalPane(orcaPage) + activePtyId = await waitForActivePanePtyId(orcaPage, 60_000) + expect(activePtyId).not.toBe(backgroundPtyId) + + const activeRunId = `${runId}_active` + await execInTerminal( + orcaPage, + activePtyId, + `node -e ${shellQuote(remoteTypingLoadScript(activeRunId))}` + ) + await waitForTerminalOutput(orcaPage, `REMOTE_TUI_READY_${activeRunId}`, 30_000, 80_000) + await expect + .poll(async () => (await readSshPtyAckGate(orcaPage))?.heldAckChars ?? 0, { + timeout: 30_000, + message: 'remote background SSH PTY stream did not build held ACK pressure' + }) + .toBeGreaterThan(MIN_HELD_SSH_ACK_CHARS) + + const measurement = await measureRemoteTyping(orcaPage, activePtyId, activeRunId) + const ackGate = await readSshPtyAckGate(orcaPage) + const summary = `median=${measurement.medianLatencyMs.toFixed( + 1 + )}ms worst=${measurement.worstLatencyMs.toFixed(1)}ms heldAckChars=${ + ackGate?.heldAckChars ?? 0 + } heldPtys=${ackGate?.heldAckCount ?? 0} samples=${measurement.latencies + .map((value) => value.toFixed(1)) + .join(',')}` + console.log(`[docker-ssh-relay-pty-ack-pressure] ${summary}`) + testInfo.annotations.push({ + type: 'docker-ssh-relay-pty-ack-pressure', + description: summary + }) + expect(ackGate?.heldAckChars ?? 0).toBeGreaterThan(MIN_HELD_SSH_ACK_CHARS) + expect(measurement.medianLatencyMs).toBeLessThan(MAX_MEDIAN_KEY_LATENCY_MS) + expect(measurement.worstLatencyMs).toBeLessThan(MAX_WORST_KEY_LATENCY_MS) + + await releaseSshPtyAckGate(orcaPage) + const releasedAckGate = await readSshPtyAckGate(orcaPage) + expect(releasedAckGate?.heldAckChars ?? 0).toBe(0) + } finally { + await releaseSshPtyAckGate(orcaPage).catch(() => undefined) + if (activePtyId) { + await stopRemoteLoad(orcaPage, activePtyId).catch(() => undefined) + } + if (backgroundPtyId) { + await stopRemoteLoad(orcaPage, backgroundPtyId).catch(() => undefined) + } + cleanupDockerSshRelayTarget(target) + } + }) + test('keeps remote typing responsive while relay file streams and git churn are active', async ({ orcaPage }, testInfo) => { diff --git a/tests/e2e/sustained-agent-typing-load-scripts.ts b/tests/e2e/sustained-agent-typing-load-scripts.ts new file mode 100644 index 00000000000..430ef47da07 --- /dev/null +++ b/tests/e2e/sustained-agent-typing-load-scripts.ts @@ -0,0 +1,135 @@ +/** + * Script generators for the multi-workspace sustained typing-latency bench + * (terminal-multi-workspace-typing-latency.spec.ts): + * + * - a paced agent-TUI load generator that replays the deterministic pipeline + * bench fixture through a real PTY at a fixed byte rate, emulating a Claude + * Code-style agent streaming in another workspace, and + * - a typing echo probe that timestamps each keystroke's arrival at the pty + * into a sidecar JSONL, so a key's total latency decomposes into + * input-half (CDP keydown -> pty stdin) and echo-half (pty echo -> screen). + */ +import { mkdirSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { pathToFileURL } from 'node:url' + +// Why absolute file URL: the generated .mjs scripts run with the disposable +// test repo as cwd, so the fixture builder must be imported by absolute +// specifier (file URL keeps Windows drive-letter paths importable). +const PIPELINE_BENCH_URL = pathToFileURL( + path.resolve(__dirname, '..', '..', 'tools', 'benchmarks', 'terminal-pipeline-bench.mjs') +).href + +export function sustainedLoadReadyFilePath( + directory: string, + runId: string, + paneIndex: number +): string { + return path.join(directory, `.orca-mwt-load-ready-${runId}-${paneIndex}`) +} + +export function typingProbeReadyMarker(runId: string): string { + return `MWT_TYPING_READY_${runId}` +} + +export function typingKeyMarkerPrefix(runId: string): string { + return `MWT_KEY_${runId}_` +} + +function sustainedAgentLoadScript(runId: string, readyFileDirectory: string): string { + return ` +import { writeFileSync } from 'node:fs' +import { buildFixture } from ${JSON.stringify(PIPELINE_BENCH_URL)} + +const paneIndex = Number(process.argv[2] ?? 0) +const rateKbps = Number(process.argv[3] ?? 256) +const durationS = Number(process.argv[4] ?? 60) + +const cols = process.stdout.columns ?? 80 +const rows = process.stdout.rows ?? 24 +// 2MB of deterministic Claude-Code-shaped frames, replayed in a loop. +const fixture = buildFixture('agent-tui', 2 * 1024 * 1024, cols, rows) + +const TICK_MS = 50 +const chunkChars = Math.max(1, Math.floor((rateKbps * 1024 * TICK_MS) / 1000)) +const writeChunk = (data) => + new Promise((resolve) => { + if (process.stdout.write(data)) { + resolve() + } else { + process.stdout.once('drain', resolve) + } + }) +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) + +// Readiness signals via the filesystem, not the terminal buffer: with many +// panes the stream scrolls a READY marker out of the buffer's serialize +// window before the spec's sequential checks reach it. +writeFileSync( + ${JSON.stringify(readyFileDirectory)} + '/.orca-mwt-load-ready-${runId}-' + paneIndex, + String(Date.now()) +) +process.stdout.write('${'MWT_LOAD_READY_'}${runId}_' + paneIndex + '\\r\\n') +const deadline = Date.now() + durationS * 1000 +let offset = 0 +while (Date.now() < deadline) { + await writeChunk(fixture.slice(offset, offset + chunkChars)) + offset = (offset + chunkChars) % fixture.length + await sleep(TICK_MS) +} +process.stdout.write('\\x1b[0m\\x1b[?2026l\\r\\nMWT_LOAD_DONE_${runId}_' + paneIndex + '\\r\\n') +` +} + +function typingEchoProbeScript(runId: string, arrivalSidecarPath: string): string { + return ` +import { appendFileSync } from 'node:fs' + +process.stdin.setEncoding('utf8') +if (process.stdin.isTTY) process.stdin.setRawMode(true) +process.stdin.resume() +let seq = 0 +const interrupt = String.fromCharCode(3) +process.stdout.write('${'MWT_TYPING_READY_'}${runId}\\r\\n') +process.stdin.on('data', (chunk) => { + // One arrival timestamp per chunk: coalesced keystrokes genuinely arrive + // together at the pty, and that coalescing is part of what we measure. + const atMs = Date.now() + if (chunk.includes(interrupt)) { + process.exit(0) + } + for (const char of chunk) { + if (char === '\\r' || char === '\\n') continue + seq += 1 + appendFileSync( + ${JSON.stringify(arrivalSidecarPath)}, + JSON.stringify({ seq, atMs }) + '\\n' + ) + process.stdout.write('\\r\\x1b[2Kmwt prompt ' + seq + ': ' + char + ' ${'MWT_KEY_'}${runId}_' + seq + '\\r\\n') + } +}) +` +} + +export function writeSustainedAgentLoadScript( + scriptPath: string, + runId: string, + readyFileDirectory: string +): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + // Generated scripts concatenate with '/', which Node's fs accepts on all + // platforms; normalize Windows backslashes out of the baked-in directory. + writeFileSync( + scriptPath, + sustainedAgentLoadScript(runId, readyFileDirectory.replaceAll('\\', '/')) + ) +} + +export function writeTypingEchoProbeScript( + scriptPath: string, + runId: string, + arrivalSidecarPath: string +): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync(scriptPath, typingEchoProbeScript(runId, arrivalSidecarPath)) +} diff --git a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts index 74453aa4076..2dccaf2e87c 100644 --- a/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts +++ b/tests/e2e/terminal-hidden-tui-visual-restore.spec.ts @@ -1,6 +1,6 @@ import type { Page, TestInfo } from '@stablyai/playwright-test' import { randomUUID } from 'node:crypto' -import { rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' import path from 'node:path' import { test, expect } from './helpers/orca-app' import { @@ -22,29 +22,23 @@ type HiddenTuiWindow = Window & { __terminalPtyDataInjection?: { inject: (paneKey: string, data: string, meta?: { seq?: number; rawLength?: number }) => boolean } + // Why: only the mode-2031 fact-reply counter survives Phase 6 — the + // hidden-skip counters were deleted with the renderer skip grammar. __terminalPtyOutputDebug?: { reset: () => void snapshot: () => { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number hiddenRendererMode2031ReplyCount: number } } } -type HiddenTuiDebugSnapshot = { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number - hiddenRendererMode2031ReplyCount: number -} - type TuiCursorState = { hidden: boolean | null initialized: boolean | null - cursorElementVisible: boolean - cursorCanvasPresent: boolean } +const HIDDEN_FRAME_SCRIPT_DELAY_MS = 750 + function tuiFrame(runId: string, frame: number): string { const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` const rows = [ @@ -79,23 +73,43 @@ function lowRiskRestoreFrame(runId: string, frame: number): string { } async function resetHiddenDebug(page: Page): Promise { - await page.evaluate(() => { + await page.evaluate(async () => { ;(window as HiddenTuiWindow).__terminalPtyOutputDebug?.reset() + // Why: under the Phase-4 hidden-delivery gate the withheld-output signal + // lives in main's delivery debug counters, not the renderer skip path. + await window.api.pty.resetRendererDeliveryDebug() }) } function writeHiddenFrameScript(scriptPath: string, runId: string): void { const frames = Array.from({ length: 25 }, (_, frame) => tuiFrame(runId, frame)) - writeFileSync(scriptPath, `process.stdout.write(${JSON.stringify(frames.join(''))})\n`) + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${HIDDEN_FRAME_SCRIPT_DELAY_MS})\n` + ) +} + +function writeLowRiskFrameScript(scriptPath: string, frame: string): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frame)}), ${HIDDEN_FRAME_SCRIPT_DELAY_MS})\n` + ) } async function writeHiddenFrames(page: Page, ptyId: string, scriptPath: string): Promise { await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)}\r`) } -async function readHiddenDebug(page: Page): Promise { - return page.evaluate(() => { - return (window as HiddenTuiWindow).__terminalPtyOutputDebug?.snapshot() ?? null +// Why: Phase-4 hidden-delivery gate contract — hidden PTY bytes are dropped +// in main after model ingestion and never reach the renderer, so "hidden +// output was withheld" is observed via main's dropped-chars counter instead +// of the old renderer hidden-skip counters. +async function readMainHiddenDeliveryDroppedChars(page: Page): Promise { + return page.evaluate(async () => { + const snapshot = await window.api.pty.getRendererDeliveryDebugSnapshot() + return snapshot.hiddenDeliveryDroppedChars }) } @@ -120,23 +134,9 @@ async function readTuiCursorState(page: Page): Promise { _core?: { coreService?: { isCursorHidden?: boolean; isCursorInitialized?: boolean } } } )._core - const cursorElement = pane.container.querySelector('.xterm-cursor') - const cursorRect = cursorElement?.getBoundingClientRect() - const cursorStyle = cursorElement ? window.getComputedStyle(cursorElement) : null return { hidden: terminalCore?.coreService?.isCursorHidden ?? null, - initialized: terminalCore?.coreService?.isCursorInitialized ?? null, - // Why: a blinking DOM cursor may be transparent during the sampled frame; - // disappearance regressions remove the laid-out cursor element/layer. - cursorElementVisible: - !!cursorElement && - !!cursorRect && - cursorRect.width > 0 && - cursorRect.height > 0 && - cursorStyle?.display !== 'none' && - cursorStyle?.visibility !== 'hidden', - cursorCanvasPresent: - pane.container.querySelector('.xterm-cursor-layer canvas') !== null + initialized: terminalCore?.coreService?.isCursorInitialized ?? null } }) } @@ -245,13 +245,23 @@ test.describe('Hidden terminal TUI visual restore', () => { writeHiddenFrameScript(scriptPath, runId) await resetHiddenDebug(orcaPage) await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath) + await resetHiddenDebug(orcaPage) + // Why: hidden-delivery gate contract — the bulk TUI frames must be + // withheld in main (dropped after model ingestion), not delivered and + // skipped renderer-side. await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { timeout: 10_000, - message: 'visually rich hidden TUI output should stay on the live xterm path' + message: 'visually rich hidden TUI output was not withheld from the renderer' }) - .toBe(0) + .toBeGreaterThan(1024) + await expect + .poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), { + timeout: 10_000, + message: 'visually rich hidden TUI source did not come from headless model' + }) + .toBe('headless') await switchToWorktree(orcaPage, secondWorktreeId) await ensureTerminalVisible(orcaPage) @@ -279,6 +289,7 @@ test.describe('Hidden terminal TUI visual restore', () => { hidden: false, initialized: true }) + const screenshotPath = testInfo.outputPath('hidden-tui-restore-final.png') await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) await testInfo.attach('hidden-tui-restore-final.png', { @@ -288,8 +299,9 @@ test.describe('Hidden terminal TUI visual restore', () => { rmSync(scriptPath, { force: true }) }) - test('keeps newer live output correct after hidden output stayed live', async ({ - orcaPage + test('keeps newer live output correct after plain hidden output restores', async ({ + orcaPage, + testRepoPath }, testInfo: TestInfo) => { await waitForSessionReady(orcaPage) const firstWorktreeId = await waitForActiveWorktree(orcaPage) @@ -323,18 +335,20 @@ test.describe('Hidden terminal TUI visual restore', () => { const hiddenFrame = lowRiskRestoreFrame(runId, 40) const liveFrame = lowRiskRestoreFrame(runId, 41) const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_41` + const scriptPath = path.join(testRepoPath, `.orca-low-risk-hidden-${runId}.mjs`) + writeLowRiskFrameScript(scriptPath, hiddenFrame) + await resetHiddenDebug(orcaPage) + await sendToTerminal(orcaPage, hiddenPane.ptyId, `node ${JSON.stringify(scriptPath)}\r`) await resetHiddenDebug(orcaPage) - await injectPaneData(orcaPage, paneKey, hiddenFrame, { - seq: hiddenFrame.length, - rawLength: hiddenFrame.length - }) + // Why: hidden-delivery gate contract — even plain hidden output is + // dropped in main, so the withheld signal is main's dropped counter. await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { timeout: 10_000, - message: 'hidden injected output should stay on the live xterm path for release' + message: 'plain hidden injected output was not withheld from the renderer' }) - .toBe(0) + .toBeGreaterThan(0) await switchToWorktree(orcaPage, secondWorktreeId) await ensureTerminalVisible(orcaPage) @@ -347,7 +361,7 @@ test.describe('Hidden terminal TUI visual restore', () => { await expect .poll(() => getTerminalContent(orcaPage, 12_000), { timeout: 10_000, - message: 'newer live TUI frame did not render after hidden output stayed live' + message: 'newer live TUI frame did not render after hidden output restored' }) .toContain(finalMarker) @@ -361,7 +375,7 @@ test.describe('Hidden terminal TUI visual restore', () => { await expect .poll(() => readTuiCursorState(orcaPage), { timeout: 5_000, - message: 'live TUI cursor stayed hidden after hidden output stayed live' + message: 'live TUI cursor stayed hidden after hidden output restored' }) .toMatchObject({ hidden: false, @@ -373,9 +387,102 @@ test.describe('Hidden terminal TUI visual restore', () => { path: screenshotPath, contentType: 'image/png' }) + rmSync(scriptPath, { force: true }) }) - test('keeps hidden terminal side effects live while hidden output stays live', async ({ + test('restores rich synchronized TUI output from the headless model', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'hidden TUI restore needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const hiddenSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + const hiddenPane = hiddenSnapshot.panes[0] + if (!hiddenPane?.ptyId) { + throw new Error('hidden rich model pane did not bind a PTY') + } + await switchToWorktree(orcaPage, firstWorktreeId) + await expect + .poll(() => getActiveWorktreeId(orcaPage), { + timeout: 10_000, + message: 'first worktree did not become active before hidden rich model restore' + }) + .toBe(firstWorktreeId) + + const runId = randomUUID() + const finalMarker = `VISUAL_RESTORE_FINAL_${runId}_24` + const scriptPath = path.join(testRepoPath, `.orca-hidden-rich-model-${runId}.mjs`) + writeHiddenFrameScript(scriptPath, runId) + await resetHiddenDebug(orcaPage) + try { + await writeHiddenFrames(orcaPage, hiddenPane.ptyId, scriptPath) + await resetHiddenDebug(orcaPage) + + // Why: hidden-delivery gate contract — synchronized rich frames are + // withheld in main; the headless model snapshot is the restore source. + await expect + .poll(() => readMainHiddenDeliveryDroppedChars(orcaPage), { + timeout: 10_000, + message: 'rich hidden TUI output was not withheld from the renderer' + }) + .toBeGreaterThan(0) + await expect + .poll(() => readMainSnapshotSource(orcaPage, hiddenPane.ptyId!), { + timeout: 10_000, + message: 'rich hidden TUI source did not come from headless model' + }) + .toBe('headless') + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 10_000, + message: 'rich headless TUI frame did not restore when visible' + }) + .toContain(finalMarker) + + const content = await getTerminalContent(orcaPage, 12_000) + expect(content).toContain(`Frame 024`) + expect(content).toContain('╭') + expect(content).toContain('├') + expect(content).toContain('█') + expect(content).not.toContain('Orca skipped hidden terminal output') + await expect + .poll(() => readTuiCursorState(orcaPage), { + timeout: 5_000, + message: 'rich headless TUI cursor stayed hidden after restore' + }) + .toMatchObject({ + hidden: false, + initialized: true + }) + + const screenshotPath = testInfo.outputPath('hidden-rich-model-restore-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('hidden-rich-model-restore-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + rmSync(scriptPath, { force: true }) + } + }) + + test('keeps hidden terminal side effects live while hidden output may restore', async ({ orcaPage }) => { await waitForSessionReady(orcaPage) @@ -411,12 +518,6 @@ test.describe('Hidden terminal TUI visual restore', () => { await resetHiddenDebug(orcaPage) await writeHiddenSideEffectBurst(orcaPage, hiddenPane.ptyId, hiddenTitle, marker) - await expect - .poll(async () => (await readHiddenDebug(orcaPage))?.hiddenRendererSkipCount ?? 0, { - timeout: 10_000, - message: 'hidden side-effect output should stay on the live xterm path for release' - }) - .toBe(0) await expect .poll(() => getRuntimePaneTitle(orcaPage, hiddenSnapshot.tabId, hiddenPane.numericPaneId), { timeout: 10_000, diff --git a/tests/e2e/terminal-hidden-view-parking.spec.ts b/tests/e2e/terminal-hidden-view-parking.spec.ts new file mode 100644 index 00000000000..4bd2e143e13 --- /dev/null +++ b/tests/e2e/terminal-hidden-view-parking.spec.ts @@ -0,0 +1,552 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { mkdirSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + getWorktreeTabs, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +// Why: the parking wiring registers this handle (dev/exposeStore builds only) +// so tests can detect that hidden-view parking is compiled in and which delay +// override the app actually applied. +type ParkingDebugWindow = Window & { + __terminalParkingDebug?: { + parkDelayMs?: number + } +} + +// Why: production cold-park hysteresis is 30s with a multi-minute hot-retain +// window. The fast-park override must be scoped to THIS spec's app launches — +// mutating process.env at module scope leaked into later specs when a worker +// reloaded files without replaying this file's afterAll. +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) } +}) + +const PARKED_FRAME_SCRIPT_DELAY_MS = 750 +const PARKED_FRAME_COUNT = 25 + +function parkedTuiFrame(runId: string, frame: number): string { + const progress = `${'█'.repeat((frame % 8) + 1)}${'░'.repeat(8 - ((frame % 8) + 1))}` + const rows = [ + '╭────────────────────────────────────────────────────────────────────╮', + `│ Parked view restore Frame ${String(frame).padStart(3, '0')} ${frame % 2 === 0 ? '🟢' : '🟡'} ${progress} │`, + '├──────────────┬──────────────────────┬──────────────────────────────┤', + `│ model │ codex/opencode │ ${runId.slice(0, 28).padEnd(28)} │`, + `│ status │ ${frame % 2 === 0 ? 'thinking' : 'streaming'} │ input ${'#'.repeat((frame % 18) + 1).padEnd(22)} │`, + `│ diff │ +${String(frame * 3).padEnd(19)} │ -${String(frame).padEnd(27)} │`, + '╰──────────────┴──────────────────────┴──────────────────────────────╯', + `PARKED_RESTORE_FINAL_${runId}_${frame}` + ] + return [ + '\x1b[?2026h', + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'), + '\x1b[10;18H\x1b[?25h', + '\x1b[?2026l' + ].join('') +} + +function writeParkedFrameScript(scriptPath: string, runId: string): void { + const frames = Array.from({ length: PARKED_FRAME_COUNT }, (_, frame) => + parkedTuiFrame(runId, frame) + ) + mkdirSync(path.dirname(scriptPath), { recursive: true }) + writeFileSync( + scriptPath, + `setTimeout(() => process.stdout.write(${JSON.stringify(frames.join(''))}), ${PARKED_FRAME_SCRIPT_DELAY_MS})\n` + ) +} + +// Deterministic static alt-screen frame for the park/reveal cycle test: rich +// styling (box drawing, SGR colors, wide glyphs) that exercises the snapshot +// restore, painted once and held so the on-screen content is stable across +// cycles. No spinner/progress churn — the frame must be byte-identical every +// reveal so drift is detectable. +function cycleReferenceFrame(runId: string): string { + const rows = [ + '╭──────────────────────────────────────────────────────────╮', + `│ Park/reveal cycle reference ${runId.slice(0, 8)} 🟢 你好世界 터미널 │`, + '├───────────────┬──────────────────────────────────────────┤', + `│ model │ \x1b[1mcodex/opencode\x1b[22m stream +142 -37 │`, + `│ status │ \x1b[38;5;204mrunning\x1b[0m\x1b[2;36m diff --git a/pty.ts esc↩ │`, + '╰───────────────┴──────────────────────────────────────────╯', + `CYCLE_REFERENCE_${runId}` + ] + return [ + '\x1b[?1049h', + '\x1b[2J\x1b[H', + '\x1b[?25l', + rows.map((row) => `\x1b[2;36m${row}\x1b[0m`).join('\r\n'), + '\x1b[?25h' + ].join('') +} + +function writeCycleReferenceScript(scriptPath: string, runId: string): void { + mkdirSync(path.dirname(scriptPath), { recursive: true }) + // Paint the frame once, then hold the process open so the alt-screen TUI + // stays on screen (and the parkable PTY session stays alive) across cycles. + writeFileSync( + scriptPath, + `process.stdout.write(${JSON.stringify(cycleReferenceFrame(runId))}); setInterval(() => {}, 1000)\n` + ) +} + +// Why: serialize() re-emits the buffer with cursor-restore trailer sequences +// (ESC[…H, ESC[?25h) and the exact CSI form can differ run-to-run without any +// visible change. Compare the CONTENT rows, not the trailer — strip trailing +// control sequences and normalize whitespace-only tail lines. +function terminalContentRows(serialized: string): string[] { + // eslint-disable-next-line no-control-regex + const stripped = serialized.replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') + return stripped + .split(/\r?\n/) + .map((line) => line.replace(/\s+$/, '')) + .filter((line) => line.length > 0) +} + +async function readParkingWiring( + page: Page +): Promise<{ present: boolean; parkDelayMs: number | null }> { + return page.evaluate(() => { + const debug = (window as ParkingDebugWindow).__terminalParkingDebug + return { present: debug !== undefined, parkDelayMs: debug?.parkDelayMs ?? null } + }) +} + +// Why: the spec lands ahead of the feature wiring. Skip (rather than fail) +// when the app under test does not expose the parking debug handle so this +// file is safe to merge in any order with the wiring branch. +async function skipUnlessParkingWired(page: Page): Promise { + const deadline = Date.now() + 2_000 + let wiring = await readParkingWiring(page) + while (!wiring.present && Date.now() < deadline) { + await page.waitForTimeout(250) + wiring = await readParkingWiring(page) + } + test.skip( + !wiring.present, + 'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)' + ) +} + +type TerminalTabViewState = { + hasManager: boolean + paneCount: number +} + +async function readTerminalTabViewState(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return { + hasManager: manager !== undefined, + paneCount: manager?.getPanes?.().length ?? 0 + } + }, tabId) +} + +// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so a +// missing manager is the observable signal that the tab's xterm was parked. +async function waitForTabParked(page: Page, tabId: string): Promise { + const parkWaitStartedAt = Date.now() + await expect + .poll(async () => (await readTerminalTabViewState(page, tabId)).hasManager, { + timeout: Math.max(20_000, PARKING_DELAY_MS * 10), + message: `terminal tab ${tabId} did not park (pane manager still mounted)` + }) + .toBe(false) + return Date.now() - parkWaitStartedAt +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((targetTabId) => { + const store = window.__store + if (!store) { + throw new Error('activateTerminalTab: window.__store is unavailable') + } + const state = store.getState() + state.setActiveTabType('terminal') + state.setActiveTab(targetTabId) + }, tabId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: `terminal tab ${tabId} did not become active` + }) + .toBe(tabId) +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + return tabId +} + +async function getUnreadTerminalTabIds(page: Page): Promise { + return page.evaluate(() => { + const store = window.__store + if (!store) { + return [] + } + return Object.keys(store.getState().unreadTerminalTabs) + }) +} + +async function isWorktreeUnread(page: Page, worktreeId: string): Promise { + return page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + return false + } + const worktree = Object.values(store.getState().worktreesByRepo) + .flat() + .find((candidate) => candidate.id === worktreeId) + return worktree?.isUnread === true + }, worktreeId) +} + +async function getTerminalTabTitle( + page: Page, + worktreeId: string, + tabId: string +): Promise { + const tabs = await getWorktreeTabs(page, worktreeId) + return tabs.find((tab) => tab.id === tabId)?.title ?? null +} + +async function hasPendingStartupCommand(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const store = window.__store + if (!store) { + return false + } + return store.getState().pendingStartupByTabId[tabId] !== undefined + }, tabId) +} + +type ParkableTabSetup = { + worktreeId: string + tabAId: string + tabAPtyId: string +} + +// Why: every scenario starts from the same shape — tab A live in the active +// worktree; callers then create more tabs on top so tab A goes hidden. +async function setUpParkableTabA(page: Page): Promise { + const worktreeId = await waitForActiveWorktree(page) + await skipUnlessParkingWired(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const tabASnapshot = await waitForPaneIdentitySnapshot(page, 1) + const tabAPtyId = tabASnapshot.panes[0]?.ptyId + if (!tabAPtyId) { + throw new Error('parking spec tab A did not bind a PTY') + } + return { + worktreeId, + tabAId: tabASnapshot.tabId, + tabAPtyId + } +} + +test.describe('Terminal hidden view parking', () => { + test('parks a hidden terminal tab and restores rich TUI output on reveal', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId, tabAPtyId } = setup + + const runId = randomUUID() + const finalMarker = `PARKED_RESTORE_FINAL_${runId}_${PARKED_FRAME_COUNT - 1}` + const scriptPath = path.join(testRepoPath, `.orca-parked-rich-tui-${runId}.mjs`) + writeParkedFrameScript(scriptPath, runId) + try { + await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'rich TUI final frame did not render while tab A was visible' + }) + .toContain(finalMarker) + + const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) + const parkDetectedAfterMs = await waitForTabParked(orcaPage, tabAId) + const wiring = await readParkingWiring(orcaPage) + testInfo.annotations.push({ + type: 'terminal-parking', + description: `parkDelayMs=${wiring.parkDelayMs ?? PARKING_DELAY_MS} parkDetectedAfterMs=${parkDetectedAfterMs}` + }) + + // Why: parking must be scoped to the hidden tab — the visible tab keeps + // a live pane manager and xterm. + const tabBState = await readTerminalTabViewState(orcaPage, tabBId) + expect(tabBState.hasManager).toBe(true) + expect(tabBState.paneCount).toBeGreaterThan(0) + + await activateTerminalTab(orcaPage, tabAId) + await waitForActiveTerminalManager(orcaPage, 30_000) + const revealedSnapshot = await waitForPaneIdentitySnapshot(orcaPage, 1) + expect(revealedSnapshot.tabId).toBe(tabAId) + // Why: parking only tears down the renderer view; the PTY session must + // survive so reveal reattaches to the same shell. + expect(revealedSnapshot.panes[0]?.ptyId).toBe(tabAPtyId) + + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'parked rich TUI frame did not restore when the tab was revealed' + }) + .toContain(finalMarker) + + const content = await getTerminalContent(orcaPage, 12_000) + expect(content).toContain(`Frame ${String(PARKED_FRAME_COUNT - 1).padStart(3, '0')}`) + expect(content).toContain('╭') + expect(content).toContain('├') + expect(content).toContain('█') + expect(content).not.toContain('Orca skipped hidden terminal output') + + // Why: the typed marker only appears joined in command *output*, so this + // proves the revealed terminal accepts input end-to-end, not just echo. + const typedMarker = `PARKED_TYPED_OK_${runId}` + const typedProbeScript = `console.log('PARKED_TYPED_OK_' + '${runId}')` + await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(typedProbeScript)}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 10_000, + message: 'revealed terminal did not execute and display typed input' + }) + .toContain(typedMarker) + + const screenshotPath = testInfo.outputPath('parked-tab-restore-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('parked-tab-restore-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + rmSync(scriptPath, { force: true }) + } + }) + + test('keeps bell and title side effects live while parked', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId, tabAPtyId } = setup + + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabAId) + + const runId = randomUUID() + const parkedTitle = `Parked side effects ${runId}` + const marker = `PARKED_SIDE_EFFECT_MARKER_${runId}` + // Why: OSC 0 title first, then a standalone BEL (the OSC terminator BEL + // must not count as a bell), then a content marker for the reveal check. + // The 30s keep-alive stops the shell prompt from overwriting the title + // before the store assertion lands. + const payload = `\x1b]0;${parkedTitle}\x07\x07${marker}\n` + const sideEffectScript = `process.stdout.write(${JSON.stringify(payload)}); setTimeout(() => process.exit(0), 30000)` + await sendToTerminal(orcaPage, tabAPtyId, `node -e ${JSON.stringify(sideEffectScript)}\r`) + + await expect + .poll(() => getTerminalTabTitle(orcaPage, worktreeId, tabAId), { + timeout: 10_000, + message: 'parked OSC 0 title did not update the tab title in the store' + }) + .toBe(parkedTitle) + await expect + .poll(async () => (await getUnreadTerminalTabIds(orcaPage)).includes(tabAId), { + timeout: 10_000, + message: 'parked BEL did not mark the terminal tab unread' + }) + .toBe(true) + await expect + .poll(() => isWorktreeUnread(orcaPage, worktreeId), { + timeout: 10_000, + message: 'parked BEL did not mark the worktree unread' + }) + .toBe(true) + + // Why: side effects must come from the pane-less watcher — the burst must + // not have woken the parked view back up. + expect((await readTerminalTabViewState(orcaPage, tabAId)).hasManager).toBe(false) + + await activateTerminalTab(orcaPage, tabAId) + await waitForActiveTerminalManager(orcaPage, 30_000) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'parked side-effect marker did not restore when the tab was revealed' + }) + .toContain(marker) + }) + + test('does not park excluded tabs', async ({ orcaPage }) => { + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId } = setup + + // Tab C: parking-excluded because it has a pending startup command. Queue + // it after the pane mounted so the mount-time consume cannot drain it. + const tabCId = await createActiveTerminalTab(orcaPage, worktreeId) + await orcaPage.evaluate((tabId) => { + const store = window.__store + if (!store) { + throw new Error('parking exclusion spec: window.__store is unavailable') + } + store.getState().queueTabStartupCommand(tabId, { command: 'echo parked-exclusion-probe' }) + }, tabCId) + expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true) + + // Tab B on top hides both A and C. + const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) + await expect + .poll(() => getActiveTabId(orcaPage), { + timeout: 5_000, + message: 'tab B did not stay active while waiting on the parking window' + }) + .toBe(tabBId) + + // Why: tab A parking proves the machinery ran past the delay in this app + // instance, so the tab C assertion below is not vacuously green. + await waitForTabParked(orcaPage, tabAId) + await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) + + // Premise guard: nothing consumed the pending startup while hidden. + expect(await hasPendingStartupCommand(orcaPage, tabCId)).toBe(true) + const tabCState = await readTerminalTabViewState(orcaPage, tabCId) + expect(tabCState.hasManager).toBe(true) + expect(tabCState.paneCount).toBeGreaterThan(0) + }) + + // Drives 25 deterministic park→reveal cycles on a static rich TUI frame and + // asserts every reveal reproduces the SAME content the tab showed while it was + // continuously visible (the never-parked reference). This is the field-garble + // guard end-to-end: it exercises the real renderer teardown + HeadlessEmulator + // snapshot restore + PTY reattach path the fuzz suites model in isolation, and + // fails if any single cycle — or accumulated drift across 25 — garbles a cell. + test('reproduces a static frame byte-for-byte across 25 park/reveal cycles', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(180_000) + await waitForSessionReady(orcaPage) + const setup = await setUpParkableTabA(orcaPage) + const { worktreeId, tabAId, tabAPtyId } = setup + + const runId = randomUUID() + const marker = `CYCLE_REFERENCE_${runId}` + const scriptPath = path.join(testRepoPath, `.orca-cycle-reference-${runId}.mjs`) + writeCycleReferenceScript(scriptPath, runId) + try { + await sendToTerminal(orcaPage, tabAPtyId, `node ${JSON.stringify(scriptPath)}\r`) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: 'cycle reference frame did not render while tab A was visible' + }) + .toContain(marker) + + // Tab B stays visible whenever tab A is parked; toggling the active tab + // between them is the deterministic hide/reveal driver. + const tabBId = await createActiveTerminalTab(orcaPage, worktreeId) + + // One park/reveal cycle to run the frame through the snapshot restore for a + // baseline. Why not compare against the visible-before-park content: an + // alt-screen restore deliberately drops the normal-buffer scrollback + // (serializeHeadlessTerminalBuffer forces scrollback 0 under alt), so the + // pre-park serialize carries the shell command echo the restore correctly + // omits — that is contract, not garble. Baselining after one reveal makes + // both sides pass through identical machinery, so any later diff is drift. + const runOneParkRevealCycle = async (cycle: number): Promise => { + await activateTerminalTab(orcaPage, tabBId) + await waitForTabParked(orcaPage, tabAId) + await activateTerminalTab(orcaPage, tabAId) + await waitForActiveTerminalManager(orcaPage, 30_000) + const revealed = await waitForPaneIdentitySnapshot(orcaPage, 1) + expect(revealed.panes[0]?.ptyId).toBe(tabAPtyId) + await expect + .poll(() => getTerminalContent(orcaPage, 12_000), { + timeout: 15_000, + message: `cycle ${cycle}: reference frame did not restore on reveal` + }) + .toContain(marker) + const rows = terminalContentRows(await getTerminalContent(orcaPage, 12_000)) + // Garble sentinel: the hidden-skip banner must never appear. + expect(rows.join('\n')).not.toContain('Orca skipped hidden terminal output') + return rows + } + + const referenceRows = await runOneParkRevealCycle(0) + expect(referenceRows.join('\n')).toContain(marker) + expect(referenceRows.join('\n')).toContain('╭') + + // waitForTabParked (inside runOneParkRevealCycle) throws if the tab never + // parked, so reaching here means the machinery ran every cycle — no + // separate premise guard needed for a vacuous-green check. + const CYCLES = 25 + const mismatches: string[] = [] + for (let cycle = 1; cycle < CYCLES; cycle++) { + const rows = await runOneParkRevealCycle(cycle) + if (JSON.stringify(rows) !== JSON.stringify(referenceRows)) { + mismatches.push( + `cycle ${cycle}:\n expected: ${JSON.stringify(referenceRows)}\n actual: ${JSON.stringify(rows)}` + ) + } + } + + testInfo.annotations.push({ + type: 'terminal-parking-cycles', + description: `cycles=${CYCLES} mismatches=${mismatches.length}` + }) + expect( + mismatches, + `park/reveal drift across ${CYCLES} cycles:\n${mismatches.join('\n')}` + ).toEqual([]) + + const screenshotPath = testInfo.outputPath('park-reveal-25-cycles-final.png') + await orcaPage.screenshot({ path: screenshotPath, fullPage: true }) + await testInfo.attach('park-reveal-25-cycles-final.png', { + path: screenshotPath, + contentType: 'image/png' + }) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +}) diff --git a/tests/e2e/terminal-long-table-scroll-restore.spec.ts b/tests/e2e/terminal-long-table-scroll-restore.spec.ts index 1af4b1ecf41..6452207980f 100644 --- a/tests/e2e/terminal-long-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-long-table-scroll-restore.spec.ts @@ -51,17 +51,6 @@ type TerminalRenderDiagnostics = { }[] } -type LongTableDebugWindow = Window & { - __terminalPtyOutputDebug?: { - reset: () => void - snapshot: () => { - hiddenRendererSkipCount: number - hiddenRendererSkippedChars: number - hiddenRendererMode2031ReplyCount: number - } - } -} - async function setNarrowTerminalViewport(page: Page): Promise { await page.setViewportSize({ width: 900, height: 820 }) await page.waitForTimeout(250) @@ -359,7 +348,6 @@ test.describe('Terminal long table scroll restore repro', () => { window.__store ?.getState() .markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) - ;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset() }) const firstWorktreeId = await waitForActiveWorktree(orcaPage) const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( @@ -398,10 +386,6 @@ test.describe('Terminal long table scroll restore repro', () => { await scrollActiveTerminalLikeUser(orcaPage) await closeFeatureTips(orcaPage) const diagnostics = await readTerminalRenderDiagnostics(orcaPage) - const hiddenDebug = await orcaPage.evaluate(() => - (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() - ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) const restoredPane = diagnostics.allPaneStates.find((paneState) => paneState.hasMarker) expect(restoredPane).toBeDefined() expect(diagnostics.cursorHidden).toBe(false) @@ -426,7 +410,6 @@ test.describe('Terminal long table scroll restore repro', () => { window.__store ?.getState() .markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) - ;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset() }) const firstWorktreeId = await waitForActiveWorktree(orcaPage) const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( @@ -467,10 +450,6 @@ test.describe('Terminal long table scroll restore repro', () => { await scrollActiveTerminalLikeUser(orcaPage) await closeFeatureTips(orcaPage) const diagnostics = await readTerminalRenderDiagnostics(orcaPage) - const hiddenDebug = await orcaPage.evaluate(() => - (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() - ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) // Why: renderer cell metrics can land one column wider in headless runs; // the content and screenshot assertions below cover the actual regression. expect(diagnostics.cols).toBeLessThanOrEqual(112) @@ -504,7 +483,6 @@ test.describe('Terminal long table scroll restore repro', () => { window.__store ?.getState() .markFeatureTipsSeen(['orca-cli', 'cmd-j-palette', 'voice-dictation']) - ;(window as LongTableDebugWindow).__terminalPtyOutputDebug?.reset() }) const firstWorktreeId = await waitForActiveWorktree(orcaPage) const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( @@ -571,10 +549,6 @@ test.describe('Terminal long table scroll restore repro', () => { const diagnostics = await readTerminalRenderDiagnostics(orcaPage) const overpaint = await readTerminalRightEdgeOverpaint(orcaPage) const wrapDiagnostics = await readTerminalBoxTableWrapDiagnostics(orcaPage) - const hiddenDebug = await orcaPage.evaluate(() => - (window as LongTableDebugWindow).__terminalPtyOutputDebug?.snapshot() - ) - expect(hiddenDebug?.hiddenRendererSkipCount).toBe(0) expect(diagnostics.cols).toBeLessThanOrEqual(NARROW_TERMINAL_MAX_COLS) expect(wrapDiagnostics.cols).toBeGreaterThanOrEqual(generatedTableWidth) expect(diagnostics.cursorHidden).toBe(false) diff --git a/tests/e2e/terminal-multi-workspace-typing-latency.spec.ts b/tests/e2e/terminal-multi-workspace-typing-latency.spec.ts new file mode 100644 index 00000000000..49bb13fc496 --- /dev/null +++ b/tests/e2e/terminal-multi-workspace-typing-latency.spec.ts @@ -0,0 +1,602 @@ +/** + * Deterministic reproduction + benchmark for "typing lags while multiple + * workspaces run agents" (the multi-workspace typing-latency complaint). + * + * Unlike the artificial-opencode suite (bounded bursts + held ACK gates), + * this harness runs SUSTAINED paced agent-TUI streams through real PTYs in + * background-workspace panes (and optionally visible splits) with no + * artificial wedges, types at a fixed cadence WITHOUT waiting for each echo + * (real users keep typing), and decomposes every key's latency into: + * input-half = CDP keydown -> byte arrives at the pty (probe sidecar) + * echo-half = pty echo -> marker visible in the xterm buffer + * All three clocks are epoch ms on one machine, so the halves add up. + * + * Scenarios are gated behind ORCA_TYPING_BENCH=1 (they are benchmarks that + * may legitimately "fail" while the bug reproduces, not CI regression gates). + * Entry point: pnpm bench:multi-workspace-typing (see + * config/scripts/run-multi-workspace-typing-bench.mjs for knobs). Results are + * written as JSON to tools/benchmarks/results/ for A/B comparison. + */ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { type ChildProcess, spawn } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveWorktreeId, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager +} from './helpers/terminal' +import { + ensureActiveWorktreePaneLoad, + focusActiveTerminalInput, + focusPane, + waitForTerminalOutputForPtyId, + type TerminalLoadPane +} from './artificial-opencode-pane-interactions' +import { + sustainedLoadReadyFilePath, + typingKeyMarkerPrefix, + typingProbeReadyMarker, + writeSustainedAgentLoadScript, + writeTypingEchoProbeScript +} from './sustained-agent-typing-load-scripts' + +const BENCH_ENABLED = process.env.ORCA_TYPING_BENCH === '1' + +function readPositiveInt(name: string, fallback: number): number { + const value = Number(process.env[name]) + return Number.isInteger(value) && value > 0 ? value : fallback +} + +const LOAD_PANES = readPositiveInt('ORCA_TYPING_BENCH_LOAD_PANES', 4) +const LOAD_RATE_KBPS = readPositiveInt('ORCA_TYPING_BENCH_RATE_KBPS', 256) +const KEY_COUNT = readPositiveInt('ORCA_TYPING_BENCH_KEYS', 32) +const KEY_CADENCE_MS = readPositiveInt('ORCA_TYPING_BENCH_KEY_CADENCE_MS', 250) +const CPU_WORKERS = readPositiveInt('ORCA_TYPING_BENCH_CPU_WORKERS', 0) +const BENCH_LABEL = process.env.ORCA_TYPING_BENCH_LABEL ?? 'dev' + +const KEY_CHARS = 'abcdefghijklmnopqrstuvwxyz' +const TIMER_SAMPLE_MS = 16 +const MARKER_SCAN_TRAILING_ROWS = 160 +const ECHO_STRAGGLER_TIMEOUT_MS = 30_000 +// Load must outlive setup (pane splits, worktree switches) plus the typing +// window; generously padded because setup time varies with pane count. +const LOAD_DURATION_S = Math.ceil((KEY_COUNT * KEY_CADENCE_MS) / 1000) + 90 + +const RESULTS_DIR = path.resolve(__dirname, '..', '..', 'tools', 'benchmarks', 'results') + +type LatencyStats = { + count: number + p50: number + p90: number + p99: number + max: number +} + +type KeySample = { + seq: number + sentAt: number + ptyArrivedAt: number | null + echoSeenAt: number | null +} + +type PacedTypingMeasurement = { + keyCount: number + missingPtyArrivalCount: number + missingEchoCount: number + totalMs: LatencyStats | null + inputHalfMs: LatencyStats | null + echoHalfMs: LatencyStats | null + maxTimerDriftMs: number + samples: KeySample[] +} + +type SchedulerDebugSnapshot = { + queuedChars: number + peakQueuedChars: number + droppedBacklogCount: number +} + +type MainDeliveryDebugSnapshot = { + pendingChars: number + peakPendingChars: number + peakRendererInFlightChars: number + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryDroppedChars: number + pendingDroppedChars: number +} + +type TypingBenchWindow = Window & { + __terminalOutputSchedulerDebug?: { + reset: () => void + snapshot: () => SchedulerDebugSnapshot + } +} + +function latencyStats(samples: number[]): LatencyStats | null { + if (samples.length === 0) { + return null + } + const sorted = [...samples].sort((a, b) => a - b) + const at = (q: number): number => + sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))] + return { + count: sorted.length, + p50: at(0.5), + p90: at(0.9), + p99: at(0.99), + max: sorted.at(-1) ?? 0 + } +} + +async function scanRecentKeyMarkerSeqs( + page: Page, + markerPrefix: string +): Promise<{ seqs: number[]; atMs: number }> { + return page.evaluate( + ({ markerPrefix, trailingRows }) => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + const seqs: number[] = [] + if (!pane) { + return { seqs, atMs: Date.now() } + } + // Why trailing rows, not serialize: full-buffer serialization on every + // poll runs on the renderer main thread and would perturb the very + // latency being measured (same rationale as the history-size spec). + const re = new RegExp(`${markerPrefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(\\d+)`, 'g') + const buffer = pane.terminal.buffer.active + const start = Math.max(0, buffer.length - trailingRows) + for (let row = start; row < buffer.length; row += 1) { + const line = buffer.getLine(row)?.translateToString(true) ?? '' + let match: RegExpExecArray | null + while ((match = re.exec(line)) !== null) { + seqs.push(Number(match[1])) + } + } + return { seqs, atMs: Date.now() } + }, + { markerPrefix, trailingRows: MARKER_SCAN_TRAILING_ROWS } + ) +} + +function readKeyArrivalSidecar(sidecarPath: string): Map { + const arrivals = new Map() + let raw = '' + try { + raw = readFileSync(sidecarPath, 'utf8') + } catch { + return arrivals + } + for (const line of raw.split('\n')) { + if (!line.trim()) { + continue + } + try { + const entry = JSON.parse(line) as { seq: number; atMs: number } + arrivals.set(entry.seq, entry.atMs) + } catch { + /* torn tail write; final retry pass re-reads */ + } + } + return arrivals +} + +async function measurePacedTyping( + page: Page, + runId: string, + sidecarPath: string +): Promise { + const markerPrefix = typingKeyMarkerPrefix(runId) + await focusActiveTerminalInput(page) + + const timerDrift = await page.evaluateHandle((sampleMs) => { + let maxTimerDriftMs = 0 + let lastTick = performance.now() + const timer = window.setInterval(() => { + const now = performance.now() + maxTimerDriftMs = Math.max(maxTimerDriftMs, now - lastTick - sampleMs) + lastTick = now + }, sampleMs) + return { + stop: () => { + window.clearInterval(timer) + return maxTimerDriftMs + } + } + }, TIMER_SAMPLE_MS) + + // Concurrent echo watcher: records the first time each key's marker is + // visible in the buffer, while typing continues at its own cadence. + const echoSeenAt = new Map() + let watching = true + const echoWatcher = (async () => { + while (watching) { + const { seqs, atMs } = await scanRecentKeyMarkerSeqs(page, markerPrefix) + for (const seq of seqs) { + if (!echoSeenAt.has(seq)) { + echoSeenAt.set(seq, atMs) + } + } + await page.waitForTimeout(10) + } + })() + + const sentAtBySeq = new Map() + try { + for (let index = 0; index < KEY_COUNT; index++) { + const seq = index + 1 + const tickStart = Date.now() + sentAtBySeq.set(seq, tickStart) + await page.keyboard.type(KEY_CHARS[index % KEY_CHARS.length]) + const elapsed = Date.now() - tickStart + if (elapsed < KEY_CADENCE_MS) { + await page.waitForTimeout(KEY_CADENCE_MS - elapsed) + } + } + // Wait out stragglers so a slow echo is measured, not dropped. + const stragglerDeadline = Date.now() + ECHO_STRAGGLER_TIMEOUT_MS + while (echoSeenAt.size < KEY_COUNT && Date.now() < stragglerDeadline) { + await page.waitForTimeout(25) + } + } finally { + watching = false + await echoWatcher + } + const maxTimerDriftMs = await timerDrift.evaluate((watcher) => watcher.stop()) + await timerDrift.dispose() + + // The probe appends arrivals asynchronously; re-read until complete or 5s. + let arrivals = readKeyArrivalSidecar(sidecarPath) + const sidecarDeadline = Date.now() + 5_000 + while (arrivals.size < KEY_COUNT && Date.now() < sidecarDeadline) { + await new Promise((resolve) => setTimeout(resolve, 100)) + arrivals = readKeyArrivalSidecar(sidecarPath) + } + + const samples: KeySample[] = [] + const totalMs: number[] = [] + const inputHalfMs: number[] = [] + const echoHalfMs: number[] = [] + for (let seq = 1; seq <= KEY_COUNT; seq++) { + const sentAt = sentAtBySeq.get(seq) ?? 0 + const ptyArrivedAt = arrivals.get(seq) ?? null + const seenAt = echoSeenAt.get(seq) ?? null + samples.push({ seq, sentAt, ptyArrivedAt, echoSeenAt: seenAt }) + if (ptyArrivedAt !== null) { + inputHalfMs.push(ptyArrivedAt - sentAt) + } + if (seenAt !== null) { + totalMs.push(seenAt - sentAt) + if (ptyArrivedAt !== null) { + echoHalfMs.push(seenAt - ptyArrivedAt) + } + } + } + + return { + keyCount: KEY_COUNT, + missingPtyArrivalCount: KEY_COUNT - arrivals.size, + missingEchoCount: KEY_COUNT - echoSeenAt.size, + totalMs: latencyStats(totalMs), + inputHalfMs: latencyStats(inputHalfMs), + echoHalfMs: latencyStats(echoHalfMs), + maxTimerDriftMs, + samples + } +} + +async function readSchedulerDebug(page: Page): Promise { + return page.evaluate( + () => (window as TypingBenchWindow).__terminalOutputSchedulerDebug?.snapshot() ?? null + ) +} + +async function readMainDeliveryDebug(page: Page): Promise { + return page.evaluate(async () => window.api.pty.getRendererDeliveryDebugSnapshot()) +} + +async function resetDeliveryDebug(page: Page): Promise { + await page.evaluate(async () => { + ;(window as TypingBenchWindow).__terminalOutputSchedulerDebug?.reset() + await window.api.pty.resetRendererDeliveryDebug() + }) +} + +function spawnCpuPressureWorkers(): ChildProcess[] { + const workerPath = path.resolve( + __dirname, + '..', + '..', + 'tools', + 'benchmarks', + 'cpu-pressure-worker.mjs' + ) + return Array.from({ length: CPU_WORKERS }, () => + spawn(process.execPath, [workerPath, String((LOAD_DURATION_S + 120) * 1000)], { + stdio: 'ignore' + }) + ) +} + +function writeBenchReport( + testInfo: TestInfo, + scenario: string, + measurement: PacedTypingMeasurement, + scheduler: SchedulerDebugSnapshot | null, + mainDelivery: MainDeliveryDebugSnapshot | null +): void { + const report = { + benchmark: 'multi-workspace-typing-latency', + label: BENCH_LABEL, + scenario, + timestamp: new Date().toISOString(), + config: { + loadPanes: LOAD_PANES, + loadRateKbps: LOAD_RATE_KBPS, + keyCount: KEY_COUNT, + keyCadenceMs: KEY_CADENCE_MS, + cpuWorkers: CPU_WORKERS + }, + measurement, + scheduler, + mainDelivery + } + mkdirSync(RESULTS_DIR, { recursive: true }) + const stamp = report.timestamp.replace(/[:.]/g, '-') + const outPath = path.join( + RESULTS_DIR, + `multi-workspace-typing-${BENCH_LABEL}-${scenario}-${stamp}.json` + ) + writeFileSync(outPath, JSON.stringify(report, null, 2)) + const fmt = (stats: LatencyStats | null): string => + stats + ? `p50 ${stats.p50.toFixed(1)}ms p90 ${stats.p90.toFixed(1)}ms max ${stats.max.toFixed(1)}ms` + : 'n/a' + testInfo.annotations.push({ + type: `multi-workspace-typing-${scenario}`, + description: + `total ${fmt(measurement.totalMs)} | input-half ${fmt(measurement.inputHalfMs)} | ` + + `echo-half ${fmt(measurement.echoHalfMs)} | drift ${measurement.maxTimerDriftMs.toFixed(1)}ms | ` + + `missingEcho ${measurement.missingEchoCount} | report ${outPath}` + }) + console.log(`[multi-workspace-typing] ${scenario}: ${testInfo.annotations.at(-1)?.description}`) +} + +async function startSustainedLoadInPanes( + page: Page, + panes: TerminalLoadPane[], + scriptPath: string, + runId: string, + readyFileDirectory: string +): Promise { + for (const [index, pane] of panes.entries()) { + await sendToTerminal( + page, + pane.ptyId, + `node ${JSON.stringify(scriptPath)} ${index} ${LOAD_RATE_KBPS} ${LOAD_DURATION_S}\r` + ) + } + // Readiness is signalled via files, not terminal markers: a streaming pane + // scrolls its READY line out of the buffer before sequential checks get to + // it once several panes start together. + const missingReadyPanes = (): number[] => + panes + .map((_, index) => index) + .filter((index) => !existsSync(sustainedLoadReadyFilePath(readyFileDirectory, runId, index))) + await expect + .poll(() => missingReadyPanes().length, { + timeout: 30_000, + message: `load panes never signalled ready: ${missingReadyPanes().join(', ')}` + }) + .toBe(0) +} + +async function startTypingProbe( + page: Page, + typingPtyId: string, + scriptPath: string, + runId: string +): Promise { + await sendToTerminal(page, typingPtyId, `node ${JSON.stringify(scriptPath)}\r`) + await waitForTerminalOutputForPtyId(page, typingPtyId, typingProbeReadyMarker(runId), 15_000) +} + +function removeLoadReadyFiles(directory: string, runId: string, paneCount: number): void { + for (let index = 0; index < paneCount; index++) { + rmSync(sustainedLoadReadyFilePath(directory, runId, index), { force: true }) + } +} + +async function stopPtysQuietly(page: Page, ptyIds: string[]): Promise { + await Promise.all( + ptyIds.map((ptyId) => sendToTerminal(page, ptyId, '\x03').catch(() => undefined)) + ) +} + +test.describe('Multi-workspace sustained typing latency bench', () => { + test.describe.configure({ mode: 'serial' }) + test.setTimeout(10 * 60 * 1000) + + test('baseline: paced typing with no agent load', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.skip(!BENCH_ENABLED, 'Bench-only: run via pnpm bench:multi-workspace-typing') + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const typingPtyId = await waitForActivePanePtyId(orcaPage) + + const runId = randomUUID() + const probePath = path.join(testRepoPath, `.orca-mwt-probe-${runId}.mjs`) + const sidecarPath = path.join(testRepoPath, `.orca-mwt-arrivals-${runId}.jsonl`) + writeTypingEchoProbeScript(probePath, runId, sidecarPath) + try { + await resetDeliveryDebug(orcaPage) + await startTypingProbe(orcaPage, typingPtyId, probePath, runId) + const measurement = await measurePacedTyping(orcaPage, runId, sidecarPath) + writeBenchReport( + testInfo, + 'baseline', + measurement, + await readSchedulerDebug(orcaPage), + await readMainDeliveryDebug(orcaPage) + ) + expect(measurement.missingEchoCount).toBe(0) + expect(measurement.totalMs?.p50 ?? Number.POSITIVE_INFINITY).toBeLessThan(250) + } finally { + await stopPtysQuietly(orcaPage, [typingPtyId]) + rmSync(probePath, { force: true }) + rmSync(sidecarPath, { force: true }) + } + }) + + test('typing under sustained hidden multi-workspace agent load', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.skip(!BENCH_ENABLED, 'Bench-only: run via pnpm bench:multi-workspace-typing') + await waitForSessionReady(orcaPage) + const typingWorktreeId = await waitForActiveWorktree(orcaPage) + const loadWorktreeId = (await getAllWorktreeIds(orcaPage)).find((id) => id !== typingWorktreeId) + expect(Boolean(loadWorktreeId), 'bench needs the seeded secondary worktree').toBe(true) + if (!loadWorktreeId) { + return + } + + const runId = randomUUID() + const loadPath = path.join(testRepoPath, `.orca-mwt-load-${runId}.mjs`) + const probePath = path.join(testRepoPath, `.orca-mwt-probe-${runId}.mjs`) + const sidecarPath = path.join(testRepoPath, `.orca-mwt-arrivals-${runId}.jsonl`) + writeSustainedAgentLoadScript(loadPath, runId, testRepoPath) + writeTypingEchoProbeScript(probePath, runId, sidecarPath) + + const cpuWorkers = spawnCpuPressureWorkers() + let loadPanes: TerminalLoadPane[] = [] + try { + await switchToWorktree(orcaPage, loadWorktreeId) + loadPanes = await ensureActiveWorktreePaneLoad(orcaPage, LOAD_PANES) + await startSustainedLoadInPanes(orcaPage, loadPanes, loadPath, runId, testRepoPath) + + await switchToWorktree(orcaPage, typingWorktreeId) + await expect + .poll(() => getActiveWorktreeId(orcaPage), { timeout: 10_000 }) + .toBe(typingWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const typingPtyId = await waitForActivePanePtyId(orcaPage) + + await resetDeliveryDebug(orcaPage) + // Load is flowing when the hidden-delivery gate starts dropping the + // background worktree's bytes — the topology the complaint describes. + await expect + .poll( + async () => (await readMainDeliveryDebug(orcaPage))?.hiddenDeliveryDroppedChars ?? 0, + { timeout: 30_000, message: 'hidden load never started flowing' } + ) + .toBeGreaterThan(0) + + await startTypingProbe(orcaPage, typingPtyId, probePath, runId) + const measurement = await measurePacedTyping(orcaPage, runId, sidecarPath) + writeBenchReport( + testInfo, + `hidden-load-${LOAD_PANES}x${LOAD_RATE_KBPS}kbps-cpu${CPU_WORKERS}`, + measurement, + await readSchedulerDebug(orcaPage), + await readMainDeliveryDebug(orcaPage) + ) + // Hang detector only — the JSON report is the benchmark output. A + // reproduced regression shows up as large percentiles, not a hard fail. + expect(measurement.missingEchoCount).toBe(0) + + await stopPtysQuietly(orcaPage, [typingPtyId]) + } finally { + for (const worker of cpuWorkers) { + worker.kill('SIGKILL') + } + await switchToWorktree(orcaPage, loadWorktreeId).catch(() => undefined) + await stopPtysQuietly( + orcaPage, + loadPanes.map((pane) => pane.ptyId) + ) + await switchToWorktree(orcaPage, typingWorktreeId).catch(() => undefined) + rmSync(loadPath, { force: true }) + rmSync(probePath, { force: true }) + rmSync(sidecarPath, { force: true }) + removeLoadReadyFiles(testRepoPath, runId, LOAD_PANES) + } + }) + + test('typing under sustained visible split agent load', async ({ + orcaPage, + testRepoPath + }, testInfo) => { + test.skip(!BENCH_ENABLED, 'Bench-only: run via pnpm bench:multi-workspace-typing') + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + + const runId = randomUUID() + const loadPath = path.join(testRepoPath, `.orca-mwt-load-${runId}.mjs`) + const probePath = path.join(testRepoPath, `.orca-mwt-probe-${runId}.mjs`) + const sidecarPath = path.join(testRepoPath, `.orca-mwt-arrivals-${runId}.jsonl`) + writeSustainedAgentLoadScript(loadPath, runId, testRepoPath) + writeTypingEchoProbeScript(probePath, runId, sidecarPath) + + const cpuWorkers = spawnCpuPressureWorkers() + let panes: TerminalLoadPane[] = [] + try { + // Pane 0 types; the rest replay the agent stream side by side — the + // "Claude Code running in a visible split" shape. + panes = await ensureActiveWorktreePaneLoad(orcaPage, 2) + const [typingPane, ...loadPanes] = panes + await startSustainedLoadInPanes(orcaPage, loadPanes, loadPath, runId, testRepoPath) + await focusPane(orcaPage, typingPane.paneKey) + + await resetDeliveryDebug(orcaPage) + await startTypingProbe(orcaPage, typingPane.ptyId, probePath, runId) + const measurement = await measurePacedTyping(orcaPage, runId, sidecarPath) + writeBenchReport( + testInfo, + `visible-split-${LOAD_RATE_KBPS}kbps-cpu${CPU_WORKERS}`, + measurement, + await readSchedulerDebug(orcaPage), + await readMainDeliveryDebug(orcaPage) + ) + expect(measurement.missingEchoCount).toBe(0) + } finally { + for (const worker of cpuWorkers) { + worker.kill('SIGKILL') + } + await stopPtysQuietly( + orcaPage, + panes.map((pane) => pane.ptyId) + ) + rmSync(loadPath, { force: true }) + rmSync(probePath, { force: true }) + rmSync(sidecarPath, { force: true }) + removeLoadReadyFiles(testRepoPath, runId, panes.length) + } + }) +}) diff --git a/tests/e2e/terminal-pane-close-layout-consistency.spec.ts b/tests/e2e/terminal-pane-close-layout-consistency.spec.ts new file mode 100644 index 00000000000..db837c317cb --- /dev/null +++ b/tests/e2e/terminal-pane-close-layout-consistency.spec.ts @@ -0,0 +1,360 @@ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + sendToTerminal, + splitActiveTerminalPane, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +/** + * Repro hunt for the "ghost blank pane" field incident: a split pane whose PTY + * and leaf→PTY binding were torn down while the persisted layout `root` kept + * the leaf, so revisiting the tab materialized a permanently blank pane with + * no terminal behind it. + * + * Field state (workspace remote-issue-2, 2026-07-09): root held 3 leaves, + * ptyIdsByLeafId held 2, no daemon session for the third — the close/exit ran + * near a hidden/park boundary. Each test here closes (or exits) a split pane + * at a different phase of the hidden-view parking lifecycle and asserts the + * invariant that broke in the field: + * + * leaves(persisted root) === keys(persisted ptyIdsByLeafId) === live panes + * + * A failing scenario IS the finding — it pins which boundary loses the layout + * collapse. + */ + +// Why 2000ms: the override shrinks BOTH cold-park delay and hot-retain, and +// the hidden-but-mounted scenario needs the shell exit to land well inside the +// hot-retain window — 500ms let slow shell teardown race past parking and turn +// that scenario into the exits-while-parked one. +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 2_000 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) } +}) + +type ParkingDebugWindow = Window & { + __terminalParkingDebug?: { parkDelayMs?: number } +} + +async function skipUnlessParkingWired(page: Page): Promise { + const deadline = Date.now() + 2_000 + let present = await page.evaluate( + () => (window as ParkingDebugWindow).__terminalParkingDebug !== undefined + ) + while (!present && Date.now() < deadline) { + await page.waitForTimeout(250) + present = await page.evaluate( + () => (window as ParkingDebugWindow).__terminalParkingDebug !== undefined + ) + } + test.skip(!present, 'terminal hidden view parking wiring is not compiled in') +} + +type LayoutConsistency = { + rootLeafIds: string[] + boundLeafIds: string[] + boundPtyIds: string[] + livePaneCount: number | null + hasManager: boolean + domPaneCount: number +} + +async function readLayoutConsistency(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const store = window.__store + if (!store) { + throw new Error('window.__store unavailable') + } + const layout = store.getState().terminalLayoutsByTabId[tabId] + const rootLeafIds: string[] = [] + type LayoutNode = + | { type: 'leaf'; leafId: string } + | { type: 'split'; first: LayoutNode; second: LayoutNode } + const walk = (node: LayoutNode | null | undefined): void => { + if (!node) { + return + } + if (node.type === 'leaf') { + rootLeafIds.push(node.leafId) + return + } + walk(node.first) + walk(node.second) + } + walk((layout?.root ?? null) as LayoutNode | null) + const manager = window.__paneManagers?.get(tabId) + const managerPanes = manager?.getPanes?.() ?? null + const paneElements = managerPanes ? new Set(managerPanes.map((pane) => pane.container)) : null + return { + rootLeafIds, + boundLeafIds: Object.keys(layout?.ptyIdsByLeafId ?? {}), + boundPtyIds: Object.values(layout?.ptyIdsByLeafId ?? {}), + livePaneCount: managerPanes ? managerPanes.length : null, + hasManager: manager !== undefined, + domPaneCount: paneElements + ? Array.from(document.querySelectorAll('.pane[data-leaf-id]')).filter( + (element) => paneElements.has(element) + ).length + : 0 + } + }, tabId) +} + +/** + * The invariant under hunt. Polls so post-close persists can land, then does a + * final full read whose diff names the divergence (stale root leaf vs dropped + * binding vs ghost live pane). + */ +async function expectLayoutConsistent( + page: Page, + tabId: string, + expectedPaneCount: number, + phase: string, + deadPtyId?: string +): Promise { + // Why: polling a shape (not a boolean) makes a timeout print the diverged + // state — which of root/bindings/live panes went stale is the finding. + await expect + .poll( + async () => { + const state = await readLayoutConsistency(page, tabId) + return { + hasManager: state.hasManager, + livePaneCount: state.livePaneCount, + domPaneCount: state.domPaneCount, + rootLeafCount: state.rootLeafIds.length, + boundLeafCount: state.boundLeafIds.length, + unboundRootLeafIds: state.rootLeafIds.filter( + (leafId) => !state.boundLeafIds.includes(leafId) + ), + deadPtyStillBound: deadPtyId ? state.boundPtyIds.includes(deadPtyId) : false + } + }, + { + timeout: 15_000, + message: `[${phase}] layout did not settle to ${expectedPaneCount} consistent pane(s)` + } + ) + .toEqual({ + hasManager: true, + livePaneCount: expectedPaneCount, + domPaneCount: expectedPaneCount, + rootLeafCount: expectedPaneCount, + boundLeafCount: expectedPaneCount, + unboundRootLeafIds: [], + deadPtyStillBound: false + }) +} + +async function closeLastPaneOnTab(page: Page, tabId: string): Promise { + await page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + if (!manager) { + throw new Error(`closeLastPaneOnTab: no mounted pane manager for tab ${tabId}`) + } + const target = manager.getPanes().at(-1) + if (!target) { + throw new Error('closeLastPaneOnTab: tab has no panes') + } + manager.closePane(target.id) + }, tabId) +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + return tabId +} + +async function activateTerminalTab(page: Page, tabId: string): Promise { + await page.evaluate((targetTabId) => { + const store = window.__store + if (!store) { + throw new Error('activateTerminalTab: window.__store is unavailable') + } + const state = store.getState() + state.setActiveTabType('terminal') + state.setActiveTab(targetTabId) + }, tabId) + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: `terminal tab ${tabId} did not become active` + }) + .toBe(tabId) +} + +// Why: TerminalPane unmount deletes its __paneManagers entry — that absence is +// the observable "parked" signal (same detection as the parking spec). +async function waitForTabParked(page: Page, tabId: string): Promise { + await expect + .poll(async () => page.evaluate((id) => window.__paneManagers?.get(id) !== undefined, tabId), { + timeout: Math.max(20_000, PARKING_DELAY_MS * 10), + message: `terminal tab ${tabId} did not park (pane manager still mounted)` + }) + .toBe(false) +} + +async function waitForTabRemounted(page: Page, tabId: string): Promise { + await expect + .poll(async () => page.evaluate((id) => window.__paneManagers?.get(id) !== undefined, tabId), { + timeout: 15_000, + message: `terminal tab ${tabId} did not remount on reveal` + }) + .toBe(true) +} + +type SplitTabSetup = { + worktreeId: string + tabId: string + splitLeafId: string + splitPtyId: string +} + +// Why: every scenario starts from the field shape — a tab whose main pane got +// a split (the "setup pane" analog) that is fully bound and settled. +async function setUpSplitTab(page: Page): Promise { + await waitForSessionReady(page) + const worktreeId = await waitForActiveWorktree(page) + await skipUnlessParkingWired(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + await waitForPaneIdentitySnapshot(page, 1) + const tabId = await getActiveTabId(page) + if (!tabId) { + throw new Error('setUpSplitTab: no active terminal tab') + } + await splitActiveTerminalPane(page, 'vertical') + const snapshot = await waitForPaneIdentitySnapshot(page, 2) + const splitPane = snapshot.panes.at(-1) + if (!splitPane?.ptyId) { + throw new Error('setUpSplitTab: split pane did not bind a PTY') + } + return { worktreeId, tabId, splitLeafId: splitPane.leafId, splitPtyId: splitPane.ptyId } +} + +test.describe('terminal pane close vs hidden/park lifecycle keeps layout consistent', () => { + test('control: close while visible', async ({ orcaPage }) => { + const { tabId } = await setUpSplitTab(orcaPage) + await closeLastPaneOnTab(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-visible') + }) + + test('close and hide the tab in the same tick', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await orcaPage.evaluate( + ({ tabId, worktreeId }) => { + const store = window.__store + const manager = window.__paneManagers?.get(tabId) + if (!store || !manager) { + throw new Error('close+hide: store/manager unavailable') + } + const target = manager.getPanes().at(-1) + if (!target) { + throw new Error('close+hide: no split pane') + } + manager.closePane(target.id) + // Hide tab A before any deferred post-close work can run. + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + }, + { tabId, worktreeId } + ) + await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-then-hide-same-tick') + }) + + test('close while hidden but still mounted (hot-retain window)', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + await closeLastPaneOnTab(orcaPage, tabId) + await waitForTabParked(orcaPage, tabId) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-while-hidden-mounted') + }) + + test('close immediately after reveal remount, before panes settle', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabId) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + // Close as soon as the manager exists — panes may still be attaching. + await orcaPage.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + const target = manager?.getPanes().at(-1) + if (manager && target) { + manager.closePane(target.id) + } + }, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'close-mid-reveal') + }) + + test('clean visible close survives a later park/reveal cycle', async ({ orcaPage }) => { + const { worktreeId, tabId } = await setUpSplitTab(orcaPage) + await closeLastPaneOnTab(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'pre-park close') + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabId) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'post-park-reveal') + }) + + test('split pane shell exits while hidden but still mounted', async ({ orcaPage }) => { + const { worktreeId, tabId, splitPtyId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + // The setup-script analog: the split's shell ends on its own while the + // tab is hidden-but-mounted. + await sendToTerminal(orcaPage, splitPtyId, 'exit\r') + await orcaPage.waitForTimeout(PARKING_DELAY_MS / 2) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + await expectLayoutConsistent(orcaPage, tabId, 1, 'shell-exit-while-hidden-mounted', splitPtyId) + }) + + test('split pane shell exits while the tab is parked', async ({ orcaPage }) => { + const { worktreeId, tabId, splitPtyId } = await setUpSplitTab(orcaPage) + await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabParked(orcaPage, tabId) + await sendToTerminal(orcaPage, splitPtyId, 'exit\r') + await orcaPage.waitForTimeout(PARKING_DELAY_MS) + await activateTerminalTab(orcaPage, tabId) + await waitForTabRemounted(orcaPage, tabId) + // Why: the parked exit is deliberately deferred (no PaneManager to promote + // siblings) — the reveal remount owns the per-leaf teardown. This asserts + // that ownership actually resolves instead of leaving a ghost pane. + await expectLayoutConsistent(orcaPage, tabId, 1, 'shell-exit-while-parked', splitPtyId) + }) +}) diff --git a/tests/e2e/terminal-parked-memory.spec.ts b/tests/e2e/terminal-parked-memory.spec.ts new file mode 100644 index 00000000000..2fb1165abbe --- /dev/null +++ b/tests/e2e/terminal-parked-memory.spec.ts @@ -0,0 +1,358 @@ +import type { Page, TestInfo } from '@stablyai/playwright-test' +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getActiveTabId, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActiveTerminalManager, + waitForPaneIdentitySnapshot +} from './helpers/terminal' + +// Why: production cold-park hysteresis is 30s. The fast-park env override is +// scoped to this spec's app launches via orcaAppExtraEnv (same pattern as +// terminal-hidden-view-parking.spec.ts) so it cannot leak into other specs. +const PARKING_DELAY_MS = Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || 500 + +test.use({ + orcaAppExtraEnv: { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARKING_DELAY_MS) }, + // Why: without this switch Chromium quantizes performance.memory and only + // refreshes it every ~20 minutes, so both scenarios report the same stale + // launch-time bucket instead of a comparable heap figure. + orcaAppExtraArgs: ['--enable-precise-memory-info'] +}) + +// Why: 8 hidden tabs is below the 12-tab hot-retain limit, but that limit +// never retains anything here — the ORCA_E2E_TERMINAL_PARKING_DELAY_MS +// collapse (terminal-parking-e2e-overrides.ts) shrinks hotRetainMs to the +// same delay as coldParkDelayMs, and the policy cold-parks any tab hidden +// past hotRetainMs before the retain-count limit is even consulted. So all 8 +// park without needing 14 tabs or extra policy knobs. +const SCROLLBACK_TAB_COUNT = 8 +const SCROLLBACK_LINE_COUNT = 3000 +const PARK_SETTLE_MS = 2_000 +const HEAP_SAMPLE_COUNT = 5 +const HEAP_SAMPLE_INTERVAL_MS = 250 +// Why: each test launches a fresh app, fills 8 terminals with ~3000 lines of +// scrollback each, then waits out the parking window — well past the default +// 120s per-test budget. +const PARKED_MEMORY_TEST_TIMEOUT_MS = 300_000 + +// Why: mixed-width content (ASCII, CJK wide cells, emoji, box drawing) makes +// each xterm hold realistic narrow+wide buffer rows, so released parked-tab +// memory reflects real agent output rather than uniform filler. +function writeScrollbackFillScript(scriptPath: string, runId: string): void { + const script = [ + `const tabIndex = process.argv[2] ?? '0'`, + `const wide = '統合端末記憶計測'`, + `const emoji = ['🟢', '🟡', '🔵', '🟣']`, + `const lines = []`, + `for (let i = 0; i < ${SCROLLBACK_LINE_COUNT}; i += 1) {`, + ` const ascii = ('tab ' + tabIndex + ' line ' + String(i).padStart(4, '0') + ' ').padEnd(48, 'abcdefghijklmnopqrstuvwxyz')`, + ` const box = '│' + '─'.repeat(8 + (i % 24)) + '│'`, + ` lines.push(ascii + ' ' + wide.repeat(1 + (i % 3)) + ' ' + emoji[i % 4] + ' ' + box)`, + `}`, + `process.stdout.write(lines.join('\\n') + '\\n')`, + `process.stdout.write('PARKED_MEMORY_FILL_DONE_${runId}_' + tabIndex + '\\n')` + ].join('\n') + writeFileSync(scriptPath, `${script}\n`) +} + +// Why: the spec lands ahead of the feature wiring in some merge orders. Skip +// (rather than fail) when the app under test does not expose the parking +// debug handle, mirroring terminal-hidden-view-parking.spec.ts. +async function skipUnlessParkingWired(page: Page): Promise { + const deadline = Date.now() + 2_000 + let present = await page.evaluate(() => window.__terminalParkingDebug !== undefined) + while (!present && Date.now() < deadline) { + await page.waitForTimeout(250) + present = await page.evaluate(() => window.__terminalParkingDebug !== undefined) + } + test.skip( + !present, + 'terminal hidden view parking wiring has not landed (window.__terminalParkingDebug missing)' + ) +} + +type TerminalTabViewState = { + hasManager: boolean + paneCount: number +} + +// Why: TerminalPane unmount deletes its entry from window.__paneManagers, so +// a missing manager is the observable signal that the tab's xterm was parked. +async function readTerminalTabViewState(page: Page, tabId: string): Promise { + return page.evaluate((tabId) => { + const manager = window.__paneManagers?.get(tabId) + return { + hasManager: manager !== undefined, + paneCount: manager?.getPanes?.().length ?? 0 + } + }, tabId) +} + +async function countMountedPaneManagers(page: Page, tabIds: string[]): Promise { + return page.evaluate( + (tabIds) => tabIds.filter((tabId) => window.__paneManagers?.get(tabId) !== undefined).length, + tabIds + ) +} + +async function waitForTabsParked(page: Page, tabIds: string[]): Promise { + await expect + .poll(() => countMountedPaneManagers(page, tabIds), { + timeout: Math.max(30_000, PARKING_DELAY_MS * 10), + message: 'hidden scrollback tabs did not all park (pane managers still mounted)' + }) + .toBe(0) +} + +type ScrollbackTab = { + tabId: string + ptyId: string +} + +async function createActiveTerminalTab(page: Page, worktreeId: string): Promise { + const tabId = await page.evaluate((worktreeId) => { + const store = window.__store + if (!store) { + throw new Error('createActiveTerminalTab: window.__store is unavailable') + } + const state = store.getState() + const tab = state.createTab(worktreeId, undefined, undefined, { activate: true }) + state.setActiveTab(tab.id) + state.setActiveTabType('terminal') + return tab.id + }, worktreeId) + + await expect + .poll(() => getActiveTabId(page), { + timeout: 5_000, + message: 'newly created terminal tab did not become active' + }) + .toBe(tabId) + await waitForActiveTerminalManager(page, 30_000) + const snapshot = await waitForPaneIdentitySnapshot(page, 1) + const ptyId = snapshot.panes[0]?.ptyId + if (snapshot.tabId !== tabId || !ptyId) { + throw new Error('createActiveTerminalTab: new tab did not bind a PTY') + } + return { tabId, ptyId } +} + +async function fillActiveTerminalWithScrollback( + page: Page, + ptyId: string, + scriptPath: string, + tabIndex: number, + runId: string +): Promise { + await sendToTerminal(page, ptyId, `node ${JSON.stringify(scriptPath)} ${tabIndex}\r`) + await expect + .poll(() => getTerminalContent(page, 4_000), { + timeout: 30_000, + message: `scrollback fill marker for tab ${tabIndex} did not render` + }) + .toContain(`PARKED_MEMORY_FILL_DONE_${runId}_${tabIndex}`) +} + +type ScrollbackTabSetup = { + worktreeId: string + scrollbackTabs: ScrollbackTab[] +} + +// Why: each tab generates its scrollback while visible, so every xterm holds +// the full buffer before going hidden — the hidden-delivery gate never gets a +// chance to drop the output the memory comparison depends on. +async function setUpScrollbackTabs( + page: Page, + scriptPath: string, + runId: string +): Promise { + const worktreeId = await waitForActiveWorktree(page) + await skipUnlessParkingWired(page) + await ensureTerminalVisible(page) + await waitForActiveTerminalManager(page, 30_000) + const baselineSnapshot = await waitForPaneIdentitySnapshot(page, 1) + const baselinePtyId = baselineSnapshot.panes[0]?.ptyId + if (!baselinePtyId) { + throw new Error('parked memory spec: baseline terminal tab did not bind a PTY') + } + + const scrollbackTabs: ScrollbackTab[] = [{ tabId: baselineSnapshot.tabId, ptyId: baselinePtyId }] + await fillActiveTerminalWithScrollback(page, baselinePtyId, scriptPath, 0, runId) + for (let tabIndex = 1; tabIndex < SCROLLBACK_TAB_COUNT; tabIndex += 1) { + const tab = await createActiveTerminalTab(page, worktreeId) + scrollbackTabs.push(tab) + await fillActiveTerminalWithScrollback(page, tab.ptyId, scriptPath, tabIndex, runId) + } + return { worktreeId, scrollbackTabs } +} + +type ParkedMemoryMetrics = { + heapUsedMB: number + liveTerminals: number + livePaneManagers: number +} + +// Why: usedJSHeapSize only drops after a GC, so force one over CDP (best +// effort) and take the min of several settled samples — the min reflects +// retained heap instead of allocation noise between collections. Note xterm +// buffer rows are typed-array backing stores outside the V8 heap, so the +// liveTerminals/livePaneManagers counts are the strong release signal and the +// heap figure tracks only the on-heap share. +async function sampleParkedMemoryMetrics(page: Page): Promise { + await page.waitForTimeout(PARK_SETTLE_MS) + try { + const session = await page.context().newCDPSession(page) + await session.send('HeapProfiler.collectGarbage') + await session.detach() + } catch { + // GC over CDP is a measurement-fidelity improvement, not a gate. + } + + let minHeapBytes: number | null = null + for (let sample = 0; sample < HEAP_SAMPLE_COUNT; sample += 1) { + const heapBytes = await page.evaluate(() => { + const memory = (performance as Performance & { memory?: { usedJSHeapSize?: number } }).memory + return memory?.usedJSHeapSize ?? null + }) + if (heapBytes !== null) { + minHeapBytes = minHeapBytes === null ? heapBytes : Math.min(minHeapBytes, heapBytes) + } + await page.waitForTimeout(HEAP_SAMPLE_INTERVAL_MS) + } + if (minHeapBytes === null) { + throw new Error('sampleParkedMemoryMetrics: performance.memory.usedJSHeapSize is unavailable') + } + + const liveCounts = await page.evaluate(() => ({ + liveTerminals: document.querySelectorAll('.xterm').length, + livePaneManagers: window.__paneManagers?.size ?? 0 + })) + return { heapUsedMB: minHeapBytes / (1024 * 1024), ...liveCounts } +} + +function formatParkedMemoryAnnotation(metrics: ParkedMemoryMetrics, parkedTabs: number): string { + return [ + `panes=${SCROLLBACK_TAB_COUNT}`, + `parkedTabs=${parkedTabs}`, + `heapUsedMB=${metrics.heapUsedMB.toFixed(1)}`, + `liveTerminals=${metrics.liveTerminals}`, + `livePaneManagers=${metrics.livePaneManagers}` + ].join(' ') +} + +test.describe('Terminal parked memory', () => { + test('releases renderer terminal memory when hidden tabs park', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS) + await waitForSessionReady(orcaPage) + + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`) + writeScrollbackFillScript(scriptPath, runId) + try { + const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId) + + // A fresh 9th tab hides all 8 scrollback tabs. + const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId) + await waitForTabsParked( + orcaPage, + scrollbackTabs.map((tab) => tab.tabId) + ) + + const metrics = await sampleParkedMemoryMetrics(orcaPage) + testInfo.annotations.push({ + type: 'opencode-parked-memory', + description: formatParkedMemoryAnnotation(metrics, scrollbackTabs.length) + }) + + // Structural assertions: all 8 parked (managers gone), and the only + // live xterm/pane manager belongs to the visible tab. + for (const tab of scrollbackTabs) { + expect((await readTerminalTabViewState(orcaPage, tab.tabId)).hasManager).toBe(false) + } + const visibleState = await readTerminalTabViewState(orcaPage, visibleTab.tabId) + expect(visibleState.hasManager).toBe(true) + expect(visibleState.paneCount).toBeGreaterThan(0) + // Why: design invariant 5 — renderer terminal views scale with visible + // panes, so parked tabs must leave no xterm DOM behind. + expect(metrics.liveTerminals).toBe(visibleState.paneCount) + expect(metrics.livePaneManagers).toBe(1) + } finally { + rmSync(scriptPath, { force: true }) + } + }) + + test('retains terminal views when parking is disabled', async ({ + orcaPage, + testRepoPath + }, testInfo: TestInfo) => { + test.setTimeout(PARKED_MEMORY_TEST_TIMEOUT_MS) + await waitForSessionReady(orcaPage) + + // Why: settings.terminalHiddenViewParking === false is the design-doc + // kill switch. updateSettings persists it through window.api.settings.set + // and updates the store slice the cold-park hook subscribes to — the same + // mutation path dead-terminal-repro.spec.ts uses, so no extra launch-env + // wiring is needed. + await orcaPage.evaluate(async () => { + const store = window.__store + if (!store) { + throw new Error('parked memory spec: window.__store is unavailable') + } + await store.getState().updateSettings({ terminalHiddenViewParking: false }) + }) + await expect + .poll( + () => + orcaPage.evaluate(() => window.__store?.getState().settings?.terminalHiddenViewParking), + { timeout: 5_000, message: 'terminalHiddenViewParking kill switch did not persist' } + ) + .toBe(false) + + const runId = randomUUID() + const scriptPath = path.join(testRepoPath, `.orca-parked-memory-${runId}.mjs`) + writeScrollbackFillScript(scriptPath, runId) + try { + const { worktreeId, scrollbackTabs } = await setUpScrollbackTabs(orcaPage, scriptPath, runId) + const scrollbackTabIds = scrollbackTabs.map((tab) => tab.tabId) + + const visibleTab = await createActiveTerminalTab(orcaPage, worktreeId) + // Why: with parking enabled these tabs park within ~1x the collapsed + // delay (the first test proves the machinery in this app build), so + // surviving 3x the delay shows the kill switch held. + await orcaPage.waitForTimeout(PARKING_DELAY_MS * 3) + expect(await countMountedPaneManagers(orcaPage, scrollbackTabIds)).toBe(SCROLLBACK_TAB_COUNT) + + const metrics = await sampleParkedMemoryMetrics(orcaPage) + testInfo.annotations.push({ + type: 'opencode-parked-memory-disabled', + description: formatParkedMemoryAnnotation(metrics, 0) + }) + + // Structural assertions: every hidden tab keeps its pane manager and + // xterm; nothing parked even after the settle + sampling window. + for (const tab of scrollbackTabs) { + const state = await readTerminalTabViewState(orcaPage, tab.tabId) + expect(state.hasManager).toBe(true) + expect(state.paneCount).toBeGreaterThan(0) + } + expect((await readTerminalTabViewState(orcaPage, visibleTab.tabId)).hasManager).toBe(true) + expect(metrics.livePaneManagers).toBe(SCROLLBACK_TAB_COUNT + 1) + expect(metrics.liveTerminals).toBe(SCROLLBACK_TAB_COUNT + 1) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +}) diff --git a/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts b/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts new file mode 100644 index 00000000000..6a57a4a3506 --- /dev/null +++ b/tests/e2e/terminal-push-delivery-loss-recovery.spec.ts @@ -0,0 +1,106 @@ +/** + * Repro + recovery for the dead-push-delivery wedge (field snapshot, + * v1.4.121-rc.0, 2026-07-06): every `pty:data` push event vanishes before the + * renderer processes it while invoke IPC stays healthy — terminals go + * literally blank (bytes sent, never consumed, never ACKed) and previously + * only a renderer reload recovered. + * + * The `__terminalDeliveryWatchdog.blackhole` hook drops incoming pty:data at + * the dispatcher exactly as the field failure does (no receive count, no ACK, + * no handler). The watchdog must then confirm the wedge over invoke, write off + * the lost bytes in main, and repaint the pane from the main-owned buffer + * snapshot — all WITHOUT the push channel and WITHOUT a reload. The wedged + * output becomes visible while the blackhole is still engaged: that is the + * pull-recovery proof. + * + * Timing: the watchdog runs at 500ms ticks here, but main refuses a write-off + * until it has seen 10s of ACK silence (PTY_DELIVERY_HEAL_MIN_ACK_SILENCE_MS, + * a deliberate prod constant) — so recovery lands at ~11-13s and the polls + * below allow 30s. + */ +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' +import { + waitForActiveTerminalManager, + waitForActivePanePtyId, + execInTerminal, + getTerminalContent +} from './helpers/terminal' + +type DeliveryWatchdogWindow = Window & { + __terminalDeliveryWatchdog?: { + blackhole: (on: boolean) => void + configure: (config: { + intervalMs?: number + stallTicksToHeal?: number + healCooldownMs?: number + }) => void + snapshot: () => { + receivedPtyDataEventCount: number + stallStreakTicks: number + healCount: number + blackholed: boolean + } + } +} + +test.describe('terminal push-delivery loss recovery', () => { + test.afterEach(async ({ orcaPage }) => { + await orcaPage.evaluate(() => { + ;(window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.blackhole(false) + }) + }) + + test('watchdog repaints wedged terminals from the main buffer without push delivery or reload', async ({ + orcaPage + }) => { + test.setTimeout(120_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + + // Live baseline: push delivery works. The $((…)) arithmetic keeps the + // asserted string out of the typed command's local echo. + await execInTerminal(orcaPage, ptyId, 'echo live-before-$((41+1))') + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 15_000 }) + .toContain('live-before-42') + + // Engage the field wedge and speed the watchdog up for CI. + await orcaPage.evaluate(() => { + const watchdog = (window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog + if (!watchdog) { + throw new Error('delivery watchdog e2e hook missing — exposeStore build?') + } + watchdog.configure({ intervalMs: 500, healCooldownMs: 3_000 }) + watchdog.blackhole(true) + }) + + await execInTerminal(orcaPage, ptyId, 'echo wedged-$((100+23))') + + // The wedge repro itself: output is swallowed, pane stays stale. + await orcaPage.waitForTimeout(1_500) + expect(await getTerminalContent(orcaPage)).not.toContain('wedged-123') + + // Pull-recovery proof: while the push channel is still dead, the healed + // pane repaints from the main-owned snapshot and shows the wedged output. + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 30_000 }) + .toContain('wedged-123') + const healSnapshot = await orcaPage.evaluate( + () => (window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.snapshot() ?? null + ) + expect(healSnapshot?.healCount ?? 0).toBeGreaterThan(0) + + // Channel restored: live output flows again with no reload in between. + await orcaPage.evaluate(() => { + ;(window as DeliveryWatchdogWindow).__terminalDeliveryWatchdog?.blackhole(false) + }) + await execInTerminal(orcaPage, ptyId, 'echo live-after-$((200+56))') + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 15_000 }) + .toContain('live-after-256') + }) +}) diff --git a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts index 238bd4f9d26..3f4d4f17dae 100644 --- a/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts +++ b/tests/e2e/terminal-raw-emoji-table-scroll-restore.spec.ts @@ -180,7 +180,10 @@ function rawEmojiFixtureFrameTailMarker(runId: string): string { } async function setWideRenderedTableViewport(page: Page): Promise { - await page.setViewportSize({ width: 1480, height: 820 }) + const isWindows = await page.evaluate(() => navigator.userAgent.includes('Windows')) + // Why: macOS hosted runners need extra room for font/column variance, while + // Windows Electron golden rendering is stable at the native-sized viewport. + await page.setViewportSize({ width: isWindows ? 1480 : 1760, height: 820 }) await page.waitForTimeout(250) await page.evaluate(() => { const store = window.__store @@ -556,6 +559,9 @@ test.describe('Terminal raw emoji table scroll restore repro', () => { await switchToWorktree(orcaPage, secondWorktreeId) await waitForActiveTerminalManager(orcaPage, 30_000) await orcaPage.waitForTimeout(1_000) + // Why: switching back can replay hidden terminal contents immediately; + // make the viewport wide before restore so the table cannot wrap first. + await setWideRenderedTableViewport(orcaPage) await switchToWorktree(orcaPage, firstWorktreeId) // Why: activating another worktree can restore the right sidebar. This // golden is about terminal renderer restore at a deliberately wide width. diff --git a/tests/e2e/terminal-sleep-wake-restore.spec.ts b/tests/e2e/terminal-sleep-wake-restore.spec.ts new file mode 100644 index 00000000000..83a924a1099 --- /dev/null +++ b/tests/e2e/terminal-sleep-wake-restore.spec.ts @@ -0,0 +1,220 @@ +import { randomUUID } from 'node:crypto' +import { rmSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { + ensureTerminalVisible, + getAllWorktreeIds, + switchToWorktree, + waitForActiveWorktree, + waitForSessionReady +} from './helpers/store' +import { + getTerminalContent, + sendToTerminal, + waitForActivePanePtyId, + waitForActiveTerminalManager, + waitForTerminalOutput +} from './helpers/terminal' + +type SleepWakeTerminalDebug = { + activeTabId: string | null + activeWorktreeId: string | null + tabs: { + id: string + ptyId?: string + generation?: number + pendingActivationSpawn?: boolean | number + }[] + ptyIdsByTabId: Record + ptyIdsByLeafIdByTabId: Record> +} + +async function sleepWorktreeTerminals(page: Page, worktreeId: string): Promise { + await page.evaluate(async (id) => { + const store = window.__store + if (!store) { + throw new Error('store unavailable') + } + const state = store.getState() + await state.shutdownWorktreeBrowsers(id) + await state.shutdownWorktreeTerminals(id, { keepIdentifiers: true }) + }, worktreeId) +} + +async function readLivePtyCountForWorktree(page: Page, worktreeId: string): Promise { + return page.evaluate((id) => { + const store = window.__store + if (!store) { + return 0 + } + const state = store.getState() + const tabs = state.tabsByWorktree[id] ?? [] + return tabs.reduce((count, tab) => count + (state.ptyIdsByTabId[tab.id]?.length ?? 0), 0) + }, worktreeId) +} + +async function readSleepWakeTerminalDebug( + page: Page, + worktreeId: string +): Promise { + return page.evaluate((id) => { + const store = window.__store + if (!store) { + return { + activeTabId: null, + activeWorktreeId: null, + tabs: [], + ptyIdsByTabId: {}, + ptyIdsByLeafIdByTabId: {} + } + } + const state = store.getState() + const tabs = state.tabsByWorktree[id] ?? [] + return { + activeTabId: state.activeTabId, + activeWorktreeId: state.activeWorktreeId, + tabs: tabs.map((tab) => ({ + id: tab.id, + ptyId: tab.ptyId, + generation: tab.generation, + pendingActivationSpawn: tab.pendingActivationSpawn + })), + ptyIdsByTabId: Object.fromEntries( + tabs.map((tab) => [tab.id, state.ptyIdsByTabId[tab.id] ?? []]) + ), + ptyIdsByLeafIdByTabId: Object.fromEntries( + tabs.map((tab) => [tab.id, state.terminalLayoutsByTabId[tab.id]?.ptyIdsByLeafId ?? {}]) + ) + } + }, worktreeId) +} + +async function mainSnapshotContains(page: Page, ptyId: string, text: string): Promise { + return page.evaluate( + async ({ targetPtyId, expectedText }) => { + const snapshot = await window.api.pty.getMainBufferSnapshot(targetPtyId, { + scrollbackRows: 200 + }) + return snapshot?.data.includes(expectedText) ?? false + }, + { targetPtyId: ptyId, expectedText: text } + ) +} + +function richSleepWakePayload(runId: string): string { + const shortId = runId.slice(0, 8) + return [ + '\x1b[?2026h', + '\x1b[2J\x1b[H', + '╭────────────────────────────────────────────╮', + `│ sleep wake restore ${shortId} 😀 │`, + '├────────────┬───────────────┬───────────────┤', + '│ agent │ status │ output │', + '├────────────┼───────────────┼───────────────┤', + `│ codex-${shortId.slice(0, 4)} │ thinking │ box/table ok │`, + '│ opencode │ streaming │ unicode ✓ │', + '│ shell │ idle │ prompt ready │', + '╰────────────┴───────────────┴───────────────╯', + `SLEEP_WAKE_RESTORE_${runId}`, + `SLEEP_WAKE_TABLE_${runId}`, + '\x1b[?2026l' + ].join('\r\n') +} + +function sleepWakeExpectedMarkers(runId: string): string[] { + return [ + `SLEEP_WAKE_RESTORE_${runId}`, + `SLEEP_WAKE_TABLE_${runId}`, + 'box/table ok', + 'unicode ✓', + 'prompt ready' + ] +} + +function writeSleepWakePayloadScript(scriptPath: string, payload: string): void { + const encodedPayload = Buffer.from(payload, 'utf8').toString('base64') + writeFileSync( + scriptPath, + `process.stdout.write(Buffer.from(${JSON.stringify(encodedPayload)}, 'base64').toString('utf8'))\n`, + 'utf8' + ) +} + +test.describe('Terminal sleep wake restore', () => { + test('restores slept terminal output and accepts fresh input after wake', async ({ + orcaPage, + testRepoPath + }) => { + await waitForSessionReady(orcaPage) + const firstWorktreeId = await waitForActiveWorktree(orcaPage) + const secondWorktreeId = (await getAllWorktreeIds(orcaPage)).find( + (id) => id !== firstWorktreeId + ) + test.skip(!secondWorktreeId, 'sleep wake restore needs the seeded secondary worktree') + if (!secondWorktreeId) { + return + } + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const ptyId = await waitForActivePanePtyId(orcaPage) + const runId = randomUUID() + const restoreMarker = `SLEEP_WAKE_RESTORE_${runId}` + const freshMarker = `SLEEP_WAKE_FRESH_${runId}` + const expectedMarkers = sleepWakeExpectedMarkers(runId) + const scriptPath = path.join(testRepoPath, `.orca-sleep-wake-restore-${runId}.mjs`) + writeSleepWakePayloadScript(scriptPath, richSleepWakePayload(runId)) + try { + await sendToTerminal(orcaPage, ptyId, `node ${JSON.stringify(scriptPath)}\r`) + await waitForTerminalOutput(orcaPage, restoreMarker, 10_000, 20_000) + const beforeSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + for (const marker of expectedMarkers) { + expect(await mainSnapshotContains(orcaPage, ptyId, marker)).toBe(true) + } + + await switchToWorktree(orcaPage, firstWorktreeId) + await sleepWorktreeTerminals(orcaPage, secondWorktreeId) + const afterSleepDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + await expect + .poll(() => readLivePtyCountForWorktree(orcaPage, secondWorktreeId), { + timeout: 10_000, + message: 'sleep did not release live PTYs for the background worktree' + }) + .toBe(0) + + await switchToWorktree(orcaPage, secondWorktreeId) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage, 30_000) + const awakePtyId = await waitForActivePanePtyId(orcaPage) + const afterWakeDebug = await readSleepWakeTerminalDebug(orcaPage, secondWorktreeId) + const awakeTerminalContent = await getTerminalContent(orcaPage, 20_000) + for (const marker of expectedMarkers) { + expect + .soft(awakeTerminalContent.includes(marker), { + message: JSON.stringify( + { + missingMarker: marker, + ptyId, + awakePtyId, + beforeSleepDebug, + afterSleepDebug, + afterWakeDebug, + terminalTail: awakeTerminalContent.slice(-2000) + }, + null, + 2 + ) + }) + .toBe(true) + } + await waitForTerminalOutput(orcaPage, restoreMarker, 15_000, 20_000) + await sendToTerminal(orcaPage, awakePtyId, `printf '\\n${freshMarker}\\n'\r`) + await waitForTerminalOutput(orcaPage, freshMarker, 10_000, 20_000) + } finally { + rmSync(scriptPath, { force: true }) + } + }) +}) diff --git a/tests/e2e/terminal-stuck-occlusion-recovery.spec.ts b/tests/e2e/terminal-stuck-occlusion-recovery.spec.ts new file mode 100644 index 00000000000..6545264fee3 --- /dev/null +++ b/tests/e2e/terminal-stuck-occlusion-recovery.spec.ts @@ -0,0 +1,147 @@ +/** + * Repro + recovery for the stuck-occlusion freeze (field snapshot, + * v1.4.124-rc.2.perf, 2026-07-06): macOS occlusion tracking wedges + * document.visibilityState at 'hidden' after display sleep and never fires + * another visibilitychange. The hidden-delivery gate then marks the pane the + * user is looking at as hidden, and main drops its renderer-bound bytes + * indefinitely (78MB dropped across 2 visible ptys in the field) — a frozen + * terminal with a perfectly healthy transport. + * + * The test emulates the wedge exactly as Chromium produces it: visibilityState + * pinned to 'hidden' with one final visibilitychange, then silence. Recovery + * must come from the staleness proof — a real keystroke while the document + * claims hidden — which unlatches the gate and repaints the missed output from + * the main-owned snapshot, WITHOUT a reload and WITHOUT any visibilitychange. + */ +import type { Page } from '@stablyai/playwright-test' +import { test, expect } from './helpers/orca-app' +import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store' +import { + waitForActiveTerminalManager, + waitForActivePanePtyId, + execInTerminal, + getTerminalContent +} from './helpers/terminal' + +type DeliverySnapshot = { + hiddenDeliveryGatedPtyCount: number + hiddenDeliveryGatedVisiblePtyCount: number + hiddenDeliveryDroppedChars: number +} + +async function getDeliverySnapshot(page: Page): Promise { + return page.evaluate(async () => { + const snapshot = await window.api.pty.getRendererDeliveryDebugSnapshot() + return { + hiddenDeliveryGatedPtyCount: snapshot.hiddenDeliveryGatedPtyCount, + hiddenDeliveryGatedVisiblePtyCount: snapshot.hiddenDeliveryGatedVisiblePtyCount, + hiddenDeliveryDroppedChars: snapshot.hiddenDeliveryDroppedChars + } + }) +} + +test.describe('terminal stuck-occlusion recovery', () => { + test.afterEach(async ({ orcaPage }) => { + // Drop the instance shadow so the prototype getter (real state) rules + // again, and fire one genuine visibilitychange to restore tracker trust. + await orcaPage.evaluate(() => { + delete (document as { visibilityState?: string }).visibilityState + document.dispatchEvent(new Event('visibilitychange')) + }) + }) + + test('a keystroke unlatches the hidden-delivery gate wedged by stale visibilityState', async ({ + orcaPage + }) => { + test.setTimeout(120_000) + await waitForSessionReady(orcaPage) + await waitForActiveWorktree(orcaPage) + await ensureTerminalVisible(orcaPage) + await waitForActiveTerminalManager(orcaPage) + const ptyId = await waitForActivePanePtyId(orcaPage) + + // Live baseline: foreground delivery works. The $((…)) arithmetic keeps + // the asserted string out of the typed command's local echo. + await execInTerminal(orcaPage, ptyId, 'echo live-before-$((41+1))') + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 15_000 }) + .toContain('live-before-42') + + // Emulate the Chromium occlusion wedge: visibilityState pins at 'hidden', + // one last visibilitychange fires, then the tracker goes silent forever. + await orcaPage.evaluate(() => { + Object.defineProperty(document, 'visibilityState', { + get: () => 'hidden', + configurable: true + }) + document.dispatchEvent(new Event('visibilitychange')) + }) + + // The visible pane's pty gets marked hidden in main — the field state: + // gate holding a pty that main's own visibility set says is visible. + await expect + .poll(async () => (await getDeliverySnapshot(orcaPage)).hiddenDeliveryGatedPtyCount, { + timeout: 15_000 + }) + .toBeGreaterThan(0) + expect( + (await getDeliverySnapshot(orcaPage)).hiddenDeliveryGatedVisiblePtyCount + ).toBeGreaterThan(0) + + // The freeze repro: output produced now is dropped by main, not painted. + const droppedBefore = (await getDeliverySnapshot(orcaPage)).hiddenDeliveryDroppedChars + await execInTerminal(orcaPage, ptyId, 'echo occluded-$((70+8))') + await expect + .poll(async () => (await getDeliverySnapshot(orcaPage)).hiddenDeliveryDroppedChars, { + timeout: 15_000 + }) + .toBeGreaterThan(droppedBefore) + expect(await getTerminalContent(orcaPage)).not.toContain('occluded-78') + + // The staleness proof: one real keystroke while the document claims + // hidden. No visibilitychange fires — recovery must ride the proof alone. + await orcaPage.keyboard.press('Shift') + + // Gate unlatches and the missed output repaints from the main-owned + // snapshot — no reload, visibilityState still reads 'hidden'. + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 30_000 }) + .toContain('occluded-78') + await expect + .poll(async () => (await getDeliverySnapshot(orcaPage)).hiddenDeliveryGatedVisiblePtyCount, { + timeout: 15_000 + }) + .toBe(0) + + // Live delivery continues under the override. + await execInTerminal(orcaPage, ptyId, 'echo live-after-$((200+56))') + await expect + .poll(async () => getTerminalContent(orcaPage), { timeout: 15_000 }) + .toContain('live-after-256') + + // The one-paste freeze report is prod-reachable and carries the episode's + // history: the stale-visibility latch and gate transitions must be in the + // renderer breadcrumbs, and main's per-pty table must be populated. + const report = await orcaPage.evaluate(() => + ( + window as Window & { + __orcaTerminalFreezeReport?: () => Promise<{ + renderer: { breadcrumbs: { kind: string }[]; documentVisibilityProvenStale: boolean } + main: { diagnostics: { perPty: unknown[]; breadcrumbs: { kind: string }[] } } + }> + } + ).__orcaTerminalFreezeReport?.() + ) + if (!report) { + throw new Error('freeze report global missing from prod-path renderer') + } + expect(report.renderer.documentVisibilityProvenStale).toBe(true) + const rendererKinds = report.renderer.breadcrumbs.map((crumb) => crumb.kind) + expect(rendererKinds).toContain('stale-visibility-latch') + expect(rendererKinds).toContain('renderer-gate-unmark') + expect(report.main.diagnostics.perPty.length).toBeGreaterThan(0) + const mainKinds = report.main.diagnostics.breadcrumbs.map((crumb) => crumb.kind) + expect(mainKinds).toContain('gate-mark') + expect(mainKinds).toContain('gate-unmark') + }) +}) diff --git a/tools/benchmarks/cpu-pressure-worker.mjs b/tools/benchmarks/cpu-pressure-worker.mjs new file mode 100644 index 00000000000..a0579be6dbd --- /dev/null +++ b/tools/benchmarks/cpu-pressure-worker.mjs @@ -0,0 +1,21 @@ +#!/usr/bin/env node +/** + * Busy-spins one CPU core to emulate a loaded machine during latency benches + * (the "every workspace is running an agent and the whole box is hot" case). + * Spawn N of these to occupy N cores; kill to release. + * + * Usage: node cpu-pressure-worker.mjs [maxDurationMs] + * The duration failsafe (default 10 min) prevents orphaned spinners if the + * spawning bench dies without cleanup. + */ +const maxDurationMs = Number(process.argv[2]) > 0 ? Number(process.argv[2]) : 10 * 60 * 1000 +const deadline = Date.now() + maxDurationMs + +let x = 1 +while (Date.now() < deadline) { + // Hot integer loop between deadline checks; no yields, no allocation. + for (let i = 0; i < 5_000_000; i++) { + x = (x * 31 + 7) % 1000003 + } +} +process.exit(0) diff --git a/tools/benchmarks/terminal-headless-parse-bench.mjs b/tools/benchmarks/terminal-headless-parse-bench.mjs new file mode 100644 index 00000000000..694593cfb3c --- /dev/null +++ b/tools/benchmarks/terminal-headless-parse-bench.mjs @@ -0,0 +1,70 @@ +#!/usr/bin/env node +/** + * Decomposes cross-terminal pipeline results: feeds the same fixtures from + * terminal-pipeline-bench through a bare @xterm/headless Terminal — no Orca + * layers, no IPC, no rendering — to locate where throughput is lost. + * + * If headless xterm parses a fixture near the plain-text rate, the pipeline + * gap for that fixture lives in Orca's layers (delivery, side-effect + * scanning, renderer paint). If headless collapses too, the cost is intrinsic + * to xterm.js's parser/buffer for that byte pattern. + * + * Usage: + * node tools/benchmarks/terminal-headless-parse-bench.mjs + * [--size-mb 10] [--cols 114] [--rows 85] [--scrollback 5000] + */ +import { performance } from 'node:perf_hooks' +import xterm from '@xterm/headless' +import { buildFixture } from './terminal-pipeline-bench.mjs' + +const { Terminal } = xterm +const CHUNK = 64 * 1024 + +function arg(name, fallback) { + const i = process.argv.indexOf(name) + return i === -1 ? fallback : Number(process.argv[i + 1]) +} + +const sizeMb = arg('--size-mb', 10) +const cols = arg('--cols', 114) +const rows = arg('--rows', 85) +const scrollback = arg('--scrollback', 5000) +const targetBytes = Math.floor(sizeMb * 1024 * 1024) + +function writeAll(term, data) { + return new Promise((resolve) => { + let offset = 0 + const next = () => { + if (offset >= data.length) { + resolve() + return + } + const chunk = data.slice(offset, offset + CHUNK) + offset += CHUNK + // write callback fires after the chunk is parsed — same "fully parsed" + // fence semantics as the DSR fence in the pipeline bench. + term.write(chunk, next) + } + next() + }) +} + +const FIXTURES = ['ascii-log', 'cjk-emoji', 'agent-tui', 'styles-stress'] + +console.log( + `headless xterm ${cols}x${rows} scrollback=${scrollback}, ${sizeMb}MB per fixture (parse-only, no render)` +) +for (const name of FIXTURES) { + const fixture = buildFixture(name, targetBytes, cols, rows) + const bytes = Buffer.byteLength(fixture, 'utf8') + const term = new Terminal({ cols, rows, scrollback, allowProposedApi: true }) + // Warmup primes JIT so the first fixture isn't penalized. + await writeAll(term, fixture.slice(0, 256 * 1024)) + const start = performance.now() + await writeAll(term, fixture) + const ms = performance.now() - start + console.log( + `${name.padEnd(15)} ${(bytes / 1024 / 1024 / (ms / 1000)).toFixed(1).padStart(7)} MB/s (${ms.toFixed(0)}ms)` + ) + term.dispose() +} diff --git a/tools/benchmarks/terminal-pipeline-bench.mjs b/tools/benchmarks/terminal-pipeline-bench.mjs new file mode 100644 index 00000000000..9d013dcc3ca --- /dev/null +++ b/tools/benchmarks/terminal-pipeline-bench.mjs @@ -0,0 +1,569 @@ +#!/usr/bin/env node +/** + * Cross-terminal pipeline benchmark. Run INSIDE the terminal under test + * (Orca pane, iTerm2, Ghostty, Terminal.app, VS Code, ...) — it measures the + * full byte path of whatever terminal hosts it: PTY -> (daemon/ptyHost -> + * IPC ->) parser -> response. + * + * Metrics per run: + * 1. dsr-idle — DSR (ESC[6n) round-trip latency at rest. The reply is + * produced only after the terminal's parser reaches the + * query, so this tracks the input/echo pipeline without + * needing OS-level keystroke injection. + * 2. throughput — wall time to stream each fixture, ended by a DSR + * fence. The fence matters: xterm.js-class terminals + * ingest at wire speed into an internal queue and parse + * later, so socket drain alone would flatter them. + * 3. dsr-under-load — DSR latency sampled while a paced (default 1 MB/s) + * agent-TUI stream plays. This is the "typing while an + * agent floods output" complaint, quantified. + * + * Usage (run in EACH terminal being compared, same machine, on AC power): + * node tools/benchmarks/terminal-pipeline-bench.mjs --label m2max-2026-07-02 + * [--size-mb 10] [--iterations 5] [--dsr-count 200] [--skip-load] + * [--fixtures ascii-log,cjk-emoji,agent-tui,styles-stress] + * + * Aggregate results from all terminals into one comparison table: + * node tools/benchmarks/terminal-pipeline-bench.mjs report [--label ] + * + * Protocol notes: keep hands off the keyboard during a run (stdin is parsed + * for DSR replies), use a comparable window size everywhere, avoid tmux/screen + * (they proxy the queries and would be the thing measured). `styles-stress` is + * deliberately pathological (every cell restyled); read it as a ceiling probe, + * not a realistic workload. Complementary manual metric: Typometer for true + * keypress->pixel latency — this probe stops at the parser reply. + * Results: tools/benchmarks/results/terminal-pipeline-