mirror of
https://github.com/stablyai/orca.git
synced 2026-10-27 16:02:42 +00:00
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:
co-authored by
Orca
Neil
Neil
parent
e1de59aaa8
commit
e84a8ddec9
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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 () => {}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1080
-112
File diff suppressed because it is too large
Load Diff
@@ -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])
|
||||
|
||||
@@ -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 →
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}))
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 chunk’s facts under its own seq, not the next chunk’s', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
vi.spyOn(
|
||||
runtime as unknown as { applyTrackedPtyTitle: (ptyId: string, title: string) => boolean },
|
||||
'applyTrackedPtyTitle'
|
||||
).mockImplementationOnce(() => {
|
||||
throw new Error('tracker boom')
|
||||
})
|
||||
|
||||
const first = '\x1b]0;Codex working\x07'
|
||||
expect(() => runtime.onPtyData('pty-1', first, 100)).toThrow('tracker boom')
|
||||
runtime.onPtyData('pty-1', '\x1b]0;Codex done\x07', 101)
|
||||
|
||||
expect(batches).toHaveLength(2)
|
||||
expect(batches[0].seq).toBe(first.length)
|
||||
expect(batches[0].facts).toEqual([
|
||||
{ kind: 'title', normalizedTitle: 'Codex working', rawTitle: 'Codex working' }
|
||||
])
|
||||
// The next chunk's batch carries only its own facts (the throw aborted
|
||||
// the first chunk's agent-tracker pass, so no working state was kept).
|
||||
expect(batches[1].seq).toBeGreaterThan(batches[0].seq)
|
||||
expect(batches[1].facts).toEqual([
|
||||
{ kind: 'title', normalizedTitle: 'Codex done', rawTitle: 'Codex done' }
|
||||
])
|
||||
})
|
||||
|
||||
it('parses synthetic frames statelessly so ticks cannot corrupt the bell detector', () => {
|
||||
const { runtime, batches } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
|
||||
runtime.onPtyData('pty-1', '\x1b]0;split ti', 100)
|
||||
// An 80ms spinner tick lands between the two halves of the real OSC.
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;⠋ Cursor Agent\x07')
|
||||
// Continuation: this BEL terminates the real OSC — it is NOT a bell.
|
||||
runtime.onPtyData('pty-1', 'tle\x07', 101)
|
||||
// A later standalone BEL is a real bell and must not be swallowed.
|
||||
runtime.onPtyData('pty-1', 'ready\x07', 102)
|
||||
|
||||
expect(batches.flatMap((batch) => batch.facts)).toEqual([
|
||||
{ kind: 'title', normalizedTitle: '⠋ Cursor Agent', rawTitle: '⠋ Cursor Agent' },
|
||||
{ kind: 'agent-working' },
|
||||
{ kind: 'title', normalizedTitle: 'split title', rawTitle: 'split title' },
|
||||
{ kind: 'bell' }
|
||||
])
|
||||
})
|
||||
|
||||
it('touches mobile snapshots once for decorative spinner ticks, again on idle', () => {
|
||||
const { runtime } = createSideEffectRuntime()
|
||||
syncSinglePty(runtime)
|
||||
const touchSpy = vi.spyOn(
|
||||
runtime as unknown as { touchMobileSessionSnapshotsForPty: (ptyId: string) => void },
|
||||
'touchMobileSessionSnapshotsForPty'
|
||||
)
|
||||
|
||||
for (const frame of ['⠋', '⠙', '⠹', '⠸', '⠼']) {
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', `\x1b]0;${frame} Cursor Agent\x07`)
|
||||
}
|
||||
// Five ticks with the same de-spinnered title: one snapshot fan-out.
|
||||
expect(touchSpy).toHaveBeenCalledTimes(1)
|
||||
|
||||
runtime.ingestSyntheticTitleFrame('pty-1', '\x1b]0;Cursor ready\x07')
|
||||
expect(touchSpy).toHaveBeenCalledTimes(2)
|
||||
// Raw record titles still track every frame for worktree ps/mobile tabs.
|
||||
expect(
|
||||
(
|
||||
runtime as unknown as {
|
||||
ptysById: Map<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, {
|
||||
|
||||
+988
-164
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
})
|
||||
}
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+78
-14
@@ -206,22 +206,18 @@ import type {
|
||||
WorkspaceSessionPatch,
|
||||
WorkspaceSessionState
|
||||
} from '../shared/types'
|
||||
|
||||
type GitLabRepoSelectorArgs = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
type GitHubRepoSelectorArgs = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker'
|
||||
import type {
|
||||
PtyRendererDeliveryHealthReply,
|
||||
PtyRendererDeliveryStateReport
|
||||
} from '../shared/pty-renderer-delivery-health'
|
||||
import type { TerminalViewAttributes } from '../shared/terminal-view-attributes'
|
||||
import type { PtyMainDeliveryDiagnostics } from '../shared/pty-delivery-diagnostics'
|
||||
import type {
|
||||
WarpThemeImportPreview,
|
||||
WarpThemeImportSource
|
||||
} from '../shared/terminal-custom-themes'
|
||||
|
||||
import type { SetupScriptImportCandidate } from '../shared/setup-script-imports'
|
||||
import type { GitHistoryOptions, GitHistoryResult } from '../shared/git-history'
|
||||
import type { PublicKnownRuntimeEnvironment } from '../shared/runtime-environments'
|
||||
@@ -292,6 +288,7 @@ import type {
|
||||
MigrationUnsupportedPtyEntry
|
||||
} from '../shared/agent-status-types'
|
||||
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
|
||||
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
|
||||
import type {
|
||||
RuntimeBrowserDriverState,
|
||||
RuntimeMobileSessionTabMove,
|
||||
@@ -445,6 +442,18 @@ import type {
|
||||
} from '../shared/workspace-cleanup'
|
||||
import type { KeybindingActionId, KeybindingFileSnapshot } from '../shared/keybindings'
|
||||
|
||||
type GitLabRepoSelectorArgs = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
type GitHubRepoSelectorArgs = {
|
||||
repoPath: string
|
||||
repoId?: string | null
|
||||
sourceContext?: TaskSourceContext | null
|
||||
}
|
||||
|
||||
export type BrowserApi = {
|
||||
registerGuest: (args: {
|
||||
browserPageId: string
|
||||
@@ -1206,6 +1215,10 @@ export type PreloadApi = {
|
||||
shellOverride?: string
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution
|
||||
terminalColorQueryReplies?: { foreground?: string; background?: string }
|
||||
// Why: hidden-at-spawn declaration — main marks the PTY hidden before
|
||||
// its first byte so the delivery gate + model responder own spawn-time
|
||||
// queries (terminal-query-authority.md §races).
|
||||
initiallyHidden?: boolean
|
||||
// Why: closes the SIGKILL race documented in INVESTIGATION.md — main
|
||||
// sync-flushes the (worktreeId, tabId, leafId → ptyId) binding before
|
||||
// pty:spawn returns. Only the renderer's daemon-host path threads these.
|
||||
@@ -1237,12 +1250,34 @@ export type PreloadApi = {
|
||||
clearBuffer: (id: string) => void
|
||||
kill: (id: string, opts?: { keepHistory?: boolean }) => Promise<void>
|
||||
ackColdRestore: (id: string) => void
|
||||
ackData: (id: string, charCount: number) => void
|
||||
ackData: (id: string, charCount: number, processedChars?: number) => void
|
||||
onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void) => () => void
|
||||
respondDeliveryResync: (payload: {
|
||||
requestId: number
|
||||
processedCharsByPty: Record<string, number>
|
||||
}) => void
|
||||
/** Renderer-initiated delivery health/heal lane over invoke — reaches main
|
||||
* even when every main→renderer push channel is dead (field wedge). */
|
||||
reportRendererDeliveryState: (
|
||||
report: PtyRendererDeliveryStateReport
|
||||
) => Promise<PtyRendererDeliveryHealthReply>
|
||||
/** Live pty:data listener count on the preload emitter (sync) — heal-time
|
||||
* discriminator between a detached listener and a dead channel. */
|
||||
getPtyDataListenerCount: () => number
|
||||
/** One-shot signal that this page's pty:data dispatcher is registered, so
|
||||
* main can release sends held during the load/reload boot window. */
|
||||
rendererDispatcherReady: () => void
|
||||
setActiveRendererPty: (id: string, active: boolean) => void
|
||||
setRendererPtyVisible: (id: string, visible: boolean) => void
|
||||
/** Hidden-delivery gate (Phase 4): hidden=true lets main drop renderer
|
||||
* byte delivery after model ingestion; reveal restores from snapshots. */
|
||||
setHiddenRendererPty: (id: string, hidden: boolean) => void
|
||||
/** Ref-counted-on-the-renderer delivery-interest signal that suppresses
|
||||
* the hidden-delivery gate while any raw-byte consumer is registered. */
|
||||
setPtyDeliveryInterest: (id: string, interested: boolean) => void
|
||||
/** View-attribute bridge (Phase 5 slice 2): app-global composed terminal
|
||||
* appearance push backing main's hidden-PTY OSC/DSR color replies. */
|
||||
publishTerminalViewAttributes: (attributes: TerminalViewAttributes) => void
|
||||
hasChildProcesses: (id: string) => Promise<boolean>
|
||||
getForegroundProcess: (id: string) => Promise<string | null>
|
||||
getCwd: (id: string) => Promise<string>
|
||||
@@ -1258,8 +1293,16 @@ export type PreloadApi = {
|
||||
rows: number
|
||||
cwd?: string | null
|
||||
seq?: number
|
||||
/** Start of main's pending renderer-delivery queue at snapshot time
|
||||
* (equals `seq` when empty) — bounds the renderer's post-restore
|
||||
* duplicate window. */
|
||||
pendingDeliveryStartSeq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
alternateScreen?: boolean
|
||||
/** Authoritative normal buffer paired with an alternate-screen frame. */
|
||||
scrollbackAnsi?: string
|
||||
/** Trailing incomplete escape the emulator ingested; the restorer must
|
||||
* write it after its post-replay resets, last before live chunks. */
|
||||
pendingEscapeTailAnsi?: string
|
||||
} | null>
|
||||
getRendererDeliveryDebugSnapshot: () => Promise<{
|
||||
@@ -1276,6 +1319,14 @@ export type PreloadApi = {
|
||||
peakRendererInFlightChars: number
|
||||
peakMaxRendererInFlightCharsByPty: number
|
||||
ackGatedFlushSkipCount: number
|
||||
hiddenDeliveryGatedPtyCount: number
|
||||
hiddenDeliveryGatedVisiblePtyCount: number
|
||||
hiddenDeliveryGatedActivePtyCount: number
|
||||
deliveryInterestPtyCount: number
|
||||
hiddenDeliveryDroppedChars: number
|
||||
hiddenDeliveryDroppedChunks: number
|
||||
pendingDroppedChars: number
|
||||
diagnostics: PtyMainDeliveryDiagnostics
|
||||
rendererLifecycleResetCount: number
|
||||
lastLifecycleResetClearedChars: number
|
||||
rendererPtyDispatcherReady: boolean
|
||||
@@ -1289,10 +1340,19 @@ export type PreloadApi = {
|
||||
seq?: number
|
||||
rawLength?: number
|
||||
background?: boolean
|
||||
droppedBacklog?: boolean
|
||||
droppedOutput?: boolean
|
||||
}) => void
|
||||
) => () => void
|
||||
onReplay: (callback: (data: { id: string; data: string }) => void) => () => void
|
||||
/** Out-of-band main→renderer signal that renderer-bound bytes were
|
||||
* dropped (hidden-delivery gate / pending cap); the pane restores from
|
||||
* the model snapshot. Never delivered in-band on pty:data. */
|
||||
onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void) => () => void
|
||||
/** Batched derived side-effect facts for PTYs whose bytes transit local
|
||||
* main; see docs/reference/terminal-side-effect-authority.md. */
|
||||
onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void) => () => void
|
||||
/** Title-only replay snapshot for (re)attach; attention facts never replay. */
|
||||
getSideEffectSnapshot: (id: string) => Promise<TerminalSideEffectBatch | null>
|
||||
onExit: (callback: (data: { id: string; code: number }) => void) => () => void
|
||||
onSerializeBufferRequest: (
|
||||
callback: (data: {
|
||||
@@ -1993,6 +2053,10 @@ export type PreloadApi = {
|
||||
telemetryAcknowledgeBanner: () => Promise<void>
|
||||
settings: {
|
||||
get: () => Promise<GlobalSettings>
|
||||
/** Synchronous persisted-settings read for startup decisions that cannot
|
||||
* wait for async hydration (terminal side-effect authority). Blocking
|
||||
* IPC — call sparingly. */
|
||||
getSync: () => GlobalSettings | null
|
||||
set: (args: Partial<GlobalSettings>) => Promise<GlobalSettings>
|
||||
listFonts: () => Promise<string[]>
|
||||
previewGhosttyImport: () => Promise<GhosttyImportPreview>
|
||||
|
||||
@@ -20,5 +20,8 @@ const exposeStore = preloadEnv?.MODE === 'e2e' || isEnvFlagEnabled(preloadEnv?.V
|
||||
export const preloadE2EConfig = createE2EConfig({
|
||||
headless: process.env.ORCA_E2E_HEADLESS === '1',
|
||||
exposeStore,
|
||||
userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null
|
||||
userDataDir: process.env.ORCA_E2E_USER_DATA_DIR ?? null,
|
||||
// Why: Number('') is 0 and Number(undefined) is NaN; both coerce to null so
|
||||
// only a real positive override reaches the renderer parking policy.
|
||||
terminalParkingDelayMs: Number(process.env.ORCA_E2E_TERMINAL_PARKING_DELAY_MS) || null
|
||||
})
|
||||
|
||||
+112
-9
@@ -54,6 +54,13 @@ import type {
|
||||
WorktreeDefaultTabsLaunch,
|
||||
WorktreeRemoteBranchConflictEvent
|
||||
} from '../shared/types'
|
||||
import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker'
|
||||
import type {
|
||||
PtyRendererDeliveryHealthReply,
|
||||
PtyRendererDeliveryStateReport
|
||||
} from '../shared/pty-renderer-delivery-health'
|
||||
import type { TerminalViewAttributes } from '../shared/terminal-view-attributes'
|
||||
import type { PtyMainDeliveryDiagnostics } from '../shared/pty-delivery-diagnostics'
|
||||
import type {
|
||||
WarpThemeImportPreview,
|
||||
WarpThemeImportSource
|
||||
@@ -131,6 +138,7 @@ import type {
|
||||
MigrationUnsupportedPtyEntry
|
||||
} from '../shared/agent-status-types'
|
||||
import type { AgentInterruptInferenceRequest } from '../shared/agent-interrupt-intent'
|
||||
import type { TerminalSideEffectBatch } from '../shared/terminal-side-effect-facts'
|
||||
import type {
|
||||
SpeechErrorEvent,
|
||||
SpeechLifecycleEvent,
|
||||
@@ -792,6 +800,10 @@ const api = {
|
||||
shellOverride?: string
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution
|
||||
terminalColorQueryReplies?: { foreground?: string; background?: string }
|
||||
// Why: hidden-at-spawn declaration — main marks the PTY hidden before
|
||||
// its first byte so the delivery gate + model responder own spawn-time
|
||||
// queries (terminal-query-authority.md §races).
|
||||
initiallyHidden?: boolean
|
||||
// Why: closes the SIGKILL race documented in INVESTIGATION.md by
|
||||
// letting main patch + sync-flush the (worktreeId, tabId, leafId →
|
||||
// ptyId) binding before pty:spawn returns. Only the renderer's
|
||||
@@ -851,9 +863,39 @@ const api = {
|
||||
ackColdRestore: (id: string): void => {
|
||||
ipcRenderer.send('pty:ackColdRestore', { id })
|
||||
},
|
||||
ackData: (id: string, charCount: number): void => {
|
||||
ipcRenderer.send('pty:ackData', { id, charCount })
|
||||
/** charCount is the legacy per-chunk delta; processedChars is the
|
||||
* cumulative per-pty total (self-healing under lost ACK messages). */
|
||||
ackData: (id: string, charCount: number, processedChars?: number): void => {
|
||||
ipcRenderer.send('pty:ackData', {
|
||||
id,
|
||||
charCount,
|
||||
...(typeof processedChars === 'number' ? { processedChars } : {})
|
||||
})
|
||||
},
|
||||
/** Main asks for the renderer's cumulative processed totals when terminal
|
||||
* delivery looks stuck on lost ACKs. */
|
||||
onDeliveryResyncRequest: (callback: (payload: { requestId: number }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, payload: { requestId: number }) =>
|
||||
callback(payload)
|
||||
ipcRenderer.on('pty:requestDeliveryResync', listener)
|
||||
return () => ipcRenderer.removeListener('pty:requestDeliveryResync', listener)
|
||||
},
|
||||
respondDeliveryResync: (payload: {
|
||||
requestId: number
|
||||
processedCharsByPty: Record<string, number>
|
||||
}): void => {
|
||||
ipcRenderer.send('pty:deliveryResyncResponse', payload)
|
||||
},
|
||||
/** Renderer-initiated delivery health/heal lane. Rides invoke because the
|
||||
* field wedge (v1.4.121-rc.0 snapshot) kills main→renderer push events
|
||||
* while invoke stays alive — push-initiated recovery can't reach it. */
|
||||
reportRendererDeliveryState: (
|
||||
report: PtyRendererDeliveryStateReport
|
||||
): Promise<PtyRendererDeliveryHealthReply> =>
|
||||
ipcRenderer.invoke('pty:reportRendererDeliveryState', report),
|
||||
/** Sync count of live pty:data listeners on this preload's emitter — the
|
||||
* watchdog's "listener detached" vs "channel dead" discriminator. */
|
||||
getPtyDataListenerCount: (): number => ipcRenderer.listenerCount('pty:data'),
|
||||
rendererDispatcherReady: (): void => {
|
||||
ipcRenderer.send('pty:rendererDispatcherReady')
|
||||
},
|
||||
@@ -863,6 +905,24 @@ const api = {
|
||||
setRendererPtyVisible: (id: string, visible: boolean): void => {
|
||||
ipcRenderer.send('pty:setRendererPtyVisible', { id, visible })
|
||||
},
|
||||
/** Hidden-delivery gate (Phase 4): hidden=true lets main DROP renderer
|
||||
* byte delivery after model ingestion; reveal restores from the model
|
||||
* snapshot. Fire-and-forget like setActiveRendererPty. */
|
||||
setHiddenRendererPty: (id: string, hidden: boolean): void => {
|
||||
ipcRenderer.send('pty:setHiddenRendererPty', { id, hidden })
|
||||
},
|
||||
/** Delivery-interest signal: any renderer party that needs raw bytes
|
||||
* (dispatcher sidecars, eager pre-mount buffers) suppresses the
|
||||
* hidden-delivery gate for that PTY while registered. */
|
||||
setPtyDeliveryInterest: (id: string, interested: boolean): void => {
|
||||
ipcRenderer.send('pty:setPtyDeliveryInterest', { id, interested })
|
||||
},
|
||||
/** View-attribute bridge (Phase 5 slice 2): app-global composed terminal
|
||||
* appearance push that lets main's model responder answer OSC 4/10/11/12
|
||||
* and DSR ?996n for hidden-gated PTYs with renderer-true values. */
|
||||
publishTerminalViewAttributes: (attributes: TerminalViewAttributes): void => {
|
||||
ipcRenderer.send('pty:terminalViewAttributes', attributes)
|
||||
},
|
||||
|
||||
kill: (id: string, opts?: { keepHistory?: boolean }): Promise<void> =>
|
||||
ipcRenderer.invoke('pty:kill', { id, keepHistory: opts?.keepHistory ?? false }),
|
||||
@@ -880,8 +940,10 @@ const api = {
|
||||
rows: number
|
||||
cwd?: string | null
|
||||
seq?: number
|
||||
pendingDeliveryStartSeq?: number
|
||||
source?: 'headless' | 'renderer'
|
||||
alternateScreen?: boolean
|
||||
scrollbackAnsi?: string
|
||||
pendingEscapeTailAnsi?: string
|
||||
} | null> => ipcRenderer.invoke('pty:getMainBufferSnapshot', { id, opts }),
|
||||
|
||||
@@ -899,6 +961,14 @@ const api = {
|
||||
peakRendererInFlightChars: number
|
||||
peakMaxRendererInFlightCharsByPty: number
|
||||
ackGatedFlushSkipCount: number
|
||||
hiddenDeliveryGatedPtyCount: number
|
||||
hiddenDeliveryGatedVisiblePtyCount: number
|
||||
hiddenDeliveryGatedActivePtyCount: number
|
||||
deliveryInterestPtyCount: number
|
||||
hiddenDeliveryDroppedChars: number
|
||||
hiddenDeliveryDroppedChunks: number
|
||||
pendingDroppedChars: number
|
||||
diagnostics: PtyMainDeliveryDiagnostics
|
||||
rendererLifecycleResetCount: number
|
||||
lastLifecycleResetClearedChars: number
|
||||
rendererPtyDispatcherReady: boolean
|
||||
@@ -934,7 +1004,7 @@ const api = {
|
||||
seq?: number
|
||||
rawLength?: number
|
||||
background?: boolean
|
||||
droppedBacklog?: boolean
|
||||
droppedOutput?: boolean
|
||||
}) => void
|
||||
): (() => void) => {
|
||||
const listener = (
|
||||
@@ -945,7 +1015,7 @@ const api = {
|
||||
seq?: number
|
||||
rawLength?: number
|
||||
background?: boolean
|
||||
droppedBacklog?: boolean
|
||||
droppedOutput?: boolean
|
||||
}
|
||||
) => callback(data)
|
||||
ipcRenderer.on('pty:data', listener)
|
||||
@@ -959,6 +1029,32 @@ const api = {
|
||||
return () => ipcRenderer.removeListener('pty:replay', listener)
|
||||
},
|
||||
|
||||
/** Out-of-band signal that main dropped renderer-bound bytes for a PTY
|
||||
* (hidden-delivery gate / pending cap) — the pane must restore from the
|
||||
* model snapshot. Deliberately NOT on pty:data: an in-band marker is
|
||||
* ambiguous with chunks fully stripped by OSC-9999 cleaning. */
|
||||
onModelRestoreNeeded: (callback: (event: PtyModelRestoreNeededEvent) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, event: PtyModelRestoreNeededEvent) =>
|
||||
callback(event)
|
||||
ipcRenderer.on('pty:modelRestoreNeeded', listener)
|
||||
return () => ipcRenderer.removeListener('pty:modelRestoreNeeded', listener)
|
||||
},
|
||||
|
||||
/** Batched derived side-effect facts (title/bell/agent transitions) for
|
||||
* PTYs whose bytes transit local main. Per-PTY in-order; deliberately not
|
||||
* synchronized with pty:data (terminal-side-effect-authority.md). */
|
||||
onSideEffect: (callback: (batch: TerminalSideEffectBatch) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, batch: TerminalSideEffectBatch) =>
|
||||
callback(batch)
|
||||
ipcRenderer.on('pty:sideEffect', listener)
|
||||
return () => ipcRenderer.removeListener('pty:sideEffect', listener)
|
||||
},
|
||||
|
||||
/** Title-only replay snapshot applied on (re)attach — attention facts
|
||||
* (bells/completions) never replay. */
|
||||
getSideEffectSnapshot: (id: string): Promise<TerminalSideEffectBatch | null> =>
|
||||
ipcRenderer.invoke('pty:sideEffectSnapshot', { id }),
|
||||
|
||||
onExit: (callback: (data: { id: string; code: number }) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, data: { id: string; code: number }) =>
|
||||
callback(data)
|
||||
@@ -1739,6 +1835,10 @@ const api = {
|
||||
settings: {
|
||||
get: (): Promise<unknown> => ipcRenderer.invoke('settings:get'),
|
||||
|
||||
// Why: blocking read for the few startup decisions (terminal side-effect
|
||||
// authority) that cannot wait for async hydration. Call sparingly.
|
||||
getSync: (): unknown => ipcRenderer.sendSync('settings:get-sync'),
|
||||
|
||||
set: (args: Record<string, unknown>): Promise<unknown> =>
|
||||
ipcRenderer.invoke('settings:set', args),
|
||||
|
||||
@@ -3506,11 +3606,6 @@ const api = {
|
||||
ipcRenderer.on('terminal:zoom', listener)
|
||||
return () => ipcRenderer.removeListener('terminal:zoom', listener)
|
||||
},
|
||||
onSystemResumed: (callback: () => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent) => callback()
|
||||
ipcRenderer.on('system:resumed', listener)
|
||||
return () => ipcRenderer.removeListener('system:resumed', listener)
|
||||
},
|
||||
readClipboardText: (options?: ReadClipboardTextOptions): Promise<string> =>
|
||||
ipcRenderer.invoke('clipboard:readText', options),
|
||||
readSelectionClipboardText: (options?: ReadClipboardTextOptions): Promise<string> =>
|
||||
@@ -3576,6 +3671,14 @@ const api = {
|
||||
ipcRenderer.on('window:fullscreen-changed', listener)
|
||||
return () => ipcRenderer.removeListener('window:fullscreen-changed', listener)
|
||||
},
|
||||
/** Fired when the OS resumes from sleep (main relays powerMonitor). A
|
||||
* focus-preserving display wake fires no renderer focus/visibility
|
||||
* events, so terminal wake recovery listens to this explicit signal. */
|
||||
onSystemResumed: (callback: () => void): (() => void) => {
|
||||
const listener = () => callback()
|
||||
ipcRenderer.on('system:resumed', listener)
|
||||
return () => ipcRenderer.removeListener('system:resumed', listener)
|
||||
},
|
||||
/** Desktop custom titlebar only: minimize via renderer-drawn window controls. */
|
||||
minimize: (): void => {
|
||||
ipcRenderer.send('window:minimize')
|
||||
|
||||
@@ -130,6 +130,8 @@ import {
|
||||
} from './startup/startup-diagnostics'
|
||||
import { shouldRenderPetOverlay } from './components/pet/pet-overlay-visibility'
|
||||
import { applyDocumentTheme } from './lib/document-theme'
|
||||
import { getSystemPrefersDark } from './lib/terminal-theme'
|
||||
import { publishTerminalViewAttributesAtAppStart } from './components/terminal-pane/terminal-appearance'
|
||||
import { isEditableTarget } from './lib/editable-target'
|
||||
import { getSelectedTextForFileSearch } from './lib/file-search-selection'
|
||||
import { useShortcutLabel } from './hooks/useShortcutLabel'
|
||||
@@ -176,6 +178,11 @@ import {
|
||||
subscribeBackgroundTerminalWorktreeMountRequests
|
||||
} from './components/terminal/background-terminal-worktree-mount'
|
||||
|
||||
// Why: agents alive during a hard kill (crash, forced update install) need a
|
||||
// reasonably fresh resume record on disk; one minute bounds the lost window
|
||||
// without measurable per-tick cost (the capture skips unchanged records).
|
||||
const SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS = 60_000
|
||||
|
||||
const isMac = navigator.userAgent.includes('Mac')
|
||||
const isWindows = !isMac && navigator.userAgent.includes('Windows')
|
||||
const shortcutPlatform: NodeJS.Platform = isMac ? 'darwin' : isWindows ? 'win32' : 'linux'
|
||||
@@ -886,6 +893,14 @@ function App(): React.JSX.Element {
|
||||
// Load settings first so a persisted remote runtime does not boot against
|
||||
// the local filesystem and then hydrate stale local workspace state.
|
||||
await timeRendererStartupStep('fetch-settings', () => actions.fetchSettings())
|
||||
// Why here: hidden-at-launch PTYs (background terminal reconnects,
|
||||
// agent sessions) can query OSC 10/11 before any terminal pane mounts
|
||||
// and main's responder is silent-until-first-push. Publish composed
|
||||
// view attributes as soon as settings exist, before any spawn below.
|
||||
publishTerminalViewAttributesAtAppStart(
|
||||
useAppStore.getState().settings,
|
||||
getSystemPrefersDark()
|
||||
)
|
||||
// Why: keybindings + onboarding are main-side reads with no dependency
|
||||
// on the catalog/session steps below, so start them now and await them
|
||||
// at their original positions — the round-trips overlap the local
|
||||
@@ -1365,6 +1380,22 @@ function App(): React.JSX.Element {
|
||||
return () => window.removeEventListener('beforeunload', captureAndFlush)
|
||||
}, [])
|
||||
|
||||
// Why: beforeunload never fires on a hard kill (crash, forced update
|
||||
// install, TerminateProcess), so agents alive at that moment would leave no
|
||||
// resume record. This periodic capture stores only agent session ids — not
|
||||
// scrollback, see the no-periodic-scrollback note below — and the store
|
||||
// action skips unchanged records, so idle ticks write nothing; real changes
|
||||
// flow through the debounced session-write subscriber.
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
if (!shouldPersistWorkspaceSession(useAppStore.getState())) {
|
||||
return
|
||||
}
|
||||
useAppStore.getState().captureAllSleepingAgentSessions()
|
||||
}, SLEEPING_AGENT_RESUME_CAPTURE_INTERVAL_MS)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [])
|
||||
|
||||
// Own the single window-close-request subscription at the always-mounted App
|
||||
// root. Why: the rich confirmation flow lives in Terminal, which is not
|
||||
// mounted on the no-workspace landing page (and is lazy-loaded elsewhere), so
|
||||
|
||||
@@ -73,6 +73,18 @@ import {
|
||||
import { buildDuplicatedBrowserTabOptions } from '@/lib/duplicate-browser-tab-options'
|
||||
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
|
||||
import { setForegroundTerminalTabIds } from '@/lib/foreground-terminal-tabs'
|
||||
import {
|
||||
getTerminalWorktreeColdParkRecheckDelayMs,
|
||||
selectColdParkedTerminalWorktrees,
|
||||
type TerminalWorktreeColdParkCandidate
|
||||
} from './terminal-pane/terminal-hidden-view-parking'
|
||||
import { getTerminalParkingPolicyOverrides } from './terminal-pane/terminal-parking-e2e-overrides'
|
||||
import {
|
||||
canWatcherCoverParkedTerminalTab,
|
||||
pruneParkedTerminalWatchers,
|
||||
shouldDeferParkedPtyExitTabClose,
|
||||
syncParkedTerminalTabWatchers
|
||||
} from './terminal-pane/terminal-parked-tab-watchers'
|
||||
import { appendUniqueOpenFileIds } from './terminal/unsaved-close-queue'
|
||||
import { setWindowCloseRequestHandler } from './window-close-request-coordinator'
|
||||
import CodexRestartChip from './CodexRestartChip'
|
||||
@@ -132,6 +144,18 @@ const EDITOR_TAB_CONTENT_TYPES = new Set<TabContentType>([
|
||||
|
||||
type TerminalStoreSnapshot = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
function haveSameWorktreeIds(left: ReadonlySet<string>, right: ReadonlySet<string>): boolean {
|
||||
if (left.size !== right.size) {
|
||||
return false
|
||||
}
|
||||
for (const id of left) {
|
||||
if (!right.has(id)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
function findUnifiedTabByVisibleId(
|
||||
state: TerminalStoreSnapshot,
|
||||
worktreeId: string,
|
||||
@@ -214,6 +238,8 @@ function getKeybindingContext(target: EventTarget | null): KeybindingContext {
|
||||
function Terminal(): React.JSX.Element | null {
|
||||
const mountedWorktreeIdsRef = useRef(new Set<string>())
|
||||
const measurableBackgroundWorktreeIdsRef = useRef(new Set<string>())
|
||||
const terminalWorktreeHiddenSinceRef = useRef(new Map<string, number>())
|
||||
const terminalWorktreeParkingTimersRef = useRef(new Map<string, number>())
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const folderWorkspaces = useAppStore((s) => s.folderWorkspaces)
|
||||
const workspaceSurfaces = useMemo(
|
||||
@@ -230,6 +256,8 @@ function Terminal(): React.JSX.Element | null {
|
||||
const renderedActiveWorktreeId = activeWorktreeId
|
||||
const activeView = useAppStore((s) => s.activeView)
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
const pendingStartupByTabId = useAppStore((s) => s.pendingStartupByTabId)
|
||||
const terminalParkingEnabled = useAppStore((s) => s.settings?.terminalHiddenViewParking !== false)
|
||||
const activeTabId = useAppStore((s) => s.activeTabId)
|
||||
const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree)
|
||||
const createTab = useAppStore((s) => s.createTab)
|
||||
@@ -737,11 +765,15 @@ function Terminal(): React.JSX.Element | null {
|
||||
// Only mount TerminalPanes for visited worktrees to prevent mass PTY
|
||||
// spawning when restoring a session with many saved worktree tabs.
|
||||
const measurableBackgroundWorktreeTimersRef = useRef(new Map<string, number>())
|
||||
const [backgroundMountRevision, setBackgroundMountRevision] = useState(0)
|
||||
const [terminalParkingRevision, setTerminalParkingRevision] = useState(0)
|
||||
const [parkedTerminalWorktreeIds, setParkedTerminalWorktreeIds] = useState<ReadonlySet<string>>(
|
||||
() => new Set()
|
||||
)
|
||||
// Why: background-mounted worktrees restricted to specific tabs (targeted
|
||||
// wake/resume) must not instantiate a TerminalPane per saved tab. A worktree
|
||||
// absent from this map mounts all of its tabs.
|
||||
const backgroundMountTabIdsByWorktreeRef = useRef(new Map<string, ReadonlySet<string>>())
|
||||
const [, setBackgroundMountRevision] = useState(0)
|
||||
useEffect(() => {
|
||||
const timers = measurableBackgroundWorktreeTimersRef.current
|
||||
const closeDialogDebounceTimers = closeDialogDebounceTimersRef.current
|
||||
@@ -798,6 +830,122 @@ function Terminal(): React.JSX.Element | null {
|
||||
closeDialogDebounceTimers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timers = terminalWorktreeParkingTimersRef.current
|
||||
return () => {
|
||||
for (const timer of timers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
timers.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Why: worktree-level cold-park policy — hiddenSince bookkeeping, parked-set
|
||||
// selection, and one recheck timer per still-pending deadline so React
|
||||
// re-renders exactly when the hysteresis elapses instead of polling.
|
||||
useEffect(() => {
|
||||
const parkingTimers = terminalWorktreeParkingTimersRef.current
|
||||
for (const timer of parkingTimers.values()) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
parkingTimers.clear()
|
||||
|
||||
const nowMs = Date.now()
|
||||
const overrides = getTerminalParkingPolicyOverrides()
|
||||
const portalWorktreeIds = new Set(activityTerminalPortals.map((portal) => portal.worktreeId))
|
||||
const currentWorktreeIds = new Set(workspaceSurfaces.map((workspace) => workspace.id))
|
||||
for (const worktreeId of Array.from(terminalWorktreeHiddenSinceRef.current.keys())) {
|
||||
if (!currentWorktreeIds.has(worktreeId) || !mountedWorktreeIdsRef.current.has(worktreeId)) {
|
||||
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
|
||||
const retentionCandidates: TerminalWorktreeColdParkCandidate[] = []
|
||||
for (const workspace of workspaceSurfaces) {
|
||||
const worktreeId = workspace.id
|
||||
if (!mountedWorktreeIdsRef.current.has(worktreeId)) {
|
||||
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
|
||||
continue
|
||||
}
|
||||
const isVisible = activeView === 'terminal' && renderedActiveWorktreeId === worktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(worktreeId)
|
||||
const hasActivityTerminalPortal = portalWorktreeIds.has(worktreeId)
|
||||
if (isVisible || shouldMeasureHiddenWorktree || hasActivityTerminalPortal) {
|
||||
terminalWorktreeHiddenSinceRef.current.delete(worktreeId)
|
||||
} else if (!terminalWorktreeHiddenSinceRef.current.has(worktreeId)) {
|
||||
terminalWorktreeHiddenSinceRef.current.set(worktreeId, nowMs)
|
||||
}
|
||||
|
||||
retentionCandidates.push({
|
||||
worktreeId,
|
||||
terminalTabs: tabsByWorktree[worktreeId] ?? [],
|
||||
isVisible,
|
||||
shouldMeasureHiddenWorktree,
|
||||
hasActivityTerminalPortal,
|
||||
hiddenSinceMs: terminalWorktreeHiddenSinceRef.current.get(worktreeId) ?? null
|
||||
})
|
||||
}
|
||||
|
||||
const nextParkedTerminalWorktreeIds = selectColdParkedTerminalWorktrees({
|
||||
worktrees: retentionCandidates,
|
||||
pendingStartupByTabId,
|
||||
parkingEnabled: terminalParkingEnabled,
|
||||
nowMs,
|
||||
...overrides
|
||||
})
|
||||
// Why: a worktree with any tab the byte watchers cannot cover (no
|
||||
// capture, no layout snapshot, legacy leaf ids) must never park — it
|
||||
// would go silent for bells/titles/completions, the failure that sank
|
||||
// the first parking attempt.
|
||||
for (const worktreeId of Array.from(nextParkedTerminalWorktreeIds)) {
|
||||
const tabs = tabsByWorktree[worktreeId] ?? []
|
||||
if (!tabs.every((tab) => canWatcherCoverParkedTerminalTab(worktreeId, tab))) {
|
||||
nextParkedTerminalWorktreeIds.delete(worktreeId)
|
||||
}
|
||||
}
|
||||
setParkedTerminalWorktreeIds((current) =>
|
||||
haveSameWorktreeIds(current, nextParkedTerminalWorktreeIds)
|
||||
? current
|
||||
: nextParkedTerminalWorktreeIds
|
||||
)
|
||||
|
||||
for (const candidate of retentionCandidates) {
|
||||
if (
|
||||
candidate.isVisible ||
|
||||
candidate.shouldMeasureHiddenWorktree ||
|
||||
candidate.hasActivityTerminalPortal ||
|
||||
nextParkedTerminalWorktreeIds.has(candidate.worktreeId)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const delayMs = getTerminalWorktreeColdParkRecheckDelayMs({
|
||||
parkingEnabled: terminalParkingEnabled,
|
||||
hiddenSinceMs: candidate.hiddenSinceMs,
|
||||
nowMs,
|
||||
...overrides
|
||||
})
|
||||
if (delayMs !== null && delayMs > 0) {
|
||||
const worktreeId = candidate.worktreeId
|
||||
const timer = window.setTimeout(() => {
|
||||
parkingTimers.delete(worktreeId)
|
||||
setTerminalParkingRevision((revision) => revision + 1)
|
||||
}, delayMs)
|
||||
parkingTimers.set(worktreeId, timer)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activeView,
|
||||
activityTerminalPortals,
|
||||
backgroundMountRevision,
|
||||
pendingStartupByTabId,
|
||||
renderedActiveWorktreeId,
|
||||
tabsByWorktree,
|
||||
terminalParkingEnabled,
|
||||
terminalParkingRevision,
|
||||
workspaceSurfaces
|
||||
])
|
||||
// Why: gated on workspaceSessionReady to prevent TerminalPane from mounting
|
||||
// before reconnectPersistedTerminals() has finished eagerly spawning PTYs.
|
||||
// Without this gate, Phase 1 (hydrateWorkspaceSession) sets activeWorktreeId
|
||||
@@ -829,6 +977,59 @@ function Terminal(): React.JSX.Element | null {
|
||||
groupsByWorktree,
|
||||
activeGroupIdByWorktree
|
||||
)
|
||||
// Why: parked byte-watcher reconciliation for the legacy (non-split)
|
||||
// terminal host, which renders TerminalPanes directly. In split mode each
|
||||
// TerminalPaneOverlayLayer owns its worktree's watchers, so here we only
|
||||
// dispose worktrees that render no overlay layer (no layout / unmounted)
|
||||
// and prune watchers for deleted worktrees.
|
||||
useEffect(() => {
|
||||
pruneParkedTerminalWatchers(new Set(workspaceSurfaces.map((workspace) => workspace.id)))
|
||||
for (const workspace of workspaceSurfaces) {
|
||||
if (
|
||||
anyMountedWorktreeHasLayout &&
|
||||
mountedWorktreeIdsRef.current.has(workspace.id) &&
|
||||
getEffectiveLayoutForWorktree(workspace.id)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
const tabs = tabsByWorktree[workspace.id] ?? []
|
||||
const parkedTabIds = new Set<string>()
|
||||
if (!anyMountedWorktreeHasLayout && mountedWorktreeIdsRef.current.has(workspace.id)) {
|
||||
const isVisible = activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
const parked =
|
||||
!isVisible && !shouldMeasureHiddenWorktree && parkedTerminalWorktreeIds.has(workspace.id)
|
||||
if (parked) {
|
||||
for (const tab of tabs) {
|
||||
const activityTerminalPortal = findActivityTerminalPortal(activityTerminalPortals, {
|
||||
worktreeId: workspace.id,
|
||||
tabId: tab.id
|
||||
})
|
||||
if (!activityTerminalPortal) {
|
||||
parkedTabIds.add(tab.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
syncParkedTerminalTabWatchers({ worktreeId: workspace.id, tabs, parkedTabIds })
|
||||
}
|
||||
}, [
|
||||
activeView,
|
||||
activityTerminalPortals,
|
||||
anyMountedWorktreeHasLayout,
|
||||
backgroundMountRevision,
|
||||
getEffectiveLayoutForWorktree,
|
||||
parkedTerminalWorktreeIds,
|
||||
renderedActiveWorktreeId,
|
||||
tabsByWorktree,
|
||||
workspaceSurfaces
|
||||
])
|
||||
// Why: symmetric with useTerminalTabColdParking's unmount cleanup — when
|
||||
// the terminal host unmounts, no reconciliation effect will run again, so
|
||||
// dispose every remaining parked watcher here (overlay-layer children have
|
||||
// already disposed theirs by the time this parent cleanup runs).
|
||||
useEffect(() => () => pruneParkedTerminalWatchers(new Set()), [])
|
||||
// Auto-create first tab when worktree activates
|
||||
useEffect(() => {
|
||||
if (!workspaceSessionReady) {
|
||||
@@ -1127,6 +1328,12 @@ function Terminal(): React.JSX.Element | null {
|
||||
if (consumeSuppressedPtyExit(ptyId)) {
|
||||
return
|
||||
}
|
||||
// Why: a parked multi-leaf tab has no PaneManager to promote split
|
||||
// siblings, so closing the tab here would kill them; the reveal
|
||||
// remount handles dead PTYs per leaf instead.
|
||||
if (shouldDeferParkedPtyExitTabClose(tabId, ptyId)) {
|
||||
return
|
||||
}
|
||||
handleCloseTab(tabId)
|
||||
},
|
||||
[consumeSuppressedPtyExit, handleCloseTab]
|
||||
@@ -1859,6 +2066,10 @@ function Terminal(): React.JSX.Element | null {
|
||||
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
const shouldColdParkTerminalPanes =
|
||||
!isVisible &&
|
||||
!shouldMeasureHiddenWorktree &&
|
||||
parkedTerminalWorktreeIds.has(workspace.id)
|
||||
return (
|
||||
<WorktreeSplitSurface
|
||||
key={`tab-groups-${workspace.id}`}
|
||||
@@ -1868,6 +2079,7 @@ function Terminal(): React.JSX.Element | null {
|
||||
focusedGroupId={activeGroupIdByWorktree[workspace.id]}
|
||||
isVisible={isVisible}
|
||||
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
|
||||
shouldColdParkTerminalPanes={shouldColdParkTerminalPanes}
|
||||
activityTerminalPortals={activityTerminalPortals}
|
||||
backgroundMountTabIds={
|
||||
backgroundMountTabIdsByWorktreeRef.current.get(workspace.id) ?? null
|
||||
@@ -1922,6 +2134,10 @@ function Terminal(): React.JSX.Element | null {
|
||||
activeView === 'terminal' && workspace.id === renderedActiveWorktreeId
|
||||
const shouldMeasureHiddenWorktree =
|
||||
!isVisible && measurableBackgroundWorktreeIdsRef.current.has(workspace.id)
|
||||
const shouldColdParkTerminalPanes =
|
||||
!isVisible &&
|
||||
!shouldMeasureHiddenWorktree &&
|
||||
parkedTerminalWorktreeIds.has(workspace.id)
|
||||
return (
|
||||
<div
|
||||
key={workspace.id}
|
||||
@@ -1950,6 +2166,11 @@ function Terminal(): React.JSX.Element | null {
|
||||
const isActivityPortalTab = activityTerminalPortal !== null
|
||||
const isActiveTerminalTab =
|
||||
isVisible && tab.id === activeTabId && activeTabType === 'terminal'
|
||||
// Why: parking unmounts the view while preserving the PTY;
|
||||
// an Activity portal remains mounted as a visible consumer.
|
||||
if (shouldColdParkTerminalPanes && !isActivityPortalTab) {
|
||||
return null
|
||||
}
|
||||
const terminalPane = (
|
||||
<TerminalPane
|
||||
key={`${tab.id}-${tab.generation ?? 0}`}
|
||||
@@ -2158,6 +2379,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
||||
focusedGroupId,
|
||||
isVisible,
|
||||
shouldMeasureHiddenWorktree,
|
||||
shouldColdParkTerminalPanes,
|
||||
activityTerminalPortals,
|
||||
backgroundMountTabIds
|
||||
}: {
|
||||
@@ -2167,6 +2389,7 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
||||
focusedGroupId?: string
|
||||
isVisible: boolean
|
||||
shouldMeasureHiddenWorktree: boolean
|
||||
shouldColdParkTerminalPanes: boolean
|
||||
activityTerminalPortals: ActivityTerminalPortalTarget[]
|
||||
backgroundMountTabIds: ReadonlySet<string> | null
|
||||
}): React.JSX.Element {
|
||||
@@ -2207,6 +2430,8 @@ const WorktreeSplitSurface = React.memo(function WorktreeSplitSurface({
|
||||
worktreeId={worktreeId}
|
||||
worktreePath={worktreePath}
|
||||
isWorktreeActive={isVisible}
|
||||
coldParkTerminalPanes={shouldColdParkTerminalPanes}
|
||||
shouldMeasureHiddenWorktree={shouldMeasureHiddenWorktree}
|
||||
activityTerminalPortals={activityTerminalPortals}
|
||||
backgroundMountTabIds={backgroundMountTabIds}
|
||||
/>
|
||||
|
||||
@@ -13,6 +13,8 @@ import TerminalPane from './TerminalPane'
|
||||
import { closeTerminalTab } from '../terminal/terminal-tab-actions'
|
||||
import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount'
|
||||
import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut'
|
||||
import { shouldDeferParkedPtyExitTabClose } from './terminal-parked-tab-watchers'
|
||||
import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking'
|
||||
|
||||
type TerminalOverlayAssignment = {
|
||||
unifiedTabId: string
|
||||
@@ -238,6 +240,12 @@ const TerminalOverlaySlot = memo(function TerminalOverlaySlot({
|
||||
if (consumeSuppressedPtyExit(ptyId)) {
|
||||
return
|
||||
}
|
||||
// Why: a parked multi-leaf tab has no PaneManager to promote split
|
||||
// siblings, so closing the tab here would kill them; the reveal
|
||||
// remount handles dead PTYs per leaf instead.
|
||||
if (shouldDeferParkedPtyExitTabClose(terminalTabId, ptyId)) {
|
||||
return
|
||||
}
|
||||
closeTab(terminalTabId)
|
||||
leaveWorktreeIfEmpty()
|
||||
}}
|
||||
@@ -279,12 +287,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
worktreeId,
|
||||
worktreePath,
|
||||
isWorktreeActive,
|
||||
coldParkTerminalPanes = false,
|
||||
shouldMeasureHiddenWorktree = false,
|
||||
activityTerminalPortals = EMPTY_ACTIVITY_PORTALS,
|
||||
backgroundMountTabIds = null
|
||||
}: {
|
||||
worktreeId: string
|
||||
worktreePath: string
|
||||
isWorktreeActive: boolean
|
||||
coldParkTerminalPanes?: boolean
|
||||
shouldMeasureHiddenWorktree?: boolean
|
||||
activityTerminalPortals?: ActivityTerminalPortalTarget[]
|
||||
/** Non-null for targeted background mounts: only these terminal tabs get a
|
||||
* TerminalPane, so waking one slept agent does not connect every saved tab. */
|
||||
@@ -351,6 +363,16 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
return entries
|
||||
}, [groupActiveTabById, unifiedTabs])
|
||||
|
||||
const parkedTerminalTabIds = useTerminalTabColdParking({
|
||||
worktreeId,
|
||||
terminalTabs,
|
||||
assignments,
|
||||
isWorktreeActive,
|
||||
coldParkTerminalPanes,
|
||||
shouldMeasureHiddenWorktree,
|
||||
activityTerminalPortals
|
||||
})
|
||||
|
||||
if (!worktreePath) {
|
||||
return null
|
||||
}
|
||||
@@ -369,6 +391,11 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({
|
||||
worktreeId,
|
||||
tabId: terminalTab.id
|
||||
})
|
||||
// Why: parking unmounts only the view; the parked watcher owns exit
|
||||
// and side-effect handling until this tab is eligible to remount.
|
||||
if (parkedTerminalTabIds.has(terminalTab.id)) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<TerminalOverlaySlot
|
||||
key={terminalTab.id}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Agent-task-complete notification policy predicates and timing constants.
|
||||
*
|
||||
* Why extracted from pty-connection.ts: the parked byte watcher and the
|
||||
* pty:sideEffect facts handler apply the exact live-path semantics without a
|
||||
* pane, and policy must not drift between the three consumers
|
||||
* (docs/reference/terminal-side-effect-authority.md). This module is
|
||||
* deliberately dependency-light — no pane/xterm imports — so pane-less
|
||||
* consumers can use it.
|
||||
*/
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { GlobalSettings } from '../../../../shared/types'
|
||||
|
||||
/** Delay before BEL/completion OS notifications so the richer
|
||||
* agent-task-complete notification can win a same-burst BEL race. */
|
||||
export const AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS = 250
|
||||
/** Hard cap on waiting for hook detail before dispatching a completion. */
|
||||
export const AGENT_TASK_COMPLETE_NOTIFICATION_MAX_WAIT_MS = 1500
|
||||
export const AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS = 10_000
|
||||
|
||||
type NotificationSettingsState = {
|
||||
settings: Pick<GlobalSettings, 'notifications' | 'experimentalTerminalAttention'> | null
|
||||
}
|
||||
|
||||
export function isAgentTaskCompleteOsNotificationEnabledFromState(
|
||||
state: NotificationSettingsState
|
||||
): boolean {
|
||||
const notifications = state.settings?.notifications
|
||||
return notifications?.enabled !== false && notifications?.agentTaskComplete !== false
|
||||
}
|
||||
|
||||
export function isTerminalAttentionEnabledFromState(state: NotificationSettingsState): boolean {
|
||||
return state.settings?.experimentalTerminalAttention === true
|
||||
}
|
||||
|
||||
/** Completion tracking runs when either consumer (OS notification or the
|
||||
* experimental terminal-attention marker) is enabled. */
|
||||
export function isAgentTaskCompleteTrackingEnabledFromState(
|
||||
state: NotificationSettingsState
|
||||
): boolean {
|
||||
return (
|
||||
isAgentTaskCompleteOsNotificationEnabledFromState(state) ||
|
||||
isTerminalAttentionEnabledFromState(state)
|
||||
)
|
||||
}
|
||||
|
||||
export function hasAgentNotificationDetail(entry: AgentStatusEntry | undefined): boolean {
|
||||
return Boolean(
|
||||
entry &&
|
||||
Date.now() - entry.updatedAt <= AGENT_TASK_COMPLETE_NOTIFICATION_DETAIL_MAX_AGE_MS &&
|
||||
(entry.lastAssistantMessage || entry.toolName || entry.toolInput)
|
||||
)
|
||||
}
|
||||
|
||||
export function canDispatchAgentNotificationAfterGrace(
|
||||
entry: AgentStatusEntry | undefined,
|
||||
options: { allowDoneDetailAfterGrace?: boolean } = {}
|
||||
): boolean {
|
||||
// Why: hook-backed goal/mission loops can report `done` between milestones.
|
||||
// User-input states may notify as soon as detail arrives, but `done` waits
|
||||
// for the max quiet window so resumed work can cancel the pending banner.
|
||||
return (
|
||||
hasAgentNotificationDetail(entry) &&
|
||||
(entry?.state !== 'done' || options.allowDoneDetailAfterGrace === true)
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,558 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildAgentTuiStreamOps,
|
||||
mulberry32,
|
||||
splitIntoRandomChunks,
|
||||
type AgentTuiStreamDims
|
||||
} from '../../../../shared/agent-tui-ansi-fuzz-stream'
|
||||
import { extractPartialEscapeTail } from '../../../../shared/terminal-partial-escape-tail'
|
||||
import {
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY,
|
||||
SNAPSHOT_REPLAY_PREAMBLE_ALT,
|
||||
SNAPSHOT_REPLAY_PREAMBLE_NORMAL,
|
||||
bufferHasSerializeHostileWrappedRow,
|
||||
buildParityMainBufferSnapshot,
|
||||
createRendererParityTerminal,
|
||||
cursorPosition,
|
||||
normalBufferRowsTrimmed,
|
||||
visibleRows,
|
||||
visibleRowStyles,
|
||||
writeChunksToTerminal
|
||||
} from '../../../../shared/terminal-restore-parity-fixture'
|
||||
|
||||
// Property fuzz for the hidden-reveal seq-reconciliation path in
|
||||
// pty-connection.ts. When a pane is hidden, main keeps metering PTY output as
|
||||
// seq'd chunks. On reveal the renderer applies a HeadlessEmulator snapshot
|
||||
// captured at seq S (paints everything <= S), then stitches the racing tail
|
||||
// (chunks straddling / after S) on top WITHOUT duplicating or losing a byte.
|
||||
// The byte arithmetic lives in two non-exported closures:
|
||||
// - getChunkDataAfterSnapshot (pty-connection.ts ~L4629): slices a
|
||||
// pending chunk against the snapshot seq.
|
||||
// - reconcileChunkAgainstRestoredSnapshot (pty-connection.ts ~L4660): dedups
|
||||
// backlog, detects a restarted seq domain, and heals dropped-output gaps by
|
||||
// forcing a fresh restore.
|
||||
// This suite mirrors those rules EXACTLY (each branch tagged with its source
|
||||
// line) and asserts the invariant they guarantee: for any reveal boundary,
|
||||
// snapshot(hidden prefix) + reconciled tail reproduces the same screen an
|
||||
// always-visible terminal shows for the full stream. A wrong slice = a garble.
|
||||
//
|
||||
// Runtime knobs:
|
||||
// FUZZ_ITERATIONS=2000 deep/nightly (default 200, <60s with the fidelity suite)
|
||||
// FUZZ_SEED=1234 re-run exactly one seed
|
||||
|
||||
const DEFAULT_ITERATIONS = 200
|
||||
const FIXED_SEED = readPositiveIntEnv('FUZZ_SEED')
|
||||
const ITERATIONS =
|
||||
FIXED_SEED !== null ? 1 : (readPositiveIntEnv('FUZZ_ITERATIONS') ?? DEFAULT_ITERATIONS)
|
||||
|
||||
function readPositiveIntEnv(name: string): number | null {
|
||||
const raw = Number(process.env[name])
|
||||
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : null
|
||||
}
|
||||
|
||||
const DIMS: readonly AgentTuiStreamDims[] = [
|
||||
{ cols: 80, rows: 24 },
|
||||
{ cols: 100, rows: 30 }
|
||||
]
|
||||
|
||||
/** A metered chunk exactly as main delivers to onData: text, the seq of the
|
||||
* LAST raw byte, the raw (pre-OSC-strip) length, and delivery-domain markers.
|
||||
* domain increments across a ptyId-exit seq restart (production clears the
|
||||
* restored baseline at that boundary, so the tail writes verbatim). */
|
||||
type MeteredChunk = {
|
||||
data: string
|
||||
seq?: number
|
||||
rawLength?: number
|
||||
domain: number
|
||||
droppedOutput?: boolean
|
||||
}
|
||||
|
||||
/** Mirror of getChunkDataAfterSnapshot (pty-connection.ts ~L4629): how much of
|
||||
* a chunk in the SNAPSHOT'S seq domain survives once the snapshot at
|
||||
* snapshotSeq has painted everything <= snapshotSeq. null = "cannot slice,
|
||||
* force a fresh snapshot" (OSC stripping desynced raw offsets). Byte-identical
|
||||
* to production; each return tagged with its branch. */
|
||||
function chunkDataAfterSnapshot(
|
||||
chunk: MeteredChunk,
|
||||
snapshotSeq: number | undefined
|
||||
): string | null {
|
||||
if (typeof snapshotSeq !== 'number' || typeof chunk.seq !== 'number') {
|
||||
return chunk.data // L4633: unmetered — pass through
|
||||
}
|
||||
const rawLength = chunk.rawLength ?? chunk.data.length
|
||||
const startSeq = chunk.seq - rawLength
|
||||
if (snapshotSeq >= chunk.seq) {
|
||||
return '' // L4638: fully before/at snapshot — already painted
|
||||
}
|
||||
if (snapshotSeq <= startSeq) {
|
||||
return chunk.data // L4641: fully after snapshot — keep whole
|
||||
}
|
||||
const offset = snapshotSeq - startSeq
|
||||
if (rawLength !== chunk.data.length) {
|
||||
return null // L4645: OSC-stripped, offsets unmappable
|
||||
}
|
||||
return chunk.data.slice(offset) // L4648: straddles — drop the painted prefix
|
||||
}
|
||||
|
||||
type RevealResult = {
|
||||
/** Full normal-buffer content (visible + scrollback, trailing blanks
|
||||
* trimmed). Viewport-independent: a byte lost or duplicated by reconciliation
|
||||
* changes this even when the visible viewport happens to line up. */
|
||||
content: string[]
|
||||
rows: string[]
|
||||
styles: string[]
|
||||
cursor: { x: number; y: number }
|
||||
/** True when the buffer scrolled (baseY > 0). The visible viewport's top
|
||||
* anchor after a snapshot restore vs continuous writing can differ by a row
|
||||
* or two purely from trailing-blank trimming — a snapshot-scrollback-depth
|
||||
* concern owned by the fidelity suite, not seq reconciliation — so styles and
|
||||
* cursor (viewport-relative) are only asserted on non-scrolled scenarios. */
|
||||
scrolled: boolean
|
||||
/** True when the terminal ended on the alternate screen. Alt has no
|
||||
* scrollback and a fixed viewport, so its content is the visible rows and the
|
||||
* normal-buffer content comparison does not apply (the alt-screen restore
|
||||
* contract is scrollback-free by design — serializeHeadlessTerminalBuffer). */
|
||||
alternate: boolean
|
||||
forcedFreshRestore: boolean
|
||||
knownSerializeWrapBug: boolean
|
||||
}
|
||||
|
||||
async function readScreen(
|
||||
term: ReturnType<typeof createRendererParityTerminal>
|
||||
): Promise<RevealResult> {
|
||||
const alternate = term.terminal.buffer.active.type === 'alternate'
|
||||
return {
|
||||
content: alternate ? visibleRows(term.terminal) : normalBufferRowsTrimmed(term.terminal),
|
||||
rows: visibleRows(term.terminal),
|
||||
styles: visibleRowStyles(term.terminal),
|
||||
cursor: cursorPosition(term.terminal),
|
||||
scrolled: term.terminal.buffer.active.baseY > 0,
|
||||
alternate,
|
||||
forcedFreshRestore: false,
|
||||
knownSerializeWrapBug: false
|
||||
}
|
||||
}
|
||||
|
||||
/** The renderer's screen for a hide→reveal cycle. `revealIdx` is the delivery
|
||||
* index at which the pane is revealed: chunks [0, revealIdx) were captured by
|
||||
* the HeadlessEmulator snapshot; chunks [revealIdx, end) are the racing tail.
|
||||
* The snapshot seq is the seq of the last hidden chunk in the snapshot's
|
||||
* domain; the tail is stitched via the production slice/reconcile rules. */
|
||||
async function revealFromSnapshot(
|
||||
dims: AgentTuiStreamDims,
|
||||
chunks: MeteredChunk[],
|
||||
revealIdx: number
|
||||
): Promise<RevealResult> {
|
||||
const source = createRendererParityTerminal(dims)
|
||||
const restored = createRendererParityTerminal(dims)
|
||||
try {
|
||||
// Snapshot source = every hidden chunk painted in order.
|
||||
const hiddenChunks = chunks.slice(0, revealIdx).map((c) => c.data)
|
||||
await writeChunksToTerminal(source.terminal, hiddenChunks)
|
||||
// Snapshot seq = seq of the last hidden chunk that carried one (the seq the
|
||||
// emulator would report). undefined when the hidden prefix was unmetered.
|
||||
let snapshotSeq: number | undefined
|
||||
let snapshotDomain = 0
|
||||
for (let i = revealIdx - 1; i >= 0; i--) {
|
||||
if (typeof chunks[i]!.seq === 'number') {
|
||||
snapshotSeq = chunks[i]!.seq
|
||||
snapshotDomain = chunks[i]!.domain
|
||||
break
|
||||
}
|
||||
}
|
||||
const snapshot = buildParityMainBufferSnapshot(source, snapshotSeq ?? 0, {
|
||||
// Mirror of HeadlessEmulator's ingest tracker: the hidden stream's
|
||||
// trailing incomplete escape rides the snapshot out-of-band (Bug E fix).
|
||||
pendingEscapeTail: extractPartialEscapeTail(hiddenChunks.join(''))
|
||||
})
|
||||
const alt = snapshot.alternateScreen
|
||||
const knownSerializeWrapBug = bufferHasSerializeHostileWrappedRow(source.terminal)
|
||||
// Mirror of applyMainBufferSnapshot's write order: the pending escape tail
|
||||
// is the FINAL replay write — any later ESC (e.g. the post-replay reset)
|
||||
// would abort the dangling sequence before the racing tail completes it.
|
||||
const preamble =
|
||||
alt && snapshot.scrollbackAnsi !== undefined
|
||||
? `\x1b[?1049l\x1b[2J\x1b[3J\x1b[H${snapshot.scrollbackAnsi}${SNAPSHOT_REPLAY_PREAMBLE_ALT}`
|
||||
: alt
|
||||
? SNAPSHOT_REPLAY_PREAMBLE_ALT
|
||||
: SNAPSHOT_REPLAY_PREAMBLE_NORMAL
|
||||
await writeChunksToTerminal(restored.terminal, [
|
||||
preamble,
|
||||
snapshot.data,
|
||||
POST_REPLAY_LIVE_SNAPSHOT_RESET_PARITY,
|
||||
...(snapshot.pendingEscapeTailAnsi ? [snapshot.pendingEscapeTailAnsi] : [])
|
||||
])
|
||||
|
||||
// Stitch the tail. A chunk in the snapshot's domain is sliced against the
|
||||
// snapshot seq (drains the painted prefix); a chunk from a later domain
|
||||
// (post-exit seq restart) writes verbatim — production clears the baseline
|
||||
// at the ptyId-exit boundary, so no seq dedupe applies across domains.
|
||||
let forcedFreshRestore = false
|
||||
for (let i = revealIdx; i < chunks.length; i++) {
|
||||
const chunk = chunks[i]!
|
||||
if (chunk.domain !== snapshotDomain) {
|
||||
await writeChunksToTerminal(restored.terminal, [chunk.data])
|
||||
continue
|
||||
}
|
||||
const sliced = chunkDataAfterSnapshot(chunk, snapshotSeq)
|
||||
if (sliced === null) {
|
||||
// Production re-fetches a fresh snapshot here; a fresh snapshot of the
|
||||
// full stream trivially matches the always-visible screen, so mark the
|
||||
// scenario as a fresh-restore (excluded from the stitch comparison).
|
||||
forcedFreshRestore = true
|
||||
continue
|
||||
}
|
||||
if (sliced) {
|
||||
await writeChunksToTerminal(restored.terminal, [sliced])
|
||||
}
|
||||
}
|
||||
const result = await readScreen(restored)
|
||||
result.forcedFreshRestore = forcedFreshRestore
|
||||
result.knownSerializeWrapBug = knownSerializeWrapBug
|
||||
return result
|
||||
} finally {
|
||||
source.terminal.dispose()
|
||||
restored.terminal.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
async function alwaysVisible(
|
||||
dims: AgentTuiStreamDims,
|
||||
chunks: MeteredChunk[]
|
||||
): Promise<RevealResult> {
|
||||
const term = createRendererParityTerminal(dims)
|
||||
try {
|
||||
await writeChunksToTerminal(
|
||||
term.terminal,
|
||||
chunks.map((c) => c.data)
|
||||
)
|
||||
return await readScreen(term)
|
||||
} finally {
|
||||
term.terminal.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/** Reference for the seq-reconciliation gate: a snapshot of the WHOLE stream
|
||||
* (reveal at the very end, no racing tail). Sharing the snapshot machinery with
|
||||
* revealFromSnapshot cancels out snapshot-fidelity gaps (Bug C cursor restore,
|
||||
* Bug B bold loss — owned by the fidelity suite), so a diff against a mid-point
|
||||
* reveal is attributable purely to the tail-stitch seq arithmetic. */
|
||||
async function fullSnapshotReference(
|
||||
dims: AgentTuiStreamDims,
|
||||
chunks: MeteredChunk[]
|
||||
): Promise<RevealResult> {
|
||||
return revealFromSnapshot(dims, chunks, chunks.length)
|
||||
}
|
||||
|
||||
type Scenario = {
|
||||
seed: number
|
||||
dims: AgentTuiStreamDims
|
||||
chunks: MeteredChunk[]
|
||||
revealIdx: number
|
||||
domains: number
|
||||
hasDropped: boolean
|
||||
}
|
||||
|
||||
const TAIL_WORDS = [
|
||||
'reading',
|
||||
'tokens 12.4k',
|
||||
'你好世界',
|
||||
'터미널',
|
||||
'✅ done',
|
||||
'PASS test.ts',
|
||||
'+142 -37',
|
||||
'diff --git'
|
||||
] as const
|
||||
// Why no SGR 7 (inverse): inverse marks trailing BLANK cells with an
|
||||
// inverse-fg the serializer round-trips slightly differently depending on how
|
||||
// much of the buffer it captures — a serialize SGR-fidelity nuance (the Bug B
|
||||
// class) that has nothing to do with seq reconciliation. Keeping it out of the
|
||||
// tail keeps the styles gate a clean function of the byte-stitch.
|
||||
const TAIL_SGR = ['\x1b[0m', '\x1b[31m', '\x1b[1m', '\x1b[38;5;204m', '\x1b[22m'] as const
|
||||
|
||||
/** Append-only racing tail: SGR runs + text + newlines only. No absolute/
|
||||
* relative cursor motion, scroll regions, alt frames, or DECSC — so the tail
|
||||
* paints wherever the cursor sits and its screen effect is a pure function of
|
||||
* which bytes the seq-reconciliation applies. Terminal-state-loss garbles
|
||||
* (Bugs C/D/E, scroll region) are pinned separately; this suite isolates the
|
||||
* seq slice. */
|
||||
function buildAppendOnlyTail(rng: () => number, lines: number): string {
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < lines; i++) {
|
||||
const words: string[] = []
|
||||
const count = 1 + Math.floor(rng() * 3)
|
||||
for (let w = 0; w < count; w++) {
|
||||
words.push(TAIL_WORDS[Math.floor(rng() * TAIL_WORDS.length)]!)
|
||||
}
|
||||
const sgr = TAIL_SGR[Math.floor(rng() * TAIL_SGR.length)]!
|
||||
out.push(`${sgr}${words.join(' ')}\x1b[0m\r\n`)
|
||||
}
|
||||
return out.join('')
|
||||
}
|
||||
|
||||
/** Builds a seeded metered stream with a random reveal boundary. A running seq
|
||||
* counter tags the LAST byte of each chunk; at most ONE mid-stream restart
|
||||
* bumps the domain and resets the counter low (a revived ptyId with a fresh
|
||||
* main-side counter — production clears the restored baseline at that exit
|
||||
* boundary, so the post-restart tail writes verbatim). Rare unmetered chunks
|
||||
* (no seq) and droppedOutput markers model no-metering runtimes and pending-cap
|
||||
* trims. Op counts are kept modest so most scenarios reach the full
|
||||
* non-scrolled comparison (the tail-stitch invariant); scrolled scenarios fall
|
||||
* back to the snapshot-depth-tolerant path (see the assertion loop). */
|
||||
function buildScenario(seed: number): Scenario {
|
||||
const rng = mulberry32(seed)
|
||||
const dims = DIMS[Math.floor(rng() * DIMS.length)]!
|
||||
// Hidden prefix: full agent-TUI churn (cursor motion, panels, alt frames,
|
||||
// scroll regions, DECSC…) so the SNAPSHOT is taken over rich state.
|
||||
const prefixOps = buildAgentTuiStreamOps(rng, dims, {
|
||||
includeMouseModes: false,
|
||||
includeOscHyperlinks: false,
|
||||
opCount: 3 + Math.floor(rng() * 6)
|
||||
})
|
||||
const prefixStream = prefixOps.join('')
|
||||
const prefixRaw = splitIntoRandomChunks(mulberry32(seed ^ 0x51ed270b), prefixStream, {
|
||||
minLen: 2,
|
||||
maxLen: 64
|
||||
})
|
||||
// Racing tail: append-only content. Isolates the seq-reconciliation byte
|
||||
// stitch from terminal-state-loss garbles (Bugs C/D/E — pinned separately).
|
||||
const tailStream = buildAppendOnlyTail(rng, 2 + Math.floor(rng() * 5))
|
||||
const tailRaw = splitIntoRandomChunks(mulberry32(seed ^ 0x2545f491), tailStream, {
|
||||
minLen: 2,
|
||||
maxLen: 40
|
||||
})
|
||||
|
||||
const chunks: MeteredChunk[] = []
|
||||
let seq = 100 + Math.floor(rng() * 50)
|
||||
let domain = 0
|
||||
let hasDropped = false
|
||||
// At most one restart, placed inside the tail so the snapshot's own domain
|
||||
// has a stable prefix — mirrors a single mid-session ptyId revival.
|
||||
const restartAt =
|
||||
rng() < 0.25 ? prefixRaw.length + Math.floor(rng() * Math.max(1, tailRaw.length)) : -1
|
||||
const allRaw = [...prefixRaw, ...tailRaw]
|
||||
for (let i = 0; i < allRaw.length; i++) {
|
||||
const data = allRaw[i]!
|
||||
if (i === restartAt) {
|
||||
seq = 10 + Math.floor(rng() * 20)
|
||||
domain = 1
|
||||
}
|
||||
const rawLength = data.length
|
||||
seq += rawLength
|
||||
const chunk: MeteredChunk = { data, seq, rawLength, domain }
|
||||
// Rare unmetered / dropped markers, only in the tail (the prefix must meter
|
||||
// so the snapshot carries a seq).
|
||||
if (i >= prefixRaw.length && rng() < 0.05) {
|
||||
delete chunk.seq
|
||||
delete chunk.rawLength
|
||||
}
|
||||
if (i >= prefixRaw.length && rng() < 0.04) {
|
||||
chunk.droppedOutput = true
|
||||
hasDropped = true
|
||||
}
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Reveal at or after the prefix ends, so the racing tail is purely the
|
||||
// append-only region — the snapshot captures all cursor-motion/scroll/alt
|
||||
// state and the tail cannot depend on unserialized terminal state. Landing
|
||||
// the reveal at various points INSIDE the append-only tail exercises the
|
||||
// straddle and "snapshot seq inside the tail" seq-slice cases.
|
||||
const tailSpan = Math.max(1, chunks.length - prefixRaw.length)
|
||||
const revealIdx = Math.min(chunks.length, prefixRaw.length + Math.floor(rng() * tailSpan))
|
||||
return { seed, dims, chunks, revealIdx, domains: domain + 1, hasDropped }
|
||||
}
|
||||
|
||||
function firstRowDiff(a: string[], b: string[]): number | null {
|
||||
const n = Math.max(a.length, b.length)
|
||||
for (let i = 0; i < n; i++) {
|
||||
if ((a[i] ?? '') !== (b[i] ?? '')) {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function formatFailure(s: Scenario, stage: string, expected: unknown, actual: unknown): string {
|
||||
return [
|
||||
`hidden-reveal reconciliation divergence — stage: ${stage}`,
|
||||
`seed: ${s.seed} (re-run: FUZZ_SEED=${s.seed})`,
|
||||
`dims: ${s.dims.cols}x${s.dims.rows} revealIdx: ${s.revealIdx}/${s.chunks.length} domains: ${s.domains} dropped: ${s.hasDropped}`,
|
||||
`chunks: ${JSON.stringify(
|
||||
s.chunks.map((c) => ({ seq: c.seq, len: c.data.length, dom: c.domain }))
|
||||
)}`,
|
||||
`expected (always-visible): ${JSON.stringify(expected)}`,
|
||||
`actual (reveal-from-snapshot): ${JSON.stringify(actual)}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
describe('hidden reveal seq-reconciliation fuzz', () => {
|
||||
it('is a byte-exact identity when the snapshot seq splits a straddling chunk', async () => {
|
||||
// Pin the core invariant on a hand-built case so the property test cannot
|
||||
// pass vacuously. rawLength === data.length so the straddle slices cleanly.
|
||||
// Chunk 0 fully hidden; chunk 1 straddles the reveal; chunk 2 is the tail.
|
||||
const dims = { cols: 40, rows: 6 }
|
||||
const c0 = 'red line one\r\n'
|
||||
const c1 = 'green straddle\r\n'
|
||||
const c2 = 'plain tail rest'
|
||||
const chunks: MeteredChunk[] = [
|
||||
{ data: c0, seq: c0.length, rawLength: c0.length, domain: 0 },
|
||||
{ data: c1, seq: c0.length + c1.length, rawLength: c1.length, domain: 0 },
|
||||
{ data: c2, seq: c0.length + c1.length + c2.length, rawLength: c2.length, domain: 0 }
|
||||
]
|
||||
// Reveal after chunk 1 (snapshot seq = end of c1); tail = c2. Also exercise
|
||||
// the straddle by revealing at chunk 1 with a snapshot seq mid-c1 handled by
|
||||
// chunkDataAfterSnapshot when c1 is in the tail — covered by revealIdx=1.
|
||||
const revealAfterC1 = await revealFromSnapshot(dims, chunks, 2)
|
||||
const control = await alwaysVisible(dims, chunks)
|
||||
expect(revealAfterC1.rows).toEqual(control.rows)
|
||||
expect(revealAfterC1.styles).toEqual(control.styles)
|
||||
expect(revealAfterC1.cursor).toEqual(control.cursor)
|
||||
|
||||
// Reveal at chunk 1: c0 hidden (snapshot seq = end of c0), c1+c2 are the
|
||||
// tail. c1 is fully after the snapshot seq so it writes whole — identity.
|
||||
const revealAtC1 = await revealFromSnapshot(dims, chunks, 1)
|
||||
expect(revealAtC1.rows).toEqual(control.rows)
|
||||
expect(revealAtC1.styles).toEqual(control.styles)
|
||||
expect(revealAtC1.cursor).toEqual(control.cursor)
|
||||
})
|
||||
|
||||
// ── Bug D regression guard: DECSC saved-cursor register across hide/reveal ──
|
||||
// The serialized screen cannot carry the VT100 saved-cursor register, so a
|
||||
// hidden DECSC followed by a post-reveal DECRC restored to home and the next
|
||||
// writes clobbered the wrong cells. FIXED: the snapshot epilogue re-saves at
|
||||
// the source's saved position before the final absolute CUP
|
||||
// (serializeWithAbsoluteCursor + readSavedCursorRegister). Found by fuzz
|
||||
// seed 3; mechanism in notes/garble-fuzz-divergences.md (Bug D).
|
||||
it('preserves the DECSC saved-cursor register across a hide/reveal boundary', async () => {
|
||||
const dims = { cols: 20, rows: 4 }
|
||||
// Hidden: write 'AB', DECSC saves cursor at r0c2, move to r3c9, write 'CD'.
|
||||
const hidden: MeteredChunk = {
|
||||
data: 'AB\x1b7\x1b[4;10HCD',
|
||||
seq: 'AB\x1b7\x1b[4;10HCD'.length,
|
||||
rawLength: 'AB\x1b7\x1b[4;10HCD'.length,
|
||||
domain: 0
|
||||
}
|
||||
// Tail: DECRC restores the saved cursor, write 'X' → live shows 'ABX'.
|
||||
const tail: MeteredChunk = {
|
||||
data: '\x1b8X',
|
||||
seq: hidden.seq! + 3,
|
||||
rawLength: 3,
|
||||
domain: 0
|
||||
}
|
||||
const reveal = await revealFromSnapshot(dims, [hidden, tail], 1)
|
||||
const live = await alwaysVisible(dims, [hidden, tail])
|
||||
// Pre-fix this showed 'XB' (DECRC landed at home) instead of live's 'ABX'.
|
||||
expect(reveal.rows).toEqual(live.rows)
|
||||
expect(reveal.cursor).toEqual(live.cursor)
|
||||
})
|
||||
|
||||
// ── Bug E regression guard: snapshot boundary mid-escape-sequence ──
|
||||
// A PTY read (one delivery record) can split an escape; the partial lives in
|
||||
// the emulator's parser, not the serialized screen, so the racing tail's
|
||||
// continuation rendered literal ('ABmCD'). FIXED: the emulator tracks the
|
||||
// unparsed trailing partial (terminal-partial-escape-tail.ts) and the
|
||||
// snapshot ships it out-of-band (pendingEscapeTailAnsi); the reveal replay
|
||||
// re-arms it as the final write. Found by fuzz seed 4; mechanism in
|
||||
// notes/garble-fuzz-divergences.md (Bug E).
|
||||
it('completes an escape sequence split across the hide/reveal boundary', async () => {
|
||||
const dims = { cols: 20, rows: 3 }
|
||||
// Hidden prefix ends mid-escape: 'AB' then ESC[3 (no final byte).
|
||||
const hidden: MeteredChunk = { data: 'AB\x1b[3', seq: 5, rawLength: 5, domain: 0 }
|
||||
// Tail completes it: 'm' → ESC[3m (italic), then 'CD' italic. Live: 'ABCD'.
|
||||
const tail: MeteredChunk = { data: 'mCD', seq: 8, rawLength: 3, domain: 0 }
|
||||
const reveal = await revealFromSnapshot(dims, [hidden, tail], 1)
|
||||
const live = await alwaysVisible(dims, [hidden, tail])
|
||||
// Pre-fix the reveal showed 'ABmCD' (the 'm' became literal).
|
||||
expect(reveal.rows).toEqual(live.rows)
|
||||
})
|
||||
|
||||
it(`stitches the racing tail losslessly across ${ITERATIONS} seeded hide/reveal scenarios`, async () => {
|
||||
let statsCompared = 0
|
||||
let statsSkippedScrolled = 0
|
||||
let statsForcedFresh = 0
|
||||
let statsKnownWrapBug = 0
|
||||
for (let i = 0; i < ITERATIONS; i++) {
|
||||
const seed = FIXED_SEED ?? 1 + i
|
||||
const scenario = buildScenario(seed)
|
||||
// Bug E (snapshot boundary mid-escape-sequence) is no longer tolerated:
|
||||
// the snapshot now carries the trailing partial escape out-of-band and
|
||||
// the reveal replay re-arms it last, so these scenarios must compare
|
||||
// clean like any other — a regression fails the corpus loudly.
|
||||
// Primary control: a snapshot of the WHOLE stream. Sharing the snapshot
|
||||
// machinery isolates the seq-reconciliation tail-stitch from
|
||||
// snapshot-fidelity gaps (fidelity suite's Bugs B/C). If snapshot-at-S +
|
||||
// tail differs from snapshot-of-everything, a byte was lost/duped/mis-
|
||||
// sliced. Also compare against always-visible where the two agree, to
|
||||
// catch a tail that diverges from live output.
|
||||
const [reveal, reference, live] = await Promise.all([
|
||||
revealFromSnapshot(scenario.dims, scenario.chunks, scenario.revealIdx),
|
||||
fullSnapshotReference(scenario.dims, scenario.chunks),
|
||||
alwaysVisible(scenario.dims, scenario.chunks)
|
||||
])
|
||||
if (reveal.forcedFreshRestore || reference.forcedFreshRestore) {
|
||||
statsForcedFresh += 1
|
||||
continue
|
||||
}
|
||||
if (reveal.knownSerializeWrapBug || reference.knownSerializeWrapBug) {
|
||||
statsKnownWrapBug += 1
|
||||
continue
|
||||
}
|
||||
if (reveal.alternate !== reference.alternate) {
|
||||
expect.fail(
|
||||
formatFailure(scenario, 'alt-screen-state', reference.alternate, reveal.alternate)
|
||||
)
|
||||
}
|
||||
// Scrolled scenarios: top-row survival is a snapshot-depth question
|
||||
// (fidelity suite), not seq reconciliation.
|
||||
if (reveal.scrolled || reference.scrolled) {
|
||||
statsSkippedScrolled += 1
|
||||
continue
|
||||
}
|
||||
statsCompared += 1
|
||||
// Seq-reconciliation gate (snapshot-fidelity-neutral).
|
||||
if (firstRowDiff(reveal.content, reference.content) !== null) {
|
||||
expect.fail(
|
||||
formatFailure(scenario, 'tail-stitch-content', reference.content, reveal.content)
|
||||
)
|
||||
}
|
||||
if (firstRowDiff(reveal.rows, reference.rows) !== null) {
|
||||
expect.fail(formatFailure(scenario, 'tail-stitch-visible', reference.rows, reveal.rows))
|
||||
}
|
||||
if (firstRowDiff(reveal.styles, reference.styles) !== null) {
|
||||
expect.fail(formatFailure(scenario, 'tail-stitch-styles', reference.styles, reveal.styles))
|
||||
}
|
||||
if (JSON.stringify(reveal.cursor) !== JSON.stringify(reference.cursor)) {
|
||||
expect.fail(formatFailure(scenario, 'tail-stitch-cursor', reference.cursor, reveal.cursor))
|
||||
}
|
||||
// Stronger gate: when a full-stream SNAPSHOT already matches the
|
||||
// always-visible LIVE screen (no fidelity gap in play), the mid-point
|
||||
// reveal must match live too — a true end-to-end garble check.
|
||||
if (
|
||||
!live.scrolled &&
|
||||
live.alternate === reference.alternate &&
|
||||
firstRowDiff(reference.rows, live.rows) === null &&
|
||||
firstRowDiff(reference.styles, live.styles) === null &&
|
||||
JSON.stringify(reference.cursor) === JSON.stringify(live.cursor)
|
||||
) {
|
||||
if (firstRowDiff(reveal.rows, live.rows) !== null) {
|
||||
expect.fail(formatFailure(scenario, 'live-visible', live.rows, reveal.rows))
|
||||
}
|
||||
if (firstRowDiff(reveal.styles, live.styles) !== null) {
|
||||
expect.fail(formatFailure(scenario, 'live-styles', live.styles, reveal.styles))
|
||||
}
|
||||
if (JSON.stringify(reveal.cursor) !== JSON.stringify(live.cursor)) {
|
||||
expect.fail(formatFailure(scenario, 'live-cursor', live.cursor, reveal.cursor))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Guard against a degenerate corpus that skips its way to green: a healthy
|
||||
// fraction of scenarios must reach the full non-scrolled comparison.
|
||||
if (FIXED_SEED === null) {
|
||||
expect(statsCompared).toBeGreaterThan(ITERATIONS * 0.2)
|
||||
}
|
||||
expect(
|
||||
statsCompared + statsSkippedScrolled + statsForcedFresh + statsKnownWrapBug
|
||||
).toBeLessThanOrEqual(ITERATIONS)
|
||||
}, 120_000)
|
||||
})
|
||||
@@ -0,0 +1,813 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { TerminalSideEffectFact } from '../../../../shared/terminal-side-effect-facts'
|
||||
import type { ParkedTerminalByteWatcherOptions } from './parked-terminal-byte-watcher'
|
||||
|
||||
const PTY_ID = 'pty-parked-1'
|
||||
const TAB_ID = 'tab-1'
|
||||
const WORKTREE_ID = 'repo-1::/tmp/wt-1'
|
||||
const LEAF_ID = '11111111-1111-4111-8111-111111111111'
|
||||
const PANE_KEY = `${TAB_ID}:${LEAF_ID}`
|
||||
const PANE_ID = 1
|
||||
// Mirrors PARKED_NOTIFICATION_GRACE_MS / AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS.
|
||||
const NOTIFICATION_GRACE_MS = 250
|
||||
|
||||
// Real agent-detection titles: braille spinner classifies as working,
|
||||
// the "✳ " Claude prefix as idle, and both as Claude agents.
|
||||
const WORKING_TITLE_OSC = '\x1b]0;⠋ Build feature\x07'
|
||||
const IDLE_TITLE = '✳ Build feature'
|
||||
const IDLE_TITLE_OSC = `\x1b]0;${IDLE_TITLE}\x07`
|
||||
|
||||
type MockStoreState = {
|
||||
settings: {
|
||||
theme?: 'system' | 'dark' | 'light'
|
||||
promptCacheTimerEnabled?: boolean
|
||||
experimentalTerminalAttention?: boolean
|
||||
terminalMainSideEffectAuthority?: boolean
|
||||
terminalHiddenDeliveryGate?: boolean
|
||||
notifications?: { enabled?: boolean; agentTaskComplete?: boolean }
|
||||
} | null
|
||||
setRuntimePaneTitle: ReturnType<typeof vi.fn>
|
||||
clearRuntimePaneTitle: ReturnType<typeof vi.fn>
|
||||
updateTabTitle: ReturnType<typeof vi.fn>
|
||||
markWorktreeUnread: ReturnType<typeof vi.fn>
|
||||
markTerminalTabUnread: ReturnType<typeof vi.fn>
|
||||
markTerminalPaneUnread: ReturnType<typeof vi.fn>
|
||||
setCacheTimerStartedAt: ReturnType<typeof vi.fn>
|
||||
observeTerminalGitHubPullRequestLink: ReturnType<typeof vi.fn>
|
||||
}
|
||||
|
||||
const dispatchTerminalNotification = vi.fn()
|
||||
let mockStoreState: MockStoreState
|
||||
|
||||
vi.mock('./use-notification-dispatch', () => ({
|
||||
dispatchTerminalNotification
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/terminal-theme', () => ({
|
||||
getSystemPrefersDark: () => true
|
||||
}))
|
||||
|
||||
vi.mock('@/store', () => ({
|
||||
useAppStore: {
|
||||
getState: () => mockStoreState
|
||||
}
|
||||
}))
|
||||
|
||||
function createMockStoreState(): MockStoreState {
|
||||
return {
|
||||
// Why: terminalMainSideEffectAuthority false pins the legacy byte-parser
|
||||
// mode this suite was written for; the authority-on fact-consumer mode is
|
||||
// covered by the dedicated describe block below.
|
||||
settings: {
|
||||
theme: 'system',
|
||||
promptCacheTimerEnabled: true,
|
||||
experimentalTerminalAttention: false,
|
||||
terminalMainSideEffectAuthority: false,
|
||||
notifications: { enabled: true, agentTaskComplete: true }
|
||||
},
|
||||
setRuntimePaneTitle: vi.fn(),
|
||||
clearRuntimePaneTitle: vi.fn(),
|
||||
updateTabTitle: vi.fn(),
|
||||
markWorktreeUnread: vi.fn(),
|
||||
markTerminalTabUnread: vi.fn(),
|
||||
markTerminalPaneUnread: vi.fn(),
|
||||
setCacheTimerStartedAt: vi.fn(),
|
||||
observeTerminalGitHubPullRequestLink: vi.fn()
|
||||
}
|
||||
}
|
||||
|
||||
describe('startParkedTerminalByteWatcher', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
let onData: ((payload: { id: string; data: string }) => void) | null = null
|
||||
|
||||
function emit(data: string): void {
|
||||
onData?.({ id: PTY_ID, data })
|
||||
}
|
||||
|
||||
// The output processor defers title/bell side effects onto a 0ms drain timer.
|
||||
function flushSideEffects(): void {
|
||||
vi.advanceTimersByTime(0)
|
||||
}
|
||||
|
||||
async function startWatcher(
|
||||
overrides: Partial<ParkedTerminalByteWatcherOptions> = {}
|
||||
): Promise<{ dispose: () => void; sendInput: ReturnType<typeof vi.fn> }> {
|
||||
const { startParkedTerminalByteWatcher } = await import('./parked-terminal-byte-watcher')
|
||||
const sendInput = vi.fn()
|
||||
const dispose = startParkedTerminalByteWatcher({
|
||||
ptyId: PTY_ID,
|
||||
tabId: TAB_ID,
|
||||
worktreeId: WORKTREE_ID,
|
||||
leafId: LEAF_ID,
|
||||
paneId: PANE_ID,
|
||||
sendInput,
|
||||
...overrides
|
||||
})
|
||||
return { dispose, sendInput }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.useFakeTimers()
|
||||
dispatchTerminalNotification.mockClear()
|
||||
onData = null
|
||||
mockStoreState = createMockStoreState()
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
...originalWindow,
|
||||
api: {
|
||||
pty: {
|
||||
onData: vi.fn((callback: (payload: { id: string; data: string }) => void) => {
|
||||
onData = callback
|
||||
return () => {}
|
||||
}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
ackData: vi.fn()
|
||||
}
|
||||
}
|
||||
} as unknown as typeof window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
it('forwards every OSC title in order to the pane and tab title store actions', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(`${WORKING_TITLE_OSC}${IDLE_TITLE_OSC}`)
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle.mock.calls).toEqual([
|
||||
[TAB_ID, PANE_ID, '⠋ Build feature'],
|
||||
[TAB_ID, PANE_ID, IDLE_TITLE]
|
||||
])
|
||||
expect(mockStoreState.updateTabTitle.mock.calls).toEqual([
|
||||
[TAB_ID, '⠋ Build feature'],
|
||||
[TAB_ID, IDLE_TITLE]
|
||||
])
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('drops the bare cursor-agent native title before it reaches the store', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('\x1b]0;Cursor Agent\x07')
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not drive the tab title when drivesTabTitle is false', async () => {
|
||||
const { dispose } = await startWatcher({ drivesTabTitle: false })
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
|
||||
expect(mockStoreState.updateTabTitle).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks unread on BEL and schedules the delayed terminal-bell OS notification', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('build finished\x07')
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
|
||||
expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID)
|
||||
expect(mockStoreState.markTerminalPaneUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'terminal-bell',
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks the exact pane unread when experimental terminal attention is enabled', async () => {
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
experimentalTerminalAttention: true
|
||||
}
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('\x07')
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.markTerminalPaneUnread).toHaveBeenCalledWith(PANE_KEY)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not treat an OSC-terminator BEL as a bell, even split across chunks', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('\x1b]0;par')
|
||||
emit('tial title\x07')
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('fires the prompt-cache timer and agent-task-complete on working→idle', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
|
||||
PANE_KEY,
|
||||
expect.any(Number)
|
||||
)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: IDLE_TITLE,
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('suppresses the completion OS notification when only terminal attention is on', async () => {
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
experimentalTerminalAttention: true,
|
||||
notifications: { enabled: true, agentTaskComplete: false }
|
||||
}
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: IDLE_TITLE,
|
||||
paneKey: PANE_KEY,
|
||||
suppressOsNotification: true
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('skips completion dispatch when tracking is fully disabled, keeping the cache timer', async () => {
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
experimentalTerminalAttention: false,
|
||||
notifications: { enabled: false }
|
||||
}
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
|
||||
PANE_KEY,
|
||||
expect.any(Number)
|
||||
)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('lets a same-burst completion supersede the pending bell OS notification', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
emit(`${IDLE_TITLE_OSC}\x07`)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 8)
|
||||
|
||||
// The bell still marks unread immediately; only the OS notification yields.
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
expect.objectContaining({ source: 'agent-task-complete' })
|
||||
)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('cancels the pending completion and clears the cache timer when working resumes', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
emit(WORKING_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('answers a DECSET 2031 subscribe split across chunks via sendInput', async () => {
|
||||
const { dispose, sendInput } = await startWatcher()
|
||||
|
||||
emit('\x1b[?20')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
|
||||
emit('31h')
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
// theme=system + prefers-dark → dark reply per terminal-color-scheme-protocol.
|
||||
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
|
||||
|
||||
emit('\x1b[?2031l')
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('stops answering DECSET 2031 after dispose', async () => {
|
||||
const { dispose, sendInput } = await startWatcher()
|
||||
|
||||
dispose()
|
||||
emit('\x1b[?2031h')
|
||||
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('observes GitHub PR links across chunk boundaries', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('PR: https://github.com/orca-dev/orca/pull/42')
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled()
|
||||
|
||||
emit('1\r\ndone')
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1)
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
expect.objectContaining({
|
||||
url: 'https://github.com/orca-dev/orca/pull/421',
|
||||
number: 421,
|
||||
slug: { owner: 'orca-dev', repo: 'orca' }
|
||||
})
|
||||
)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('fires completion when seeded with a working title and the agent goes idle while parked', async () => {
|
||||
// Why: the pane was working at park time; the watcher's fresh tracker
|
||||
// must be seeded or this working→idle transition can never fire.
|
||||
const { dispose } = await startWatcher({ initialTitle: '⠋ Build feature' })
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledTimes(1)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: IDLE_TITLE,
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('does not fire completion for an idle title without a seed or observed transition', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('clears the watcher-written runtime title slot on dispose', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
|
||||
|
||||
dispose()
|
||||
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID)
|
||||
})
|
||||
|
||||
it('leaves the runtime title slot alone on dispose when it never wrote one', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('plain output with no titles\r\n')
|
||||
flushSideEffects()
|
||||
|
||||
dispose()
|
||||
expect(mockStoreState.clearRuntimePaneTitle).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shutdown dispose cancels the armed completion timer and silences the final flush', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit(WORKING_TITLE_OSC)
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
|
||||
// Equivalent to shutdownWorktreeTerminals → disposeParkedTerminalWatchersForPtyIds.
|
||||
dispose()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
// The teardown flush that main emits after pty.kill must be a no-op.
|
||||
emit('final teardown flush\x07')
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('dispose unregisters the sidecar and cancels the pending bell notification', async () => {
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('\x07')
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1)
|
||||
|
||||
dispose()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
emit('\x07')
|
||||
flushSideEffects()
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Idempotent: a second dispose must not throw or clobber another watcher.
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('disposes the previous watcher when a new one starts for the same PTY', async () => {
|
||||
await startWatcher({ paneId: 1 })
|
||||
const second = await startWatcher({ paneId: 2 })
|
||||
|
||||
emit(IDLE_TITLE_OSC)
|
||||
flushSideEffects()
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledTimes(1)
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, 2, IDLE_TITLE)
|
||||
second.dispose()
|
||||
})
|
||||
|
||||
// ─── Main side-effect authority (terminal-side-effect-authority.md) ────
|
||||
//
|
||||
// With the kill switch on, the watcher must not register byte parsers —
|
||||
// main is the single byte parser and the watcher's policy block consumes
|
||||
// pty:sideEffect facts instead. The byte sidecar stays ONLY for the 2031
|
||||
// reply (query authority never moves to main); PR links arrive as facts.
|
||||
describe('with main side-effect authority on', () => {
|
||||
function enableMainAuthority(): void {
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
terminalMainSideEffectAuthority: true
|
||||
}
|
||||
}
|
||||
|
||||
async function dispatchFacts(
|
||||
facts: TerminalSideEffectFact[],
|
||||
options: { seq?: number; replay?: boolean } = {}
|
||||
): Promise<void> {
|
||||
const handler = await import('./terminal-side-effect-facts-handler')
|
||||
handler._dispatchTerminalSideEffectBatchForTest({
|
||||
ptyId: PTY_ID,
|
||||
seq: options.seq ?? 0,
|
||||
...(options.replay ? { replay: true } : {}),
|
||||
facts
|
||||
})
|
||||
}
|
||||
|
||||
/** Feed chunks the way OrcaRuntimeService.onPtyData does: OSC 9999 strip,
|
||||
* shared title tracker, one fact batch per chunk — the main half of the
|
||||
* migration-safety parity check. */
|
||||
async function emitViaMainTrackerFacts(chunks: string[]): Promise<void> {
|
||||
const { createAgentStatusOscProcessor } = await import('../../../../shared/agent-status-osc')
|
||||
const { createTerminalTitleTracker } =
|
||||
await import('../../../../shared/terminal-output-side-effects')
|
||||
const handler = await import('./terminal-side-effect-facts-handler')
|
||||
const processStatusChunk = createAgentStatusOscProcessor()
|
||||
let pending: TerminalSideEffectFact[] = []
|
||||
const tracker = createTerminalTitleTracker({
|
||||
onTitle: (normalizedTitle, rawTitle) =>
|
||||
pending.push({ kind: 'title', normalizedTitle, rawTitle }),
|
||||
onAgentBecameWorking: () => pending.push({ kind: 'agent-working' }),
|
||||
onAgentBecameIdle: (title) => pending.push({ kind: 'agent-idle', title }),
|
||||
onAgentExited: () => pending.push({ kind: 'agent-exited' }),
|
||||
onBell: () => pending.push({ kind: 'bell' })
|
||||
})
|
||||
let seq = 0
|
||||
for (const chunk of chunks) {
|
||||
seq += chunk.length
|
||||
tracker.handleChunk(processStatusChunk(chunk).cleanData)
|
||||
if (pending.length > 0) {
|
||||
handler._dispatchTerminalSideEffectBatchForTest({ ptyId: PTY_ID, seq, facts: pending })
|
||||
pending = []
|
||||
}
|
||||
}
|
||||
tracker.dispose()
|
||||
}
|
||||
|
||||
type RecordedCall = [string, ...unknown[]]
|
||||
|
||||
/** Wrap the policy-visible store actions so byte mode and fact mode can be
|
||||
* compared as one ordered outcome sequence. Timestamps are masked. */
|
||||
function recordPolicyOutcomes(): RecordedCall[] {
|
||||
const calls: RecordedCall[] = []
|
||||
mockStoreState.setRuntimePaneTitle.mockImplementation((...args: unknown[]) => {
|
||||
calls.push(['setRuntimePaneTitle', ...args])
|
||||
})
|
||||
mockStoreState.updateTabTitle.mockImplementation((...args: unknown[]) => {
|
||||
calls.push(['updateTabTitle', ...args])
|
||||
})
|
||||
mockStoreState.markWorktreeUnread.mockImplementation((...args: unknown[]) => {
|
||||
calls.push(['markWorktreeUnread', ...args])
|
||||
})
|
||||
mockStoreState.markTerminalTabUnread.mockImplementation((...args: unknown[]) => {
|
||||
calls.push(['markTerminalTabUnread', ...args])
|
||||
})
|
||||
mockStoreState.markTerminalPaneUnread.mockImplementation((...args: unknown[]) => {
|
||||
calls.push(['markTerminalPaneUnread', ...args])
|
||||
})
|
||||
mockStoreState.setCacheTimerStartedAt.mockImplementation((key: unknown, at: unknown) => {
|
||||
calls.push(['setCacheTimerStartedAt', key, typeof at === 'number' ? '<ts>' : at])
|
||||
})
|
||||
dispatchTerminalNotification.mockImplementation((...args: unknown[]) => {
|
||||
calls.push(['dispatchTerminalNotification', ...args])
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
it('does not consume bytes: a byte BEL produces no unread or notification', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
emit('build finished\x07')
|
||||
flushSideEffects()
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.markTerminalTabUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('applies bell facts with the byte-mode policy: unread now, OS notification delayed', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts([{ kind: 'bell' }])
|
||||
|
||||
expect(mockStoreState.markWorktreeUnread).toHaveBeenCalledWith(WORKTREE_ID)
|
||||
expect(mockStoreState.markTerminalTabUnread).toHaveBeenCalledWith(TAB_ID)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'terminal-bell',
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('fires the cache timer and completion from working→idle facts', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts([
|
||||
{ kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' },
|
||||
{ kind: 'agent-working' }
|
||||
])
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
|
||||
|
||||
await dispatchFacts([
|
||||
{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE },
|
||||
{ kind: 'agent-idle', title: IDLE_TITLE }
|
||||
])
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(
|
||||
PANE_KEY,
|
||||
expect.any(Number)
|
||||
)
|
||||
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
expect(dispatchTerminalNotification).toHaveBeenCalledWith(WORKTREE_ID, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: IDLE_TITLE,
|
||||
paneKey: PANE_KEY
|
||||
})
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('clears state without completion attention for stale-derived idle facts', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts([
|
||||
{ kind: 'title', normalizedTitle: '⠋ Build feature', rawTitle: '⠋ Build feature' },
|
||||
{ kind: 'agent-working' }
|
||||
])
|
||||
// Main's unthrottled 3s stale-title rewrite: titles/cache clear, but a
|
||||
// merely-paused agent must not earn a task-complete notification.
|
||||
await dispatchFacts([
|
||||
{
|
||||
kind: 'title',
|
||||
normalizedTitle: 'Build feature',
|
||||
rawTitle: 'Build feature',
|
||||
staleWorkingTitleClear: true
|
||||
},
|
||||
{ kind: 'agent-idle', title: 'Build feature', staleWorkingTitleClear: true }
|
||||
])
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenLastCalledWith(
|
||||
TAB_ID,
|
||||
PANE_ID,
|
||||
'Build feature'
|
||||
)
|
||||
expect(mockStoreState.setCacheTimerStartedAt).toHaveBeenLastCalledWith(PANE_KEY, null)
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('replay batches restore the title only — attention facts never replay', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts(
|
||||
[
|
||||
{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE },
|
||||
{ kind: 'bell' },
|
||||
{ kind: 'agent-idle', title: IDLE_TITLE }
|
||||
],
|
||||
{ replay: true }
|
||||
)
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(mockStoreState.setCacheTimerStartedAt).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('answers DECSET 2031 from the main 2031-subscribe fact, never the byte scan', async () => {
|
||||
// Why: with the hidden-delivery gate on (default), parked PTY bytes are
|
||||
// dropped in main — the fact is the only 2031 signal, and the byte
|
||||
// sidecar must NOT exist (its registration would re-enable delivery).
|
||||
enableMainAuthority()
|
||||
const { dispose, sendInput } = await startWatcher()
|
||||
|
||||
emit('\x1b[?2031h')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
|
||||
await dispatchFacts([{ kind: '2031-subscribe' }])
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
|
||||
|
||||
// Why: pr-link facts arrive on the channel; byte-scanning here too
|
||||
// would observe every link twice.
|
||||
emit('PR: https://github.com/orca-dev/orca/pull/42\r\n')
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('marks the PTY hidden for delivery on start and clears it on dispose', async () => {
|
||||
enableMainAuthority()
|
||||
const setHiddenRendererPty = vi.fn()
|
||||
;(
|
||||
window as unknown as { api: { pty: Record<string, unknown> } }
|
||||
).api.pty.setHiddenRendererPty = setHiddenRendererPty
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
expect(setHiddenRendererPty).toHaveBeenCalledWith(PTY_ID, true)
|
||||
|
||||
dispose()
|
||||
// Why: the unhide must land before reveal re-registers pane handlers —
|
||||
// the watcher registry disposes watchers before the remount effect runs.
|
||||
expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false)
|
||||
})
|
||||
|
||||
it('keeps the byte 2031 responder and no hidden bit when the gate kill switch is off', async () => {
|
||||
enableMainAuthority()
|
||||
mockStoreState.settings = {
|
||||
...mockStoreState.settings,
|
||||
terminalHiddenDeliveryGate: false
|
||||
} as MockStoreState['settings']
|
||||
const setHiddenRendererPty = vi.fn()
|
||||
;(
|
||||
window as unknown as { api: { pty: Record<string, unknown> } }
|
||||
).api.pty.setHiddenRendererPty = setHiddenRendererPty
|
||||
const { dispose, sendInput } = await startWatcher()
|
||||
|
||||
// Gate off — bytes keep flowing, so the split-chunk byte scan answers.
|
||||
emit('\x1b[?20')
|
||||
expect(sendInput).not.toHaveBeenCalled()
|
||||
emit('31h')
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
expect(sendInput).toHaveBeenCalledWith('\x1b[?997;1n')
|
||||
|
||||
// Why: a 2031-subscribe fact must not double-fire the reply in byte
|
||||
// mode — exactly one responder owns the answer at any time.
|
||||
await dispatchFacts([{ kind: '2031-subscribe' }])
|
||||
expect(sendInput).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(setHiddenRendererPty).not.toHaveBeenCalled()
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('observes PR links from pr-link facts with worktree attribution', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
const link = {
|
||||
url: 'https://github.com/orca-dev/orca/pull/421',
|
||||
slug: { owner: 'orca-dev', repo: 'orca' },
|
||||
number: 421
|
||||
}
|
||||
await dispatchFacts([{ kind: 'pr-link', link }])
|
||||
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledTimes(1)
|
||||
expect(mockStoreState.observeTerminalGitHubPullRequestLink).toHaveBeenCalledWith(
|
||||
WORKTREE_ID,
|
||||
link
|
||||
)
|
||||
dispose()
|
||||
})
|
||||
|
||||
it('dispose unregisters the fact consumer and clears a written title slot', async () => {
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
|
||||
await dispatchFacts([{ kind: 'title', normalizedTitle: IDLE_TITLE, rawTitle: IDLE_TITLE }])
|
||||
expect(mockStoreState.setRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID, IDLE_TITLE)
|
||||
|
||||
dispose()
|
||||
expect(mockStoreState.clearRuntimePaneTitle).toHaveBeenCalledWith(TAB_ID, PANE_ID)
|
||||
|
||||
await dispatchFacts([{ kind: 'bell' }])
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS * 4)
|
||||
expect(mockStoreState.markWorktreeUnread).not.toHaveBeenCalled()
|
||||
expect(dispatchTerminalNotification).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// The key migration-safety check: the same bytes produce the identical
|
||||
// ordered store outcome whether the watcher parses them directly (kill
|
||||
// switch off) or consumes main-derived facts over the channel.
|
||||
it('produces identical store outcomes via the channel as the byte parser did', async () => {
|
||||
const fixtureChunks = [WORKING_TITLE_OSC, 'agent response body\r\n', `${IDLE_TITLE_OSC}\x07`]
|
||||
|
||||
// Pass 1: legacy byte-parser mode.
|
||||
const byteModeCalls = recordPolicyOutcomes()
|
||||
{
|
||||
const { dispose } = await startWatcher()
|
||||
for (const chunk of fixtureChunks) {
|
||||
emit(chunk)
|
||||
flushSideEffects()
|
||||
}
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
dispose()
|
||||
}
|
||||
|
||||
// Pass 2: fresh modules/store, authority on, facts derived by the
|
||||
// shared main tracker from the same bytes.
|
||||
vi.resetModules()
|
||||
mockStoreState = createMockStoreState()
|
||||
dispatchTerminalNotification.mockReset()
|
||||
const factModeCalls = recordPolicyOutcomes()
|
||||
{
|
||||
enableMainAuthority()
|
||||
const { dispose } = await startWatcher()
|
||||
await emitViaMainTrackerFacts(fixtureChunks)
|
||||
vi.advanceTimersByTime(NOTIFICATION_GRACE_MS)
|
||||
dispose()
|
||||
}
|
||||
|
||||
expect(byteModeCalls.length).toBeGreaterThan(0)
|
||||
expect(factModeCalls).toEqual(byteModeCalls)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Parked terminal side-effect watcher.
|
||||
*
|
||||
* Why: parking unmounts the TerminalPane subtree, which tears down the pane's
|
||||
* side-effect consumers — the parked tab's only source of bell, title,
|
||||
* agent-completion, and PR-link policy. (Losing them is the gap that sank the
|
||||
* first parking attempt.) Under main side-effect authority the watcher is
|
||||
* purely fact-driven (one pty:sideEffect consumer, no byte parsing); with the
|
||||
* kill switch off it registers the legacy byte parsers on the dispatcher
|
||||
* sidecar channel. DECSET 2031 ownership follows the hidden-delivery gate:
|
||||
* gate ON answers from main's '2031-subscribe' fact (no parked bytes exist),
|
||||
* gate OFF keeps the byte sidecar (parked-terminal-mode2031-responder.ts).
|
||||
* Either way the reply is sent from the renderer — query authority never
|
||||
* moves to main. See docs/reference/terminal-hidden-view-parking.md and
|
||||
* docs/reference/terminal-side-effect-authority.md.
|
||||
*/
|
||||
import { isClaudeAgent } from '../../../../shared/agent-detection'
|
||||
import { makePaneKey } from '../../../../shared/stable-pane-id'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
mode2031SequenceFor,
|
||||
resolveTerminalColorSchemeMode
|
||||
} from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import { createTerminalGitHubPRLinkDetector } from '../../../../shared/terminal-github-pr-link-detector'
|
||||
import { getSystemPrefersDark } from '@/lib/terminal-theme'
|
||||
import {
|
||||
AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS,
|
||||
isAgentTaskCompleteOsNotificationEnabledFromState,
|
||||
isAgentTaskCompleteTrackingEnabledFromState
|
||||
} from './agent-task-complete-policy'
|
||||
import { startParkedTerminalMode2031Responder } from './parked-terminal-mode2031-responder'
|
||||
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
|
||||
import { createPtyOutputProcessor } from './pty-transport'
|
||||
import { isRendererHiddenPtyDeliveryGateEnabled } from './terminal-hidden-delivery-gate'
|
||||
import {
|
||||
isMainTerminalSideEffectAuthorityForPty,
|
||||
registerTerminalSideEffectFactConsumer
|
||||
} from './terminal-side-effect-facts-handler'
|
||||
import { dispatchTerminalNotification } from './use-notification-dispatch'
|
||||
import { acquireHiddenRendererPtyDeliveryClaim } from './pty-renderer-delivery-claims'
|
||||
|
||||
// Why: mirrors AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS in pty-connection.ts.
|
||||
// The parked path must keep the live path's BEL-vs-completion race window so
|
||||
// notification behavior is identical whether a tab is parked or mounted.
|
||||
const PARKED_NOTIFICATION_GRACE_MS = AGENT_TASK_COMPLETE_NOTIFICATION_GRACE_MS
|
||||
|
||||
type StoreState = ReturnType<typeof useAppStore.getState>
|
||||
|
||||
function isAgentTaskCompleteOsNotificationEnabled(state: StoreState): boolean {
|
||||
return isAgentTaskCompleteOsNotificationEnabledFromState(state)
|
||||
}
|
||||
|
||||
function isAgentTaskCompleteTrackingEnabled(state: StoreState): boolean {
|
||||
return isAgentTaskCompleteTrackingEnabledFromState(state)
|
||||
}
|
||||
|
||||
export type ParkedTerminalByteWatcherOptions = {
|
||||
ptyId: string
|
||||
tabId: string
|
||||
worktreeId: string
|
||||
/** Stable terminal-layout leaf UUID. Combined with tabId into the paneKey
|
||||
* used for cache-timer, unread, and notification attribution. */
|
||||
leafId: string
|
||||
/** PaneManager pane id the unmounted pane used. Runtime pane titles are
|
||||
* keyed by it, so the watcher must write the slot the live path wrote —
|
||||
* a different id would leave a stale (possibly "working") title behind. */
|
||||
paneId: number
|
||||
/** Whether this PTY's pane was the tab's active split pane. Mirrors the
|
||||
* live path, where only the focused split drives the tab title. */
|
||||
drivesTabTitle?: boolean
|
||||
/** The pane's last known runtime title at park time. Seeds the agent
|
||||
* tracker so an agent that was working when the pane unmounted still
|
||||
* fires its completion when it goes idle while parked. */
|
||||
initialTitle?: string
|
||||
/** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */
|
||||
sendInput: (data: string) => void
|
||||
}
|
||||
|
||||
const parkedWatcherDisposersByPtyId = new Map<string, () => void>()
|
||||
|
||||
export function startParkedTerminalByteWatcher(
|
||||
options: ParkedTerminalByteWatcherOptions
|
||||
): () => void {
|
||||
const { ptyId, tabId, worktreeId, paneId, sendInput } = options
|
||||
const drivesTabTitle = options.drivesTabTitle ?? true
|
||||
const paneKey = makePaneKey(tabId, options.leafId)
|
||||
|
||||
// Why: one watcher per PTY. A stale watcher from a previous park cycle would
|
||||
// double-fire bell/completion side effects for the same bytes.
|
||||
parkedWatcherDisposersByPtyId.get(ptyId)?.()
|
||||
|
||||
let disposed = false
|
||||
let pendingBellNotification = false
|
||||
// Why: a watcher-written runtime title (especially into a negative fallback
|
||||
// slot) has no live pane to overwrite it after reveal; a stale 'working'
|
||||
// entry would pin worktree status forever. Track writes so dispose can
|
||||
// clear exactly the slot this watcher touched.
|
||||
let wroteRuntimeTitleSlot = false
|
||||
let bellNotificationTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let agentTaskCompleteTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const clearBellNotificationTimer = (): void => {
|
||||
if (bellNotificationTimer !== null) {
|
||||
clearTimeout(bellNotificationTimer)
|
||||
bellNotificationTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
const clearAgentTaskCompleteTimer = (): void => {
|
||||
if (agentTaskCompleteTimer !== null) {
|
||||
clearTimeout(agentTaskCompleteTimer)
|
||||
agentTaskCompleteTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Why: like the live path, a BEL OS notification only yields when the
|
||||
// pending completion would itself produce an OS notification.
|
||||
const hasPendingAgentTaskCompleteNotification = (): boolean =>
|
||||
agentTaskCompleteTimer !== null &&
|
||||
isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState())
|
||||
|
||||
const scheduleTerminalBellNotification = (): void => {
|
||||
if (bellNotificationTimer !== null) {
|
||||
return
|
||||
}
|
||||
bellNotificationTimer = setTimeout(() => {
|
||||
bellNotificationTimer = null
|
||||
if (disposed) {
|
||||
pendingBellNotification = false
|
||||
return
|
||||
}
|
||||
if (hasPendingAgentTaskCompleteNotification()) {
|
||||
return
|
||||
}
|
||||
pendingBellNotification = false
|
||||
dispatchTerminalNotification(worktreeId, { source: 'terminal-bell', paneKey })
|
||||
}, PARKED_NOTIFICATION_GRACE_MS)
|
||||
}
|
||||
|
||||
// Why: one policy block for both consumption modes — byte parsing (kill
|
||||
// switch off) and pty:sideEffect facts (main authority on). The semantics
|
||||
// must be identical or flipping the switch changes notification behavior.
|
||||
const sideEffectCallbacks = {
|
||||
onTitleChange: (title: string): void => {
|
||||
const state = useAppStore.getState()
|
||||
wroteRuntimeTitleSlot = true
|
||||
state.setRuntimePaneTitle(tabId, paneId, title)
|
||||
if (drivesTabTitle) {
|
||||
state.updateTabTitle(tabId, title)
|
||||
}
|
||||
},
|
||||
onBell: (): void => {
|
||||
const state = useAppStore.getState()
|
||||
state.markWorktreeUnread(worktreeId)
|
||||
state.markTerminalTabUnread(tabId)
|
||||
if (state.settings?.experimentalTerminalAttention === true) {
|
||||
state.markTerminalPaneUnread(paneKey)
|
||||
}
|
||||
// Why: agent CLIs often emit BEL in the same completion burst as their
|
||||
// working→idle title change. Delay only the OS notification so the
|
||||
// richer agent-task-complete notification can win (live-path parity).
|
||||
pendingBellNotification = true
|
||||
if (!hasPendingAgentTaskCompleteNotification()) {
|
||||
scheduleTerminalBellNotification()
|
||||
}
|
||||
},
|
||||
onAgentBecameIdle: (title: string, meta?: { staleWorkingTitleClear?: boolean }): void => {
|
||||
// Why: stale-derived idles come from main's unthrottled 3s timer, not
|
||||
// observed bytes — clear session state, never schedule the completion
|
||||
// notification a merely-paused agent did not earn (live-path parity).
|
||||
if (meta?.staleWorkingTitleClear) {
|
||||
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
return
|
||||
}
|
||||
const state = useAppStore.getState()
|
||||
// Why: mirrors pty-connection — null settings means "not hydrated yet";
|
||||
// a spurious timestamp is harmless while a dropped one loses the timer.
|
||||
if (
|
||||
isClaudeAgent(title) &&
|
||||
(state.settings === null || state.settings.promptCacheTimerEnabled)
|
||||
) {
|
||||
state.setCacheTimerStartedAt(paneKey, Date.now())
|
||||
}
|
||||
if (!isAgentTaskCompleteTrackingEnabled(state)) {
|
||||
return
|
||||
}
|
||||
clearAgentTaskCompleteTimer()
|
||||
agentTaskCompleteTimer = setTimeout(() => {
|
||||
agentTaskCompleteTimer = null
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
// Why: the completion supersedes a concurrent BEL so each completion
|
||||
// burst yields exactly one OS notification, same as the live path.
|
||||
pendingBellNotification = false
|
||||
clearBellNotificationTimer()
|
||||
dispatchTerminalNotification(worktreeId, {
|
||||
source: 'agent-task-complete',
|
||||
terminalTitle: title,
|
||||
paneKey,
|
||||
...(isAgentTaskCompleteOsNotificationEnabled(useAppStore.getState())
|
||||
? {}
|
||||
: { suppressOsNotification: true })
|
||||
})
|
||||
}, PARKED_NOTIFICATION_GRACE_MS)
|
||||
},
|
||||
onAgentBecameWorking: (): void => {
|
||||
// Why: a new API call refreshes the prompt-cache TTL, so clear any
|
||||
// running countdown; it restarts when the agent next becomes idle.
|
||||
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
clearAgentTaskCompleteTimer()
|
||||
if (pendingBellNotification) {
|
||||
scheduleTerminalBellNotification()
|
||||
}
|
||||
},
|
||||
onAgentExited: (): void => {
|
||||
// Why: title reverting to a plain shell means the agent session ended;
|
||||
// a stale countdown must not survive in the sidebar while parked.
|
||||
useAppStore.getState().setCacheTimerStartedAt(paneKey, null)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: parking eligibility excludes remote-runtime and SSH PTYs, so every
|
||||
// watched PTY's bytes transit local main — when the authority switch is on,
|
||||
// the watcher must NOT register byte parsers (the fact consumer below is
|
||||
// the single policy consumer; double registration would double-fire bells).
|
||||
const mainSideEffectAuthority = isMainTerminalSideEffectAuthorityForPty({
|
||||
settings: useAppStore.getState().settings,
|
||||
runtimeEnvironmentId: null
|
||||
})
|
||||
// Why: under the Phase-4 gate a parked PTY needs no renderer bytes at all —
|
||||
// facts carry side effects and the reveal remount restores from the model
|
||||
// snapshot. Decided once at watcher start: it picks which 2031 responder
|
||||
// (byte sidecar vs fact reply) exists, so it must never flip per chunk.
|
||||
const hiddenDeliveryGateActive =
|
||||
mainSideEffectAuthority &&
|
||||
isRendererHiddenPtyDeliveryGateEnabled(useAppStore.getState().settings)
|
||||
|
||||
const sendMode2031Reply = (): void => {
|
||||
const settings = useAppStore.getState().settings
|
||||
sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())))
|
||||
}
|
||||
|
||||
// Why (byte-parser mode only): reuse the transport's output processor so
|
||||
// the parked path keeps the exact live-path parsing semantics — all-titles
|
||||
// ordering, normalization, the cursor-agent native-title drop, the
|
||||
// OSC-aware stateful bell detector, and the working/idle agent tracker.
|
||||
// initialAgentTitle: an agent already working at park time must still
|
||||
// produce a working→idle transition; main's continuous tracker covers this
|
||||
// in fact-consumer mode.
|
||||
const processor = mainSideEffectAuthority
|
||||
? null
|
||||
: createPtyOutputProcessor({
|
||||
...(options.initialTitle !== undefined ? { initialAgentTitle: options.initialTitle } : {}),
|
||||
...sideEffectCallbacks
|
||||
})
|
||||
// Why (byte-parser mode only): with main authority, pr-link facts arrive on
|
||||
// the channel below; byte-scanning too would observe every link twice.
|
||||
const observeTerminalGitHubPRLink = mainSideEffectAuthority
|
||||
? null
|
||||
: createTerminalGitHubPRLinkDetector()
|
||||
const unregisterFactConsumer = mainSideEffectAuthority
|
||||
? registerTerminalSideEffectFactConsumer({
|
||||
ptyId,
|
||||
// Why: no title snapshot on park — the pane's runtime title slot is
|
||||
// already current at park time, exactly like the byte-parser mode.
|
||||
callbacks: {
|
||||
...sideEffectCallbacks,
|
||||
onPrLink: (link) =>
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link),
|
||||
// Why (gate mode only): bytes never arrive while gated, so the 2031
|
||||
// subscribe arrives as a main-tracker fact instead of a byte scan.
|
||||
// The reply is still sent from here — query authority stays with
|
||||
// the view/watcher (model/view contract invariant 6).
|
||||
...(hiddenDeliveryGateActive ? { onMode2031Subscribe: sendMode2031Reply } : {})
|
||||
}
|
||||
})
|
||||
: null
|
||||
|
||||
// Why: no xterm exists while parked, so nothing answers a DECSET 2031
|
||||
// subscription. With the hidden-delivery gate OFF the byte responder is the
|
||||
// parked path's only byte consumer under main authority. With the gate ON it
|
||||
// must NOT register: its subscribeToPtyData sidecar doubles as a
|
||||
// delivery-interest signal that would force-feed bytes to the gated PTY —
|
||||
// the fact callback above replaces the byte scan.
|
||||
const stopMode2031Responder = hiddenDeliveryGateActive
|
||||
? null
|
||||
: startParkedTerminalMode2031Responder({ ptyId, sendInput })
|
||||
|
||||
// Why: parked tabs are the canonical hidden view — mark the PTY gated so
|
||||
// main stops renderer byte delivery; dispose clears the bit before the
|
||||
// reveal remount re-registers pane handlers (existing dispose ordering).
|
||||
const releaseHiddenDeliveryClaim = hiddenDeliveryGateActive
|
||||
? acquireHiddenRendererPtyDeliveryClaim(ptyId)
|
||||
: null
|
||||
|
||||
// Why (byte-parser mode only): with main authority the watcher consumes
|
||||
// pty:sideEffect facts exclusively and registers NO byte parsers here —
|
||||
// title/bell/agent parsing and the PR-link scan would double-fire policy.
|
||||
const unsubscribeByteParsers =
|
||||
processor === null
|
||||
? null
|
||||
: subscribeToPtyData(ptyId, (data) => {
|
||||
// Why: empty pane callbacks — the watcher wants only the parser
|
||||
// side effects; there is no xterm to deliver bytes to.
|
||||
processor.processData(data, {})
|
||||
if (observeTerminalGitHubPRLink) {
|
||||
for (const link of observeTerminalGitHubPRLink(data)) {
|
||||
useAppStore.getState().observeTerminalGitHubPullRequestLink(worktreeId, link)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const dispose = (): void => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
// Why: unhide BEFORE the reveal remount registers pane handlers — main
|
||||
// resumes delivery and (if bytes were dropped) emits the restore marker
|
||||
// the remounted pane's restore machinery consumes.
|
||||
releaseHiddenDeliveryClaim?.()
|
||||
stopMode2031Responder?.()
|
||||
unsubscribeByteParsers?.()
|
||||
unregisterFactConsumer?.()
|
||||
// Why: cancels the deferred side-effect drain, stale-title timer, and
|
||||
// tracker/bell-detector state so the watcher cannot fire after the
|
||||
// revealed pane's live parsers take over.
|
||||
processor?.clearAccumulatedState()
|
||||
clearBellNotificationTimer()
|
||||
clearAgentTaskCompleteTimer()
|
||||
pendingBellNotification = false
|
||||
// Why: the store merge never deletes title slots, so a watcher-written
|
||||
// entry would strand after reveal (the revealing pane re-registers under
|
||||
// its own pane id) and could pin worktree status 'working'. The revealed
|
||||
// pane repopulates its slot via its own title flow.
|
||||
if (wroteRuntimeTitleSlot) {
|
||||
wroteRuntimeTitleSlot = false
|
||||
useAppStore.getState().clearRuntimePaneTitle(tabId, paneId)
|
||||
}
|
||||
if (parkedWatcherDisposersByPtyId.get(ptyId) === dispose) {
|
||||
parkedWatcherDisposersByPtyId.delete(ptyId)
|
||||
}
|
||||
}
|
||||
parkedWatcherDisposersByPtyId.set(ptyId, dispose)
|
||||
return dispose
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* DECSET 2031 color-scheme responder for parked terminals (byte-scan mode).
|
||||
*
|
||||
* Why a dedicated byte sidecar: no xterm exists while a tab is parked, so
|
||||
* nothing answers a TUI's mode-2031 theme subscription. Query authority stays
|
||||
* with the view/watcher (model/view contract invariant 6), so this reply can
|
||||
* never move to main. Phase 4: this subscribeToPtyData registration doubles
|
||||
* as a delivery-interest signal, so it is only used while the hidden-delivery
|
||||
* gate is OFF — gated parked PTYs answer from the main tracker's
|
||||
* '2031-subscribe' fact instead (parked-terminal-byte-watcher.ts).
|
||||
*
|
||||
* Survives Phase 6 (skip-grammar deletion): mounted switch-off hidden panes
|
||||
* answer 2031 from xterm once the background queue drains, but a PARKED tab
|
||||
* has no xterm in any switch-off mode, and the '2031-subscribe' fact is only
|
||||
* consumed while the gate is ON — this sidecar stays the only answerer here.
|
||||
*/
|
||||
import {
|
||||
mode2031SequenceFor,
|
||||
resolveTerminalColorSchemeMode,
|
||||
scanMode2031Sequences
|
||||
} from '../../../../shared/terminal-color-scheme-protocol'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getSystemPrefersDark } from '@/lib/terminal-theme'
|
||||
import { subscribeToPtyData } from './pty-data-sidecar-subscriptions'
|
||||
|
||||
export type ParkedTerminalMode2031ResponderOptions = {
|
||||
ptyId: string
|
||||
/** Out-of-band reply channel to the PTY (mode-2031 color-scheme answers). */
|
||||
sendInput: (data: string) => void
|
||||
}
|
||||
|
||||
export function startParkedTerminalMode2031Responder(
|
||||
options: ParkedTerminalMode2031ResponderOptions
|
||||
): () => void {
|
||||
const { ptyId, sendInput } = options
|
||||
// Why: a DECSET 2031 subscribe can be split across PTY chunks; the scan
|
||||
// carries a bounded tail between chunks so split sequences still match.
|
||||
let scanTail = ''
|
||||
return subscribeToPtyData(ptyId, (data) => {
|
||||
const scan = scanMode2031Sequences(scanTail, data)
|
||||
scanTail = scan.tail
|
||||
if (!scan.subscribe) {
|
||||
return
|
||||
}
|
||||
// Why: reply with the resolved theme so TUIs that subscribe while parked
|
||||
// still learn it before the pane is ever revealed.
|
||||
const settings = useAppStore.getState().settings
|
||||
sendInput(mode2031SequenceFor(resolveTerminalColorSchemeMode(settings, getSystemPrefersDark())))
|
||||
})
|
||||
}
|
||||
@@ -76,4 +76,10 @@ export type PtyConnectionDeps = {
|
||||
setCacheTimerStartedAt: (key: string, ts: number | null) => void
|
||||
syncPanePtyLayoutBinding: (paneId: number, ptyId: string | null) => void
|
||||
clearExitedPanePtyLayoutBinding: (paneId: number, exitedPtyId: string) => void
|
||||
/** Records a DECSET 2031 subscription answered from main's
|
||||
* '2031-subscribe' fact, mirroring the xterm CSI handler's registry write
|
||||
* (paneMode2031 + last replied theme) so later theme flips push CSI 997.
|
||||
* The reply itself is sent by the fact handler — query authority stays
|
||||
* with the view (model/view contract invariant 6). */
|
||||
recordPaneMode2031Subscription?: (paneId: number, repliedMode: 'dark' | 'light') => void
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,14 @@
|
||||
import { acquirePtyDeliveryInterest } from './pty-delivery-interest'
|
||||
import { ensurePtyDispatcher, ptyDataSidecars } from './pty-dispatcher'
|
||||
|
||||
/** Register a side-channel data watcher for a PTY without taking ownership
|
||||
* of the primary handler. Returns an unsubscribe fn. */
|
||||
export function subscribeToPtyData(ptyId: string, watcher: (data: string) => void): () => void {
|
||||
ensurePtyDispatcher()
|
||||
// Why: a sidecar is, by definition, a raw-byte consumer — its registration
|
||||
// doubles as the delivery-interest signal that suppresses main's
|
||||
// hidden-delivery gate (terminal-side-effect-authority.md, Open Items).
|
||||
const releaseDeliveryInterest = acquirePtyDeliveryInterest(ptyId)
|
||||
let set = ptyDataSidecars.get(ptyId)
|
||||
if (!set) {
|
||||
set = new Set()
|
||||
@@ -11,6 +16,7 @@ export function subscribeToPtyData(ptyId: string, watcher: (data: string) => voi
|
||||
}
|
||||
set.add(watcher)
|
||||
return () => {
|
||||
releaseDeliveryInterest()
|
||||
const current = ptyDataSidecars.get(ptyId)
|
||||
if (!current) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Renderer-side delivery-interest registry for the Phase-4 hidden-delivery
|
||||
* gate (docs/reference/terminal-side-effect-authority.md, Open Items).
|
||||
*
|
||||
* Why: main only drops hidden PTY byte delivery while NO renderer party needs
|
||||
* raw bytes. Dispatcher sidecars and eager pre-mount buffers register
|
||||
* interest here; ref-counted so main sees only the 0↔1 transitions.
|
||||
*/
|
||||
const ptyDeliveryInterestRefCounts = new Map<string, number>()
|
||||
|
||||
function sendPtyDeliveryInterest(ptyId: string, interested: boolean): void {
|
||||
;(globalThis as { window?: Window }).window?.api?.pty?.setPtyDeliveryInterest?.(ptyId, interested)
|
||||
}
|
||||
|
||||
/** Acquire a delivery-interest hold for a PTY. Returns a release fn that is
|
||||
* safe to call more than once (only the first call decrements). */
|
||||
export function acquirePtyDeliveryInterest(ptyId: string): () => void {
|
||||
const next = (ptyDeliveryInterestRefCounts.get(ptyId) ?? 0) + 1
|
||||
ptyDeliveryInterestRefCounts.set(ptyId, next)
|
||||
if (next === 1) {
|
||||
sendPtyDeliveryInterest(ptyId, true)
|
||||
}
|
||||
let released = false
|
||||
return () => {
|
||||
if (released) {
|
||||
return
|
||||
}
|
||||
released = true
|
||||
const current = ptyDeliveryInterestRefCounts.get(ptyId) ?? 0
|
||||
if (current <= 1) {
|
||||
ptyDeliveryInterestRefCounts.delete(ptyId)
|
||||
sendPtyDeliveryInterest(ptyId, false)
|
||||
} else {
|
||||
ptyDeliveryInterestRefCounts.set(ptyId, current - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: drop ref counts between tests (no IPC is sent). */
|
||||
export function _resetPtyDeliveryInterestForTest(): void {
|
||||
ptyDeliveryInterestRefCounts.clear()
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
// Why: the Phase-4 hidden-delivery gate only drops bytes while NO renderer
|
||||
// party needs them. These tests pin the dispatcher-side interest signal: every
|
||||
// subscribeToPtyData sidecar must surface a ref-counted delivery-interest hold
|
||||
// to main. Eager buffers are model-recoverable and must not defeat hidden gating.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('pty dispatcher delivery interest', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
let setPtyDeliveryInterest: ReturnType<typeof vi.fn>
|
||||
let exitCallback: ((payload: { id: string; code: number }) => void) | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
exitCallback = null
|
||||
setPtyDeliveryInterest = vi.fn()
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
...originalWindow,
|
||||
api: {
|
||||
...originalWindow?.api,
|
||||
pty: {
|
||||
...originalWindow?.api?.pty,
|
||||
setPtyDeliveryInterest,
|
||||
onData: vi.fn(() => () => {}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn((cb: (payload: { id: string; code: number }) => void) => {
|
||||
exitCallback ??= cb
|
||||
return () => {}
|
||||
}),
|
||||
ackData: vi.fn()
|
||||
}
|
||||
}
|
||||
} as unknown as typeof window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
it('registers interest on the first sidecar and releases on the last unsubscribe', async () => {
|
||||
const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions')
|
||||
|
||||
const unsubscribeFirst = subscribeToPtyData('pty-1', vi.fn())
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledWith('pty-1', true)
|
||||
|
||||
// Why: ref-counted — main only sees the 0↔1 transitions.
|
||||
const unsubscribeSecond = subscribeToPtyData('pty-1', vi.fn())
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
|
||||
|
||||
unsubscribeFirst()
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
|
||||
unsubscribeSecond()
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2)
|
||||
expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false)
|
||||
})
|
||||
|
||||
it('releases sidecar interest only once for repeated unsubscribes', async () => {
|
||||
const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions')
|
||||
|
||||
const unsubscribe = subscribeToPtyData('pty-1', vi.fn())
|
||||
unsubscribe()
|
||||
unsubscribe()
|
||||
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2)
|
||||
expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false)
|
||||
})
|
||||
|
||||
it('does not let an eager pre-mount buffer defeat hidden delivery gating', async () => {
|
||||
const { registerEagerPtyBuffer } = await import('./pty-dispatcher')
|
||||
|
||||
const handle = registerEagerPtyBuffer('pty-eager', vi.fn())
|
||||
expect(setPtyDeliveryInterest).not.toHaveBeenCalled()
|
||||
|
||||
handle.dispose()
|
||||
expect(setPtyDeliveryInterest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps eager buffers outside delivery interest when the PTY exits before mount', async () => {
|
||||
const { registerEagerPtyBuffer } = await import('./pty-dispatcher')
|
||||
|
||||
registerEagerPtyBuffer('pty-eager', vi.fn())
|
||||
expect(setPtyDeliveryInterest).not.toHaveBeenCalled()
|
||||
|
||||
exitCallback?.({ id: 'pty-eager', code: 0 })
|
||||
expect(setPtyDeliveryInterest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a sidecar exclusively own interest while an eager buffer overlaps', async () => {
|
||||
const { registerEagerPtyBuffer } = await import('./pty-dispatcher')
|
||||
const { subscribeToPtyData } = await import('./pty-data-sidecar-subscriptions')
|
||||
|
||||
const handle = registerEagerPtyBuffer('pty-1', vi.fn())
|
||||
const unsubscribe = subscribeToPtyData('pty-1', vi.fn())
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
|
||||
|
||||
handle.dispose()
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(1)
|
||||
|
||||
unsubscribe()
|
||||
expect(setPtyDeliveryInterest).toHaveBeenCalledTimes(2)
|
||||
expect(setPtyDeliveryInterest).toHaveBeenLastCalledWith('pty-1', false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,92 @@
|
||||
// Why: pins the renderer half of the delivery-resync protocol — the singleton
|
||||
// dispatcher must answer main's probe with the cumulative processed totals
|
||||
// that back its ACKs, and must drop a PTY's total once it exits.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('pty dispatcher delivery resync', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
|
||||
let dataCallback:
|
||||
| ((payload: { id: string; data: string; rawLength?: number; background?: boolean }) => void)
|
||||
| null = null
|
||||
let exitCallback: ((payload: { id: string; code: number }) => void) | null = null
|
||||
let resyncRequestCallback: ((payload: { requestId: number }) => void) | null = null
|
||||
const ackDataMock = vi.fn()
|
||||
const respondDeliveryResyncMock = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
dataCallback = null
|
||||
exitCallback = null
|
||||
resyncRequestCallback = null
|
||||
ackDataMock.mockClear()
|
||||
respondDeliveryResyncMock.mockClear()
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
...originalWindow,
|
||||
api: {
|
||||
...originalWindow?.api,
|
||||
pty: {
|
||||
...originalWindow?.api?.pty,
|
||||
ackData: ackDataMock,
|
||||
onData: vi.fn(
|
||||
(
|
||||
cb: (payload: {
|
||||
id: string
|
||||
data: string
|
||||
rawLength?: number
|
||||
background?: boolean
|
||||
}) => void
|
||||
) => {
|
||||
dataCallback ??= cb
|
||||
return () => {}
|
||||
}
|
||||
),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn((cb: (payload: { id: string; code: number }) => void) => {
|
||||
exitCallback ??= cb
|
||||
return () => {}
|
||||
}),
|
||||
onDeliveryResyncRequest: vi.fn((cb: (payload: { requestId: number }) => void) => {
|
||||
resyncRequestCallback ??= cb
|
||||
return () => {}
|
||||
}),
|
||||
respondDeliveryResync: respondDeliveryResyncMock
|
||||
}
|
||||
}
|
||||
} as unknown as typeof window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
it('answers delivery resync probes with cumulative totals and drops exited PTYs', async () => {
|
||||
const { ensurePtyDispatcher } = await import('./pty-dispatcher')
|
||||
ensurePtyDispatcher()
|
||||
|
||||
dataCallback?.({ id: 'pty-1', data: 'hello' })
|
||||
dataCallback?.({ id: 'pty-1', data: 'world!!', rawLength: 7 })
|
||||
dataCallback?.({ id: 'pty-2', data: 'abc' })
|
||||
|
||||
expect(ackDataMock).toHaveBeenNthCalledWith(1, 'pty-1', 5, 5)
|
||||
expect(ackDataMock).toHaveBeenNthCalledWith(2, 'pty-1', 7, 12)
|
||||
expect(ackDataMock).toHaveBeenNthCalledWith(3, 'pty-2', 3, 3)
|
||||
|
||||
resyncRequestCallback?.({ requestId: 7 })
|
||||
expect(respondDeliveryResyncMock).toHaveBeenCalledWith({
|
||||
requestId: 7,
|
||||
processedCharsByPty: { 'pty-1': 12, 'pty-2': 3 }
|
||||
})
|
||||
|
||||
exitCallback?.({ id: 'pty-1', code: 0 })
|
||||
resyncRequestCallback?.({ requestId: 8 })
|
||||
expect(respondDeliveryResyncMock).toHaveBeenLastCalledWith({
|
||||
requestId: 8,
|
||||
processedCharsByPty: { 'pty-2': 3 }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -92,7 +92,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => {
|
||||
dispatcherCallback?.({ id: 'pty-pi', data: 'chunk', rawLength: 10, background: true } as never)
|
||||
|
||||
expect(handler).toHaveBeenCalledWith('chunk', { rawLength: 10, background: true })
|
||||
expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-pi', 10)
|
||||
expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-pi', 10, 10)
|
||||
ptyDataHandlers.delete('pty-pi')
|
||||
})
|
||||
|
||||
@@ -299,7 +299,7 @@ describe('dispatcher → transport → onTitleChange for Pi spinner', () => {
|
||||
|
||||
dispatcherCallback?.({ id: 'pty-early', data: 'setup starts here\r\n', rawLength: 19 })
|
||||
expect(onData).not.toHaveBeenCalled()
|
||||
expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-early', 19)
|
||||
expect(window.api.pty.ackData).toHaveBeenCalledWith('pty-early', 19, 19)
|
||||
|
||||
resolveSpawn({ id: 'pty-early' })
|
||||
await connectPromise
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Why: unit-level repro of the field wedge (v1.4.121-rc.0) — push events
|
||||
// vanish before the dispatcher sees them, so no handler runs and no ACK is
|
||||
// ever produced — plus the recovery seam: reattachPtyDispatcherPushListeners
|
||||
// must drop the stale subscriptions and bind fresh ones.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/e2e-config', () => ({ e2eConfig: { exposeStore: true } }))
|
||||
|
||||
type DataCallback = (payload: { id: string; data: string; rawLength?: number }) => void
|
||||
|
||||
describe('pty dispatcher push-listener reattach and delivery blackhole', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
|
||||
let dataCallbacks: DataCallback[] = []
|
||||
let dataUnsubscribes: ReturnType<typeof vi.fn>[] = []
|
||||
const ackDataMock = vi.fn()
|
||||
const reportMock = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
inFlightTotalChars: 0,
|
||||
inFlightPtyCount: 0,
|
||||
msSinceLastAck: null
|
||||
})
|
||||
)
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
dataCallbacks = []
|
||||
dataUnsubscribes = []
|
||||
ackDataMock.mockClear()
|
||||
reportMock.mockClear()
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
...originalWindow,
|
||||
api: {
|
||||
...originalWindow?.api,
|
||||
pty: {
|
||||
...originalWindow?.api?.pty,
|
||||
ackData: ackDataMock,
|
||||
reportRendererDeliveryState: reportMock,
|
||||
getPtyDataListenerCount: () => dataCallbacks.length,
|
||||
onData: vi.fn((cb: DataCallback) => {
|
||||
dataCallbacks.push(cb)
|
||||
const unsubscribe = vi.fn()
|
||||
dataUnsubscribes.push(unsubscribe)
|
||||
return unsubscribe
|
||||
}),
|
||||
onReplay: vi.fn(() => () => {}),
|
||||
onExit: vi.fn(() => () => {}),
|
||||
onDeliveryResyncRequest: vi.fn(() => () => {}),
|
||||
respondDeliveryResync: vi.fn()
|
||||
}
|
||||
}
|
||||
} as unknown as typeof window
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
// Why: ensurePtyDispatcher started the watchdog's real-timer interval;
|
||||
// stop it so no tick outlives this file's mocked window.
|
||||
const { stopTerminalDeliveryWatchdog } = await import('./terminal-delivery-watchdog')
|
||||
stopTerminalDeliveryWatchdog()
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
it('blackholed delivery reproduces the wedge: no handler dispatch, no ACK ever', async () => {
|
||||
const { ensurePtyDispatcher, ptyDataHandlers } = await import('./pty-dispatcher')
|
||||
ensurePtyDispatcher()
|
||||
const received: string[] = []
|
||||
ptyDataHandlers.set('pty-1', (data) => received.push(data))
|
||||
|
||||
dataCallbacks[0]?.({ id: 'pty-1', data: 'before-wedge' })
|
||||
expect(received).toEqual(['before-wedge'])
|
||||
expect(ackDataMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
const blackhole = (
|
||||
window as Window & {
|
||||
__terminalDeliveryWatchdog?: { blackhole: (on: boolean) => void }
|
||||
}
|
||||
).__terminalDeliveryWatchdog
|
||||
expect(blackhole).toBeDefined()
|
||||
blackhole!.blackhole(true)
|
||||
|
||||
// The field failure in miniature: the chunk vanishes with no receive
|
||||
// count and no ACK — main's in-flight debt for it can never be repaid.
|
||||
dataCallbacks[0]?.({ id: 'pty-1', data: 'lost-in-wedge' })
|
||||
expect(received).toEqual(['before-wedge'])
|
||||
expect(ackDataMock).toHaveBeenCalledTimes(1)
|
||||
|
||||
blackhole!.blackhole(false)
|
||||
})
|
||||
|
||||
it('reattach drops stale push subscriptions and binds fresh ones', async () => {
|
||||
const { ensurePtyDispatcher, ptyDataHandlers, reattachPtyDispatcherPushListeners } =
|
||||
await import('./pty-dispatcher')
|
||||
ensurePtyDispatcher()
|
||||
const received: string[] = []
|
||||
ptyDataHandlers.set('pty-1', (data) => received.push(data))
|
||||
expect(dataCallbacks).toHaveLength(1)
|
||||
|
||||
reattachPtyDispatcherPushListeners()
|
||||
|
||||
expect(dataUnsubscribes[0]).toHaveBeenCalledTimes(1)
|
||||
expect(dataCallbacks).toHaveLength(2)
|
||||
|
||||
dataCallbacks[1]?.({ id: 'pty-1', data: 'after-reattach' })
|
||||
expect(received).toEqual(['after-reattach'])
|
||||
expect(ackDataMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,12 @@
|
||||
* and the eager-buffer reconnection logic share.
|
||||
*/
|
||||
import { TERMINAL_SCROLLBACK_SESSION_BUFFER_BYTE_LIMIT } from '../../../../shared/terminal-scrollback-limits'
|
||||
import { ackPtyData, exposeE2eTerminalPtyAckGate } from './terminal-pty-ack-gate'
|
||||
import {
|
||||
clearProcessedPtyCharTotal,
|
||||
deliverPtyDataWithDeferredAck,
|
||||
exposeE2eTerminalPtyAckGate,
|
||||
getProcessedPtyCharTotals
|
||||
} from './terminal-pty-ack-gate'
|
||||
import { clampUtf8Tail, type EagerBufferChunk } from './pty-eager-buffer-clamp'
|
||||
import {
|
||||
bufferPreHandlerPtyData,
|
||||
@@ -15,6 +20,14 @@ import {
|
||||
drainPreHandlerPtyData,
|
||||
drainPreHandlerPtyExit
|
||||
} from './pty-pre-handler-buffer'
|
||||
import {
|
||||
clearReceivedPtyCharTotal,
|
||||
isPtyPushDeliveryBlackholed,
|
||||
recordPtyDataReceived,
|
||||
startTerminalDeliveryWatchdog
|
||||
} from './terminal-delivery-watchdog'
|
||||
import { recordTerminalFreezeBreadcrumb } from './terminal-freeze-breadcrumbs'
|
||||
import { installTerminalFreezeReport } from './terminal-freeze-report'
|
||||
|
||||
// ── Singleton PTY event dispatcher ───────────────────────────────────
|
||||
// One global IPC listener per channel, routes events to transports by
|
||||
@@ -25,9 +38,9 @@ export type PtyDataMeta = {
|
||||
seq?: number
|
||||
rawLength?: number
|
||||
background?: boolean
|
||||
/** Main trimmed this pty's unsent backlog past its cap; the handler should
|
||||
* rebuild the dropped span from the main headless snapshot. */
|
||||
droppedBacklog?: boolean
|
||||
/** Main dropped this PTY's buffered output at the pending cap; the pane
|
||||
* must repaint from the main-owned snapshot instead of the live stream. */
|
||||
droppedOutput?: boolean
|
||||
}
|
||||
|
||||
export const ptyDataHandlers = new Map<string, (data: string, meta?: PtyDataMeta) => void>()
|
||||
@@ -102,78 +115,151 @@ export function restorePtyDataHandlersAfterFailedShutdown(
|
||||
}
|
||||
}
|
||||
|
||||
let pushListenerUnsubscribes: (() => void)[] = []
|
||||
|
||||
/** Detach and freshly re-subscribe every push-channel listener. Called by the
|
||||
* delivery watchdog on a confirmed wedge: if the listener was somehow
|
||||
* detached this restores delivery outright; if the channel itself is dead
|
||||
* it is a safe no-op (removeListener on a gone listener does nothing). */
|
||||
export function reattachPtyDispatcherPushListeners(): void {
|
||||
recordTerminalFreezeBreadcrumb('push-listeners-reattach', {
|
||||
staleListenerCount: pushListenerUnsubscribes.length
|
||||
})
|
||||
const stale = pushListenerUnsubscribes
|
||||
pushListenerUnsubscribes = []
|
||||
for (const unsubscribe of stale) {
|
||||
unsubscribe()
|
||||
}
|
||||
attachPtyPushListeners()
|
||||
}
|
||||
|
||||
export function ensurePtyDispatcher(): void {
|
||||
if (ptyDispatcherAttached) {
|
||||
return
|
||||
}
|
||||
ptyDispatcherAttached = true
|
||||
exposeE2eTerminalPtyAckGate()
|
||||
window.api.pty.onData((payload) => {
|
||||
try {
|
||||
let meta: PtyDataMeta | undefined
|
||||
if (typeof payload.seq === 'number') {
|
||||
meta ??= {}
|
||||
meta.seq = payload.seq
|
||||
installTerminalFreezeReport()
|
||||
attachPtyPushListeners()
|
||||
startTerminalDeliveryWatchdog({
|
||||
reattachPushListeners: reattachPtyDispatcherPushListeners,
|
||||
hasAttachedPtys: () => ptyDataHandlers.size > 0 || eagerPtyHandles.size > 0
|
||||
})
|
||||
}
|
||||
|
||||
function attachPtyPushListeners(): void {
|
||||
const unsubscribes = pushListenerUnsubscribes
|
||||
unsubscribes.push(
|
||||
window.api.pty.onData((payload) => {
|
||||
// Why: e2e-only wedge simulation — the chunk vanishes exactly as in the
|
||||
// field failure: no receive count, no ACK credit, no handler dispatch.
|
||||
if (isPtyPushDeliveryBlackholed()) {
|
||||
return
|
||||
}
|
||||
if (typeof payload.rawLength === 'number') {
|
||||
meta ??= {}
|
||||
meta.rawLength = payload.rawLength
|
||||
handleDispatchedPtyData(payload)
|
||||
})
|
||||
)
|
||||
attachPtySecondaryPushListeners(unsubscribes)
|
||||
}
|
||||
|
||||
function handleDispatchedPtyData(payload: {
|
||||
id: string
|
||||
data: string
|
||||
seq?: number
|
||||
rawLength?: number
|
||||
background?: boolean
|
||||
droppedOutput?: boolean
|
||||
}): void {
|
||||
let meta: PtyDataMeta | undefined
|
||||
if (typeof payload.seq === 'number') {
|
||||
meta ??= {}
|
||||
meta.seq = payload.seq
|
||||
}
|
||||
if (typeof payload.rawLength === 'number') {
|
||||
meta ??= {}
|
||||
meta.rawLength = payload.rawLength
|
||||
}
|
||||
if (payload.background === true) {
|
||||
meta ??= {}
|
||||
meta.background = true
|
||||
}
|
||||
if (payload.droppedOutput === true) {
|
||||
meta ??= {}
|
||||
meta.droppedOutput = true
|
||||
}
|
||||
const chars = payload.rawLength ?? payload.data.length
|
||||
const dispatch = (): void => {
|
||||
const handler = ptyDataHandlers.get(payload.id)
|
||||
if (handler) {
|
||||
handler(payload.data, meta)
|
||||
} else {
|
||||
bufferPreHandlerPtyData(payload.id, payload.data, meta)
|
||||
}
|
||||
const sidecars = ptyDataSidecars.get(payload.id)
|
||||
if (sidecars && sidecars.size > 0) {
|
||||
// Why: snapshot the Set before iterating because watchers commonly
|
||||
// unsubscribe themselves on the very chunk that satisfies them
|
||||
// (e.g. agent-paste-draft resolves on DECSET 2004 and immediately
|
||||
// tears down). Iterating the live Set in that case can skip a
|
||||
// watcher or — if a watcher synchronously subscribes a sibling —
|
||||
// double-fire. The Set is never large (one watcher per active
|
||||
// ready-wait), so the array allocation is cheap.
|
||||
const snapshot = Array.from(sidecars)
|
||||
for (const watcher of snapshot) {
|
||||
watcher(payload.data)
|
||||
}
|
||||
if (payload.background === true) {
|
||||
meta ??= {}
|
||||
meta.background = true
|
||||
}
|
||||
if (payload.droppedBacklog === true) {
|
||||
meta ??= {}
|
||||
meta.droppedBacklog = true
|
||||
}
|
||||
const handler = ptyDataHandlers.get(payload.id)
|
||||
}
|
||||
}
|
||||
recordPtyDataReceived(payload.id, chars)
|
||||
// Why deferred: main budgets renderer-bound output by bytes PARSED, not
|
||||
// bytes received. The handler's scheduler write claims this delivery's
|
||||
// credit and fires it when xterm consumes the bytes; deliveries that never
|
||||
// reach the scheduler (dropped, pre-mount eager buffer) settle at return —
|
||||
// a bad sidecar still cannot leave a PTY permanently backpressured.
|
||||
deliverPtyDataWithDeferredAck(payload.id, chars, dispatch)
|
||||
}
|
||||
|
||||
function attachPtySecondaryPushListeners(unsubscribes: (() => void)[]): void {
|
||||
unsubscribes.push(
|
||||
window.api.pty.onReplay((payload) => {
|
||||
ptyReplayHandlers.get(payload.id)?.(payload.data)
|
||||
})
|
||||
)
|
||||
unsubscribes.push(
|
||||
window.api.pty.onExit((payload) => {
|
||||
// Why: main drops its delivery accounting for this pty on exit; drop the
|
||||
// cumulative totals too so a reused id restarts at zero on both sides.
|
||||
clearProcessedPtyCharTotal(payload.id)
|
||||
clearReceivedPtyCharTotal(payload.id)
|
||||
const handler = ptyExitHandlers.get(payload.id)
|
||||
if (handler) {
|
||||
handler(payload.data, meta)
|
||||
clearPreHandlerPtyState(payload.id)
|
||||
handler(payload.code)
|
||||
} else {
|
||||
bufferPreHandlerPtyData(payload.id, payload.data, meta)
|
||||
bufferPreHandlerPtyExit(payload.id, payload.code)
|
||||
}
|
||||
const sidecars = ptyDataSidecars.get(payload.id)
|
||||
const sidecars = ptyExitSidecars.get(payload.id)
|
||||
if (sidecars && sidecars.size > 0) {
|
||||
// Why: snapshot the Set before iterating because watchers commonly
|
||||
// unsubscribe themselves on the very chunk that satisfies them
|
||||
// (e.g. agent-paste-draft resolves on DECSET 2004 and immediately
|
||||
// tears down). Iterating the live Set in that case can skip a
|
||||
// watcher or — if a watcher synchronously subscribes a sibling —
|
||||
// double-fire. The Set is never large (one watcher per active
|
||||
// ready-wait), so the array allocation is cheap.
|
||||
const snapshot = Array.from(sidecars)
|
||||
for (const watcher of snapshot) {
|
||||
watcher(payload.data)
|
||||
ptyExitSidecars.delete(payload.id)
|
||||
for (const sidecar of snapshot) {
|
||||
sidecar(payload.code)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
// Why: main budgets renderer-bound terminal output by bytes accepted
|
||||
// into this dispatcher. ACK in finally so a bad sidecar cannot leave
|
||||
// a PTY permanently backpressured.
|
||||
ackPtyData(payload.id, payload.rawLength ?? payload.data.length)
|
||||
}
|
||||
})
|
||||
window.api.pty.onReplay((payload) => {
|
||||
ptyReplayHandlers.get(payload.id)?.(payload.data)
|
||||
})
|
||||
window.api.pty.onExit((payload) => {
|
||||
const handler = ptyExitHandlers.get(payload.id)
|
||||
if (handler) {
|
||||
clearPreHandlerPtyState(payload.id)
|
||||
handler(payload.code)
|
||||
} else {
|
||||
bufferPreHandlerPtyExit(payload.id, payload.code)
|
||||
}
|
||||
const sidecars = ptyExitSidecars.get(payload.id)
|
||||
if (sidecars && sidecars.size > 0) {
|
||||
const snapshot = Array.from(sidecars)
|
||||
ptyExitSidecars.delete(payload.id)
|
||||
for (const sidecar of snapshot) {
|
||||
sidecar(payload.code)
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
// Why: main probes when delivery looks stuck on lost ACKs (data arriving
|
||||
// for a fully gated PTY). Replying with the cumulative processed totals
|
||||
// lets main reconcile verified state instead of resetting blindly.
|
||||
const unsubscribeResync = window.api.pty.onDeliveryResyncRequest?.((payload) => {
|
||||
window.api.pty.respondDeliveryResync?.({
|
||||
requestId: payload.requestId,
|
||||
processedCharsByPty: getProcessedPtyCharTotals()
|
||||
})
|
||||
})
|
||||
if (unsubscribeResync) {
|
||||
unsubscribes.push(unsubscribeResync)
|
||||
}
|
||||
// Why: tell main the pty:data listener is live now. Before this fires (fresh
|
||||
// load or post-reload boot window) main holds all sends — bytes sent into a
|
||||
// listener-less page are silently dropped yet counted in-flight, which
|
||||
@@ -223,7 +309,6 @@ export function registerEagerPtyBuffer(
|
||||
onExit: (ptyId: string, code: number) => void
|
||||
): EagerPtyHandle {
|
||||
ensurePtyDispatcher()
|
||||
|
||||
// Why: a head index instead of Array.shift() — shift() is O(n), making
|
||||
// pre-attach buffering quadratic under many small chunks. Compaction is deferred.
|
||||
const chunks: EagerBufferChunk[] = []
|
||||
@@ -252,7 +337,7 @@ export function registerEagerPtyBuffer(
|
||||
// Shell died before TerminalPane attached — clean up and notify the store
|
||||
// so the tab's ptyId is cleared and connectPanePty falls through to connect().
|
||||
// Identity-guarded like dispose(): never delete a handler a transport has
|
||||
// since registered for this id.
|
||||
// since registered for this id (#7894 detach/attach remount race).
|
||||
if (ptyDataHandlers.get(ptyId) === dataHandler) {
|
||||
ptyDataHandlers.delete(ptyId)
|
||||
ptyReplayHandlers.delete(ptyId)
|
||||
@@ -277,6 +362,8 @@ export function registerEagerPtyBuffer(
|
||||
return data
|
||||
},
|
||||
dispose() {
|
||||
// Why: dispose runs at pane attach (mount completed) — the pane's own
|
||||
// visibility sync now owns the hidden-delivery decision for this PTY.
|
||||
// Only remove if the current handler is still the temp one (compare by
|
||||
// reference). After attach() replaces the handler this becomes a no-op.
|
||||
if (ptyDataHandlers.get(ptyId) === dataHandler) {
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Why: the out-of-band pty:modelRestoreNeeded channel replaces the in-band
|
||||
// empty-chunk sentinel (ambiguous with chunks fully consumed by OSC-9999
|
||||
// stripping). These tests pin the channel routing: one channel subscription,
|
||||
// handlers keyed by PTY id, replace-on-reregister semantics.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
describe('pty model-restore channel routing', () => {
|
||||
const originalWindow = (globalThis as { window?: typeof window }).window
|
||||
let onModelRestoreNeeded: ReturnType<typeof vi.fn>
|
||||
let channelCallback: ((event: { id: string; reason: string; markerSeq?: number }) => void) | null
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
channelCallback = null
|
||||
onModelRestoreNeeded = vi.fn(
|
||||
(callback: (event: { id: string; reason: string; markerSeq?: number }) => void) => {
|
||||
channelCallback ??= callback
|
||||
return () => {}
|
||||
}
|
||||
)
|
||||
;(globalThis as { window: typeof window }).window = {
|
||||
...originalWindow,
|
||||
api: {
|
||||
...originalWindow?.api,
|
||||
pty: {
|
||||
...originalWindow?.api?.pty,
|
||||
onModelRestoreNeeded
|
||||
}
|
||||
}
|
||||
} as unknown as typeof window
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow) {
|
||||
;(globalThis as { window: typeof window }).window = originalWindow
|
||||
} else {
|
||||
delete (globalThis as { window?: typeof window }).window
|
||||
}
|
||||
})
|
||||
|
||||
it('attaches the channel once and routes markers to the registered PTY handler', async () => {
|
||||
const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel')
|
||||
const handlerA = vi.fn()
|
||||
const handlerB = vi.fn()
|
||||
|
||||
registerPtyModelRestoreNeededHandler('pty-a', handlerA)
|
||||
registerPtyModelRestoreNeededHandler('pty-b', handlerB)
|
||||
expect(onModelRestoreNeeded).toHaveBeenCalledTimes(1)
|
||||
|
||||
channelCallback?.({ id: 'pty-a', reason: 'hidden-drop', markerSeq: 42 })
|
||||
expect(handlerA).toHaveBeenCalledWith({ id: 'pty-a', reason: 'hidden-drop', markerSeq: 42 })
|
||||
expect(handlerB).not.toHaveBeenCalled()
|
||||
|
||||
// Markers for PTYs without a registered handler are dropped silently.
|
||||
channelCallback?.({ id: 'pty-unknown', reason: 'pending-cap' })
|
||||
expect(handlerA).toHaveBeenCalledTimes(1)
|
||||
expect(handlerB).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lets a new registration replace a stale one without the stale unregister clobbering it', async () => {
|
||||
const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel')
|
||||
const staleHandler = vi.fn()
|
||||
const liveHandler = vi.fn()
|
||||
|
||||
const unregisterStale = registerPtyModelRestoreNeededHandler('pty-a', staleHandler)
|
||||
registerPtyModelRestoreNeededHandler('pty-a', liveHandler)
|
||||
// Why: a reattaching pane can re-register before the old connection's
|
||||
// teardown runs — the stale unregister must not remove the live handler.
|
||||
unregisterStale()
|
||||
|
||||
channelCallback?.({ id: 'pty-a', reason: 'unhide' })
|
||||
expect(staleHandler).not.toHaveBeenCalled()
|
||||
expect(liveHandler).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('stops routing after the live handler unregisters', async () => {
|
||||
const { registerPtyModelRestoreNeededHandler } = await import('./pty-model-restore-channel')
|
||||
const handler = vi.fn()
|
||||
|
||||
const unregister = registerPtyModelRestoreNeededHandler('pty-a', handler)
|
||||
unregister()
|
||||
|
||||
channelCallback?.({ id: 'pty-a', reason: 'hidden-drop' })
|
||||
expect(handler).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
/**
|
||||
* Singleton router for the out-of-band `pty:modelRestoreNeeded` channel
|
||||
* (sibling of the pty-dispatcher's data/exit routing — split out to keep the
|
||||
* dispatcher under the line limit).
|
||||
*
|
||||
* Why a dedicated channel + registry: the marker means "main dropped
|
||||
* renderer-bound bytes (hidden gate / pending cap); restore from the model
|
||||
* snapshot". It must NOT ride the transport data path — an in-band empty
|
||||
* chunk is ambiguous with chunks fully consumed by OSC-9999 stripping, and
|
||||
* remote-runtime transports (which never see main's gate) must stay
|
||||
* structurally unaffected.
|
||||
*/
|
||||
import type { PtyModelRestoreNeededEvent } from '../../../../shared/pty-model-restore-marker'
|
||||
|
||||
const ptyModelRestoreNeededHandlers = new Map<string, (event: PtyModelRestoreNeededEvent) => void>()
|
||||
let modelRestoreNeededChannelAttached = false
|
||||
|
||||
function dispatchPtyModelRestoreNeeded(event: PtyModelRestoreNeededEvent): void {
|
||||
ptyModelRestoreNeededHandlers.get(event.id)?.(event)
|
||||
}
|
||||
|
||||
function ensureModelRestoreNeededChannel(): void {
|
||||
if (modelRestoreNeededChannelAttached) {
|
||||
return
|
||||
}
|
||||
// Why optional-chained: unit tests and the web remote client expose a
|
||||
// partial pty API; missing channel means "no markers", never a throw.
|
||||
const onModelRestoreNeeded = (globalThis as { window?: Window }).window?.api?.pty
|
||||
?.onModelRestoreNeeded
|
||||
if (typeof onModelRestoreNeeded !== 'function') {
|
||||
return
|
||||
}
|
||||
modelRestoreNeededChannelAttached = true
|
||||
onModelRestoreNeeded(dispatchPtyModelRestoreNeeded)
|
||||
}
|
||||
|
||||
/** Register the single model-restore-needed handler for a PTY (the pane
|
||||
* connection that owns its view). A new registration replaces a stale one. */
|
||||
export function registerPtyModelRestoreNeededHandler(
|
||||
ptyId: string,
|
||||
handler: (event: PtyModelRestoreNeededEvent) => void
|
||||
): () => void {
|
||||
ensureModelRestoreNeededChannel()
|
||||
ptyModelRestoreNeededHandlers.set(ptyId, handler)
|
||||
return () => {
|
||||
if (ptyModelRestoreNeededHandlers.get(ptyId) === handler) {
|
||||
ptyModelRestoreNeededHandlers.delete(ptyId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Deliver markers fetched over invoke by the delivery watchdog. Same routing
|
||||
* as the push channel — needed because a delivery-heal fires precisely when
|
||||
* `pty:modelRestoreNeeded` push events cannot reach this renderer. */
|
||||
export function deliverPulledPtyModelRestoreMarkers(
|
||||
events: readonly PtyModelRestoreNeededEvent[]
|
||||
): void {
|
||||
for (const event of events) {
|
||||
dispatchPtyModelRestoreNeeded(event)
|
||||
}
|
||||
}
|
||||
|
||||
/** Test seam: deliver a marker as if it arrived on the channel. */
|
||||
export function _dispatchPtyModelRestoreNeededForTest(event: PtyModelRestoreNeededEvent): void {
|
||||
dispatchPtyModelRestoreNeeded(event)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
_resetPtyRendererDeliveryClaimsForTest,
|
||||
acquireHiddenRendererPtyDeliveryClaim,
|
||||
declareRendererPtyDeliveryVisible,
|
||||
releaseRendererPtyVisibilityClaim,
|
||||
setRendererPtyVisibilityClaim
|
||||
} from './pty-renderer-delivery-claims'
|
||||
|
||||
const PTY_ID = 'workspace@@pty-1'
|
||||
|
||||
describe('renderer PTY delivery claims', () => {
|
||||
const setHiddenRendererPty = vi.fn()
|
||||
const setRendererPtyVisible = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
_resetPtyRendererDeliveryClaimsForTest()
|
||||
setHiddenRendererPty.mockReset()
|
||||
setRendererPtyVisible.mockReset()
|
||||
;(globalThis as { window: Window }).window = {
|
||||
api: { pty: { setHiddenRendererPty, setRendererPtyVisible } }
|
||||
} as unknown as Window
|
||||
})
|
||||
|
||||
it('keeps a PTY hidden across an overlapping pane-to-watcher handoff', () => {
|
||||
const releasePane = acquireHiddenRendererPtyDeliveryClaim(PTY_ID)
|
||||
const releaseWatcher = acquireHiddenRendererPtyDeliveryClaim(PTY_ID)
|
||||
|
||||
expect(setHiddenRendererPty).toHaveBeenCalledTimes(1)
|
||||
expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, true)
|
||||
|
||||
releasePane()
|
||||
expect(setHiddenRendererPty).toHaveBeenCalledTimes(1)
|
||||
|
||||
declareRendererPtyDeliveryVisible(PTY_ID)
|
||||
expect(setHiddenRendererPty).toHaveBeenCalledTimes(1)
|
||||
|
||||
releaseWatcher()
|
||||
expect(setHiddenRendererPty).toHaveBeenLastCalledWith(PTY_ID, false)
|
||||
expect(setHiddenRendererPty).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not let a retiring visible pane hide its replacement', () => {
|
||||
const oldPane = {}
|
||||
const newPane = {}
|
||||
setRendererPtyVisibilityClaim(oldPane, PTY_ID, true)
|
||||
setRendererPtyVisibilityClaim(newPane, PTY_ID, true)
|
||||
|
||||
expect(setRendererPtyVisible).toHaveBeenCalledTimes(1)
|
||||
releaseRendererPtyVisibilityClaim(oldPane)
|
||||
expect(setRendererPtyVisible).toHaveBeenCalledTimes(1)
|
||||
|
||||
releaseRendererPtyVisibilityClaim(newPane)
|
||||
expect(setRendererPtyVisible).toHaveBeenLastCalledWith(PTY_ID, false)
|
||||
expect(setRendererPtyVisible).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reports a never-visible mounted pane as known hidden', () => {
|
||||
setRendererPtyVisibilityClaim({}, PTY_ID, false)
|
||||
expect(setRendererPtyVisible).toHaveBeenCalledWith(PTY_ID, false)
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user