mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +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
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user