Terminal performance initiative: pipeline fixes + term-speed-2 revival + PTY flow control (integration branch) (#7214)

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* Record baseline + decomposition findings in initiative plan

Co-authored-by: Orca <help@stably.ai>

* 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 <help@stably.ai>

* Record dev-build #7153 check in findings log

Co-authored-by: Orca <help@stably.ai>

* 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 <help@stably.ai>

* Record task #9 attribution + parse-clock fix in findings log

Co-authored-by: Orca <help@stably.ai>

* Findings: 51x loss attributed to O(tail) retained-tail redraw path in main onPtyData

Co-authored-by: Orca <help@stably.ai>

* 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 <help@stably.ai>

* Add dev bench results: parse-clock and windowed-tail fixes

Co-authored-by: Orca <help@stably.ai>

* Record windowed-tail partial win + next-cycle recipe in findings log

Co-authored-by: Orca <help@stably.ai>

* Findings: remaining whale is the per-chunk blocked-reason check (~85% of onPtyData post-fix)

Co-authored-by: Orca <help@stably.ai>

* 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 <help@stably.ai>

* Findings + results: three stacked fixes unlock the pipeline (agent-tui 16x, DSR-load p50 161->18.8ms in dev)

Co-authored-by: Orca <help@stably.ai>

* Add producer flow-control design to initiative plan

Co-authored-by: Orca <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* Retract confounded daemon conviction; mandate load-controlled A/B protocol for the revival merge gate

Co-authored-by: Orca <help@stably.ai>

* Record A/B gate pass in findings log; add A/B result JSONs

Co-authored-by: Orca <help@stably.ai>

* 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 <help@stably.ai>

* Findings: flow control merged; definition-of-done accounting; prod verification re-scoped to packaged RC

Co-authored-by: Orca <help@stably.ai>

* Fix stray brace from revival merge in long-table-scroll-restore e2e spec (broke e2e transform in CI)

Co-authored-by: Orca <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* Findings + tests: batch windows were the DSR-load gap (19->8.0ms dev); timing tests updated to 2ms windows

Co-authored-by: Orca <help@stably.ai>

* Fix PR CI and guard resume relay during shutdown

Co-authored-by: Orca <help@stably.ai>

* Chain e2e specs 6/6 green — gate x drain validation debt paid

Co-authored-by: Orca <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* Add perf prerelease update check modifier

Co-authored-by: Orca <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* Keep RC update checks off perf prereleases

Co-authored-by: Orca <help@stably.ai>

* 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 <help@stably.ai>

* Count suffixed RC tags (rc.N.perf) in the shared rc counter — second suffixed cut collided with the first

Co-authored-by: Orca <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* Branch guide: document merge-not-rebase sync strategy and conflict pattern

Co-authored-by: Orca <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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:<source> 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* 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 <help@stably.ai>

* Harden terminal delivery and snapshot recovery

Co-authored-by: Orca <help@stably.ai>

* Fix inherited lint failures

Co-authored-by: Orca <help@stably.ai>

* Align merged runtime recovery tests

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong
2026-07-10 17:27:47 -07:00
committed by GitHub
co-authored by Orca Neil Neil
parent e1de59aaa8
commit e84a8ddec9
261 changed files with 38055 additions and 2409 deletions
+22 -1
View File
@@ -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
+4 -1
View File
@@ -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
+3
View File
@@ -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
File diff suppressed because one or more lines are too long
@@ -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`)
}
@@ -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')
@@ -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=]<playwright-json>... --output <report.html>'
)
}
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(
`<svg viewBox="0 0 ${width} ${height}" role="img" aria-label="${escapeHtml(title)}" class="trend-chart">`
)
parts.push(`<text x="${pad.left}" y="16" class="chart-title">${escapeHtml(title)}</text>`)
// 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(
`<line x1="${pad.left}" y1="${y}" x2="${width - pad.right}" y2="${y}" class="gridline"/>`
)
parts.push(
`<text x="${pad.left - 6}" y="${y + 3}" class="axis-label" text-anchor="end">${value % 1 === 0 ? value : value.toFixed(1)}</text>`
)
}
// X labels
revisions.forEach((revision, index) => {
parts.push(
`<text x="${xFor(index)}" y="${height - pad.bottom + 16}" class="axis-label" text-anchor="middle">${escapeHtml(revision.label)}</text>`
)
})
// 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(`<path d="${path}" fill="none" stroke="${color}" stroke-width="2"/>`)
for (const point of points) {
const x = xFor(point.index)
const y = yFor(point.value)
parts.push(`<circle cx="${x.toFixed(1)}" cy="${y.toFixed(1)}" r="3" fill="${color}"/>`)
parts.push(
`<text x="${x.toFixed(1)}" y="${(y - 7).toFixed(1)}" class="point-label" text-anchor="middle" fill="${color}">${point.value % 1 === 0 ? point.value : point.value.toFixed(1)}</text>`
)
}
}
parts.push('</svg>')
const legend = metrics
.map((metric) => {
const color = SERIES_COLORS[metric.key] ?? '#475569'
return `<span class="legend-item"><span class="legend-swatch" style="background:${color}"></span>${escapeHtml(metric.label)}</span>`
})
.join('')
return `<figure class="chart-card" data-scenario="${escapeHtml(scenario)}">${parts.join('')}<figcaption class="legend">${legend} <span class="legend-unit">ms — lower is better</span></figcaption></figure>`
}
function deltaCell(baseline, latest, { lowerIsBetter = true, zeroBudget = false } = {}) {
if (baseline == null || latest == null) {
return '<td class="delta">—</td>'
}
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 `<td class="delta ${cls}">${escapeHtml(pctLabel)} <span class="delta-abs">(${escapeHtml(diffLabel)})</span></td>`
}
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) => `<td>${value == null ? '—' : escapeHtml(format(value))}</td>`)
.join('')
const baseline = values.find((value) => value != null)
const latest = values.toReversed().find((value) => value != null)
metricRows.push(
`<tr><th scope="row">${escapeHtml(metric.label)}</th>${cells}${deltaCell(baseline, latest, {
zeroBudget: metric.key === 'rendererDroppedBacklogs'
})}</tr>`
)
}
if (metricRows.length === 0) {
return ''
}
const headers = revisions.map((revision) => `<th>${escapeHtml(revision.label)}</th>`).join('')
return `<section class="scenario-block">
<h3>${escapeHtml(title)} <span class="scenario-id">${escapeHtml(scenario)}</span></h3>
<table class="trend-table">
<thead><tr><th>Metric</th>${headers}<th>Δ first → last</th></tr></thead>
<tbody>${metricRows.join('')}</tbody>
</table>
</section>`
}
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(`<div class="card ${cls}">
<div class="card-title">${escapeHtml(scenarioTitle(scenario, lastRow))}</div>
<div class="card-value">${escapeHtml(formatMs(baseRow.medianMs))}${escapeHtml(formatMs(lastRow.medianMs))}</div>
<div class="card-sub">typing median, ${escapeHtml(first.label)}${escapeHtml(last.label)} (${pct >= 0 ? '+' : ''}${pct.toFixed(0)}%)</div>
</div>`)
}
if (cards.length === 0) {
return ''
}
return `<section><h2>Baseline vs latest</h2><div class="cards">${cards.join('')}</div></section>`
}
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 ? '<span class="pass">Pass</span>' : '<span class="fail">Fail</span>'
const failureList =
failures.length === 0
? ''
: `<ul>${failures.map((failure) => `<li>${escapeHtml(failure)}</li>`).join('')}</ul>`
return `<section><h2>Budget status — ${escapeHtml(latestRevision.label)}</h2>
<p>${latestRevision.rows.length} scenario rows checked: ${status}</p>${failureList}</section>`
}
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
? ' <span class="meta-warn">(failed assertions at this revision; metrics still recorded)</span>'
: ''
return `<li><strong>${escapeHtml(revision.label)}</strong> — ${revision.rows.length} scenario rows (${escapeHtml(revision.path)})${escapeHtml(statsLabel)}${failNote}</li>`
})
.join('')
return `<ol class="inputs">${items}</ol>`
}
function renderRawDetails(revisions) {
return revisions
.map((revision) => {
const rows = revision.rows
.map(
(row) =>
`<tr><td>${escapeHtml(row.scenario)}</td><td>${row.panes ?? '—'}</td><td>${escapeHtml(formatMs(row.medianMs))}</td><td>${escapeHtml(formatMs(row.worstMs))}</td><td>${escapeHtml(formatMs(row.scrollMs))}</td><td>${escapeHtml(formatMs(row.restoreMs))}</td><td>${escapeHtml(formatMs(row.revisitMs))}</td><td>${escapeHtml(formatLargeValue(row.rendererPeakQueuedChars))}</td><td>${escapeHtml(formatLargeValue(row.hiddenSkippedChars))}</td><td>${row.rendererDroppedBacklogs ?? '—'}</td></tr>`
)
.join('')
return `<details><summary>Raw rows — ${escapeHtml(revision.label)}</summary>
<table class="trend-table">
<thead><tr><th>Scenario</th><th>Panes</th><th>Median</th><th>Worst</th><th>Scroll</th><th>Restore</th><th>Revisit</th><th>Renderer peak</th><th>Hidden skipped</th><th>Drops</th></tr></thead>
<tbody>${rows}</tbody></table></details>`
})
.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 `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Terminal Performance Over Time</title>
<style>${PAGE_CSS}</style>
</head>
<body>
<h1>Terminal Performance Over Time</h1>
<p class="meta">Generated ${escapeHtml(generatedAt)} from ${revisions.length} benchmark run(s), ordered oldest (baseline) to newest. All metrics: lower is better.</p>
${renderInputsMeta(revisions)}
${renderHeadline(revisions, matrix)}
${charts ? `<section><h2>Trends across revisions</h2><div class="charts">${charts}</div></section>` : ''}
<section><h2>Metric detail by scenario</h2>${tables}</section>
${renderBudgets(revisions.at(-1))}
<section><h2>Raw data</h2>${renderRawDetails(revisions)}</section>
</body>
</html>
`
}
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)
}
}
@@ -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('<!doctype html>')
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 &gt; 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('<td>—</td>')
})
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')
})
})
+5 -1
View File
@@ -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) {
@@ -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)
})
@@ -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) {
@@ -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({
@@ -25,6 +25,7 @@ function printMarkdownTable(rows) {
['Frames', 'frames'],
['Median', 'median'],
['Worst', 'worst'],
['Revisit', 'revisit'],
['Scroll', 'scroll'],
['Restore', 'restore'],
['Max Drift', 'maxTimerDrift'],
@@ -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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
}
@@ -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.
@@ -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.
+326
View File
@@ -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.
@@ -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.
+11
View File
@@ -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.
+296
View File
@@ -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 015 written as `38;5;N` using classic SGR 3037/9097, 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=<n>`: re-run exactly one seed for a repro (both suites).
+532
View File
@@ -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.815.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=<path>` 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 <build>`
— 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 10k50k silently lost older history
on a hide/reveal rebuild. Restore now reads the pane's xterm scrollback
option and clamps it through the shared 050,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.
+583
View File
@@ -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 (12 GB).
5. Battery usage is high.
Goals: legit performance complaints ≤ 1/week; sampled P90 typing/scrolling
latency down significantly; lower memory with 01 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 535 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 510 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 <machine>-<date>
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 35
(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 ~210 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 ~350770 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 27117 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
"<bench> --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 ≈ 700790 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 27117 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.27.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.22.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 3948 MB/s ingest runs executed while a dev
app was still running. On a quiet machine the revive branch ingests at
**82109 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 01 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.
+4 -1
View File
@@ -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": {
+5 -2
View File
@@ -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
@@ -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()
}
})
})
@@ -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<string, TerminalTitleTracker>()
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)
}
}
+3 -1
View File
@@ -131,7 +131,9 @@ async function main(): Promise<void> {
// 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')
+23
View File
@@ -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'
}
}
+59 -1
View File
@@ -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
+71 -20
View File
@@ -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<string, unknown>
} 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<string | null> {
// 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<WindowsProcessIdentity | null> {
const startedAt = performance.now()
try {
const { stdout } = await execFileAsync(
@@ -416,14 +463,17 @@ async function queryWindowsProcessCommandLine(pid: number): Promise<string | nul
'-NoProfile',
'-NonInteractive',
'-Command',
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`
`$p = Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}"; ` +
`if ($p) { $start = $null; ` +
`if ($p.CreationDate) { $start = [long]([DateTimeOffset]$p.CreationDate).ToUnixTimeMilliseconds() }; ` +
`@{ cmd = $p.CommandLine; start = $start } | ConvertTo-Json -Compress }`
],
{
encoding: 'utf8',
timeout: 3_000
}
)
return stdout
return parseWindowsProcessIdentityJson(stdout)
} catch {
return null
} finally {
@@ -450,15 +500,16 @@ async function isDaemonProcess(
}
if (process.platform === 'win32') {
const output = await queryWindowsProcessCommandLine(pid)
if (output === null) {
const identity = await queryWindowsProcessIdentity(pid)
if (identity === null) {
return false
}
// Why: image names are too broad after PID reuse. Match the daemon entry
// plus the exact socket/token args so we only kill the daemon for this
// userData protocol endpoint.
return (
commandLineMatchesDaemon(output, socketPath, tokenPath) && startTimeMatches(pid, startedAtMs)
commandLineMatchesDaemon(identity.commandLine, socketPath, tokenPath) &&
startTimesWithinTolerance(identity.startedAtMs, startedAtMs, WIN32_START_TIME_TOLERANCE_MS)
)
}
@@ -485,7 +536,7 @@ async function isDaemonProcess(
async function getDaemonCommandLine(pid: number): Promise<string | null> {
if (process.platform === 'win32') {
return queryWindowsProcessCommandLine(pid)
return (await queryWindowsProcessIdentity(pid))?.commandLine ?? null
}
try {
+228 -4
View File
@@ -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<void> }>
checkDaemonHealthMock.mockResolvedValueOnce('unreachable')
// The raw pipe probe succeeds even though every RPC timed out.
probeSocketExistsMock.mockReturnValue(true)
netConnectMock.mockImplementationOnce(() => {
const handlers: Record<string, (() => 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<void> }>
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<void> }>
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<void> }>
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()
+62 -22
View File
@@ -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<DaemonPtyAdapter[]> {
const adapters: DaemonPtyAdapter[] = []
for (const protocolVersion of PREVIOUS_DAEMON_PROTOCOL_VERSIONS) {
@@ -830,23 +866,27 @@ async function createLegacyDaemonAdapters(runtimeDir: string): Promise<DaemonPty
// Why: dead legacy daemons leave pid/token files behind forever (one per
// protocol bump). A stale pid eventually gets recycled by an unrelated
// process, turning any future identity check into a PowerShell spawn.
// The socket is provably dead, so remove the leftovers — mirrors what
// cleanupDaemonForProtocol already does for the current version.
for (const stalePath of [
getDaemonPidPath(runtimeDir, protocolVersion),
getDaemonTokenPath(runtimeDir, protocolVersion)
]) {
try {
unlinkSync(stalePath)
} catch {
// Best-effort
// Only clean up when the pid-file process is provably gone: a live
// legacy daemon can transiently fail the 1s probe right after an update
// (wedged event loop, exhausted pipe backlog), and deleting its token
// file would make its sessions permanently unadoptable.
if (!legacyDaemonProcessMayBeAlive(runtimeDir, protocolVersion)) {
for (const stalePath of [
getDaemonPidPath(runtimeDir, protocolVersion),
getDaemonTokenPath(runtimeDir, protocolVersion)
]) {
try {
unlinkSync(stalePath)
} catch {
// Best-effort
}
}
}
if (process.platform !== 'win32' && existsSync(socketPath)) {
try {
unlinkSync(socketPath)
} catch {
// Best-effort
if (process.platform !== 'win32' && existsSync(socketPath)) {
try {
unlinkSync(socketPath)
} catch {
// Best-effort
}
}
}
continue
+250
View File
@@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'node:fs'
import { DaemonClient } from './client'
import { DaemonPtyAdapter } from './daemon-pty-adapter'
import { DaemonServer } from './daemon-server'
import { HeadlessEmulator } from './headless-emulator'
@@ -31,6 +32,8 @@ function createTestDir(): string {
}
function createMockSubprocess(dataOnSubscribe?: string): SubprocessHandle & {
pause: ReturnType<typeof vi.fn<() => void>>
resume: ReturnType<typeof vi.fn<() => 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 })
+265 -17
View File
@@ -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<void> | 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<string, string>()
// 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<string, ColdRestorePayload>()
private sleepRestoreSessionIds = new Set<string>()
private activeSessionIds = new Set<string>()
private dirtySessionVersions = new Map<string, number>()
// 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<string>()
// 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<string>()
// 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<string>()
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<void> {
await this.ensureConnected()
if (!this.supportsAuthoritativeBufferSnapshots) {
this.setPtyBackgrounded(id, false)
}
await this.client.request<CreateOrAttachResult>('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<void> {
// 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<void> {
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<PtyProviderBufferSnapshot | null> {
if (!this.supportsAuthoritativeBufferSnapshots) {
return null
}
try {
const result = await this.client.request<GetSnapshotResult>('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<void> {
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<void> {
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<void> {
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.
+86 -9
View File
@@ -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<PtySpawnResult> => {
@@ -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'])
+40 -2
View File
@@ -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<string, DaemonPtyAdapter>()
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<void> {
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<PtyProviderBufferSnapshot | null> {
return await this.adapterFor(id).getBufferSnapshot(id, opts)
}
async clearBuffer(id: string): Promise<void> {
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 () => {}
}
+66
View File
@@ -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<typeof createMockSubprocess>
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<typeof vi.fn>
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', () => {
+162 -23
View File
@@ -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<string, ConnectedClient>()
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<string, string>()
private lastInputAtBySessionId = new Map<string, number>()
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<void> {
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)
}
}
@@ -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<string, unknown>
): 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)
}
@@ -6,12 +6,37 @@ import { createNdjsonParser } from './ndjson'
function createBatcher(options?: ConstructorParameters<typeof DaemonStreamDataBatcher>[1]) {
const streamSocket = {
destroyed: false,
writableLength: 0,
write: vi.fn()
} as unknown as Socket & { write: ReturnType<typeof vi.fn> }
} as unknown as Socket & { write: ReturnType<typeof vi.fn>; writableLength: number }
const batcher = new DaemonStreamDataBatcher(() => ({ streamSocket }), options)
return { batcher, streamSocket }
}
function writtenData(streamSocket: { write: ReturnType<typeof vi.fn> }): 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<typeof vi.fn> }): 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<string, number>()
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))
}
+252 -106
View File
@@ -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<typeof setTimeout> | 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<string, PendingStreamDataBatch>()
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<string>()
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<string>()
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))
}
}
}
+114
View File
@@ -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<Socket, 'write'>,
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))
}
}
+78
View File
@@ -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
@@ -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<typeof setTimeout> | 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<string, number>
// 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
)
}
}
@@ -0,0 +1,25 @@
import type { IPtyProvider } from '../providers/types'
export async function shutdownDegradedFallbackSessions<T extends IPtyProvider>(
sessionProviders: Map<string, T>,
fallback: T
): Promise<number> {
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
}
@@ -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')
+44 -24
View File
@@ -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<string, ManagedPtyProvider>()
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<void> {
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<PtyProviderBufferSnapshot | null> {
// 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<void> {
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<number> {
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[] {
@@ -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<FidelityDiff | null> {
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<FidelityCase> {
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()
}
})
})
+117 -4
View File
@@ -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 () => {
+247 -149
View File
@@ -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<void> {
/** 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<void> {
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<void> {
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<void>((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
}
}
+18
View File
@@ -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.
+9 -7
View File
@@ -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 {
+24
View File
@@ -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
+19 -1
View File
@@ -37,7 +37,7 @@ function createMockSubprocess(): SubprocessHandle & {
// for a reattach. Alt-screen sessions include the full ANSI snapshot because
function buildReattachPayload(snapshot: ReturnType<HeadlessEmulator['getSnapshot']>) {
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({
@@ -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)
})
})
+136 -6
View File
@@ -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)
})
})
})
+79 -3
View File
@@ -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<typeof setTimeout> | 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) {
@@ -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 () => {
+27 -2
View File
@@ -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
@@ -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('')
}
@@ -0,0 +1,119 @@
import type { TerminalModes } from './types'
type MouseTrackingMode = NonNullable<TerminalModes['mouseTrackingMode']>
// 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)
}
}
@@ -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
}
}
}
@@ -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)
}
}
@@ -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<void> {
return new Promise((resolve) => terminal.write(data, () => resolve()))
}
async function replay(data: string, cols = 10, rows = 5, scrollback = 100): Promise<Terminal> {
const { terminal } = createTerminal(cols, rows, scrollback)
await write(terminal, data)
return terminal
}
function cellAt(
terminal: Terminal,
viewportRow: number,
col: number
): NonNullable<
ReturnType<NonNullable<ReturnType<Terminal['buffer']['active']['getLine']>>['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<Terminal> {
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('')
})
})
@@ -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<Terminal['parser'], 'registerOscHandler' | 'registerCsiHandler'>
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<SpecialColorSlot, string> = {
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<number, TerminalViewRgb>()
const specialOverrides = new Map<SpecialColorSlot, TerminalViewRgb>()
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()
}
}
}
+72 -59
View File
@@ -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'
+27 -1
View File
@@ -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(),
@@ -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)
})
})
+154
View File
@@ -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<string>()
// 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<string>()
// 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<string>()
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()
}
@@ -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<typeof vi.fn<(id: string) => void>>
let resumeProducer: ReturnType<typeof vi.fn<(id: string) => 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)
})
})
+105
View File
@@ -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<string, number>()
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 */
}
}
}
+1954 -144
View File
File diff suppressed because it is too large Load Diff
+1080 -112
View File
File diff suppressed because it is too large Load Diff
+20 -1
View File
@@ -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])
+9
View File
@@ -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<GlobalSettings>) => {
const sanitizedArgs = sanitizeRendererSettingsUpdate(args)
// Why: Floating Workspace grants are trusted only when written by the
@@ -92,6 +92,8 @@ describe('LocalPtyProvider', () => {
onExit: ReturnType<typeof vi.fn>
write: ReturnType<typeof vi.fn>
resize: ReturnType<typeof vi.fn>
pause: ReturnType<typeof vi.fn>
resume: ReturnType<typeof vi.fn>
kill: ReturnType<typeof vi.fn>
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 →
+20
View File
@@ -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
@@ -17,6 +17,9 @@ vi.mock('electron', () => ({
on: onMock,
removeHandler: removeHandlerMock,
removeAllListeners: removeAllListenersMock
},
powerMonitor: {
on: vi.fn()
}
}))
+67 -1
View File
@@ -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<PtyProviderBufferSnapshot | null>
/**
* 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<PtyProcessInfo[]>
getDefaultShell(): Promise<string>
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
}
+659
View File
@@ -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<string, { lastOscTitle: string | null }>
}
).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 chunks facts under its own seq, not the next chunks', () => {
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<string, { lastOscTitle: string | null }>
}
).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<string, { lastOscTitle: string | null }> }
).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, {
File diff suppressed because it is too large Load Diff
@@ -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)
}
})
})
+342 -15
View File
@@ -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<SerializedSnapshot> {
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<number, TerminalMultiplexStream>()
let ackTotalInFlightBytes = 0
let resolveMultiplex = (): void => {}
const multiplexClosed = new Promise<void>((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<void> => {
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<unknown>(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) {
+3
View File
@@ -9,6 +9,9 @@ import type { RuntimeTerminalWait } from '../../../shared/runtime-types'
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): 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
}
@@ -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()),
@@ -18,6 +18,14 @@ import {
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): 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<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => 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<RuntimeTerminalWait>(() => {})),
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<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => 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<RuntimeTerminalWait>(() => {})),
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<number, number>()
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<number, number>()
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<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => 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<RuntimeTerminalWait>(() => {})),
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<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => 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<RuntimeTerminalWait>(() => {})),
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<ArrayBufferLike>[] = []
@@ -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<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => 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<string>((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<RuntimeTerminalWait>(() => {}))
})
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<ArrayBufferLike>[] = []
const handlers = new Map<
number,
(frame: NonNullable<ReturnType<typeof decodeTerminalStreamFrame>>) => void
>()
const cleanups = new Map<string, () => 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<boolean>((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<RuntimeTerminalWait>(() => {}))
})
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
@@ -15,6 +15,9 @@ import {
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): 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
}
@@ -14,6 +14,9 @@ import {
function stubRuntime(overrides: Partial<OrcaRuntimeService> = {}): 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<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => 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<typeof frame> => 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<ArrayBufferLike>[] = []
const cleanups = new Map<string, () => 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<RuntimeTerminalWait>(() => {})),
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<typeof frame> => 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()
}
})
})
+209
View File
@@ -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<string, unknown>[] = []
const binaryFrames: Uint8Array<ArrayBufferLike>[] = []
const onError = vi.fn()
const subscription = await subscribeRemoteRuntimeRequest(
pairing,
'terminal.multiplex',
{},
15_000,
{
onResponse: (response) => responses.push(response as Record<string, unknown>),
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)
@@ -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<string, boolean> = {}): 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)
})
})
@@ -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<string>()
// 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<ConptyDa1OverrideInstaller>()
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()
}
@@ -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<void> {
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> = {}): 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;<idx>', 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([])
})
})
@@ -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<TerminalViewAttributesApplier>()
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()
}
@@ -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'
})
}
+96 -37
View File
@@ -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<typeof vi.fn>
}
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<typeof vi.fn>
}
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', () => {
+29 -1
View File
@@ -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,
@@ -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)
})
})
+14
View File
@@ -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<GlobalSettings, 'terminalMainSideEffectAuthority'> | null | undefined
): boolean {
return settings?.terminalMainSideEffectAuthority === false
}
+104
View File
@@ -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<string, (...args: any[]) => 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
+17
View File
@@ -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

Some files were not shown because too many files have changed in this diff Show More