Skip to content

perf(ws): isolate terminal writes and ordinary CLI creates from connection dispatch - #705

Merged
danshapiro merged 4 commits into
mainfrom
perf/cli-connection-scheduling
Sep 5, 2026
Merged

perf(ws): isolate terminal writes and ordinary CLI creates from connection dispatch#705
danshapiro merged 4 commits into
mainfrom
perf/cli-connection-scheduling

Conversation

@danshapiro

Copy link
Copy Markdown
Owner

perf(ws): isolate terminal writes and ordinary CLI creates from connection dispatch

Problem

The freshell-ws connection loop awaited every socket send, so a large drained output backlog or a slow network flush blocked input, attach, and liveness dispatch for every pane on that connection. It also awaited ordinary terminal.create calls inline, letting one slow CLI launch stall unrelated panes on the same socket.

Changes

  • One supervised socket writer per connection. Handlers enqueue to a bounded outbox (WriterSender) instead of awaiting socket capacity; only the writer task owns the real SplitSink. Output and control preludes are admitted under one lock so an attach.ready can never be overtaken by its own replay. terminal.exit stays in the sequenced output lane — final output can never be overtaken by the exit frame.
  • One-frame output leasing. The writer pops a single output frame, reconsiders controls between frames, and keeps the leased frame's bytes in pressure accounting until the flush completes. The existing output-byte cap and explicit overflow-gap behavior are unchanged; controls get their own bounded byte budget (fixed per-entry charge so zero-byte pings can't bypass it).
  • Superseding attach. Admitting a new attach.ready discards only that terminal's unsent old output, gaps, and exit — other terminals' queued data are untouched. A frame already leased to the socket keeps its order (it can't be unsent). Gap coalescing now respects terminal and attach-generation identity and uses saturating arithmetic.
  • No send cancellation ambiguity. A stalled/failed/cancelled socket send terminates the connection; a frame is never retried on that socket after an ambiguous send. Close codes (4009/4008) are preserved via a bounded best-effort close when no send is in progress.
  • Keepalive from flush receipts. Timeout accounting starts when a ping actually leaves the writer (flush receipt), not when it was queued; an early pong is retained; only one unsent ping exists at a time.
  • One bounded serial ordinary-create worker per connection with the existing per-connection sliding-window rate limiter (CreateProtectConfig.spawn_queue_cap bounds the queue; full → loud RateLimited error). Restore creates keep their existing gated path/permits; dedupe admission stays ahead of queueing.
  • Accepted creates are never dropped. Connection teardown closes admission and the worker drains every already-received create to settle, joining before the conn-death lease sweep — preserving the pre-existing "a create read off the socket must spawn" contract (pane_reconcile's interrupted-create regression). The one started create is never aborted (a running spawn_blocking cannot be safely cancelled). A late server-shutdown reaps only the worker's own late lineage.
  • Allocation-identity dedupe cleanup. InFlight.started is now an Arc<Instant>; worker drop guards clear only their own generation, so a stale worker cannot erase a newer same-requestId retry (e.g. with a changed restore flag). Legacy clear_if_in_flight callers are unchanged. Cleanup panics are contained by an unwind boundary.
  • Task supervision. The reader observes both task handles: a writer failure or create-worker exit closes the connection rather than leaving a dead producer/consumer pair; a dropping reader aborts the writer so stale FrameSink callbacks can't fill an orphaned outbox (the pump's Drop also closes admission).

No wire-schema, browser, dependency, provider, replay-retention, compression, or renderer changes. True foreground/background bandwidth scheduling, compression, and viewport snapshots are deliberately out of scope (later steps). WsSink is now the outbox: a "flush" means admission, not wire delivery.

Tests

31 new native tests + fixtures: 12 writer/keepalive cases, 9 serial-worker/cancellation/cleanup cases, 3 dedupe-generation cases, 5 output-queue cases, 1 real-WebSocket regression (cli_connection_scheduling, run via the repo sandbox), plus re-wire of two pre-existing terminal.rs dispatch test scaffolds onto the real outbox+pump pair.

The wire regression proves the headline contract with the real router: with an ordinary create deliberately parked mid-launch, existing claude/opencode/codex headless terminals on the same connection still accept input, new attaches still complete, and app-level ping still answers — without executing any provider binary. RED control: with ordinary-create dispatch restored inline (scratch worktree, discarded), the same test times out in 30s, as designed.

Verification completed (this environment, this base: 5b38513)

  • cargo fmt --all --check clean; cargo clippy --workspace --all-targets -- -D warnings clean (toolchain 1.96.0, matching CI).
  • 973 tests pass across freshell-ws + freshell-terminal (all targets, --no-fail-fast), including the pre-existing pane-reconcile/restore/dedupe/keepalive/exited-terminal replay/multi-client suites.
  • Coordinated npm run check (typecheck + full vitest + electron) passes on the branch.

Fresh-eyes review history

Four rounds of independent review (Claude; GPT was quota-unavailable that day): rounds 1-3 each surfaced real defects that were fixed in follow-up commits (keepalive off-by-epsilon doubling dead-peer detection; fairness fix that broke ready < modes.sync < replay ordering; a JoinHandle double-poll panic on the writer-panic teardown path). Round 4 review PASSED with only minors/nits (noted inline in code comments). Note: commit b891c0672's message says "977 tests"; the actual count at that commit was 972 — the correct current count is 973.

Not done here (per draft scope): constrained-link browser/provider A/B measurements, foreground/background fairness. During this branch's full-suite runs two distinct single-test ambient flakes appeared (test-coordinator holder timing; sidebar DOM-identity) — each passed in immediate isolated re-run and both are unrelated to this Rust-only change; one is already being hunted by the test-flake-hardening track. The repo sandbox's full freshell-ws lib binary additionally SIGABRTs in a notify inotify-destructor on both base and this branch (pre-existing container environment issue; the identical suite passes on the host, and all other sandbox targets pass).

Follow-up boundary / remaining risks

Controls still outrank output — this is not per-pane bandwidth fairness. Other long-running non-create handlers still run inline. A started non-cancellable spawn can hold its worker (not the socket or queue) until it settles. Constrained-link UX acceptance and real-provider checks are the next gate before any ship claim.

…ction dispatch

One supervised socket writer per connection: handlers enqueue to a bounded
outbox instead of awaiting socket capacity, so a backlog or slow network
flush no longer holds input/attach/liveness dispatch. The writer admits
preludes and output under one lock, leases one output frame at a time
(with the leased frame still counted in pressure accounting), keeps
final-output-before-exit ordering, and closes the connection rather than
retrying a possibly partially sent frame.

One bounded, serial ordinary-create worker per connection (restore creates
keep their gated path): a slow CLI launch no longer delays unrelated panes
on the same socket. A create already read off the socket is never dropped
on disconnect — admission closes and accepted work drains to settle,
preserving the dequeued-create-must-spawn contract the pane-reconcile
reconnect path relies on. Ordinary-create dedupe cleanup uses
allocation-identity generations so a stale worker cannot erase a newer
same-requestId retry.

Also: keepalive deadlines start at the ping's actual flush receipt; a
superseding attach discards only that terminal's unsent old output/gaps/
exit; gap coalescing respects terminal and attach-generation identity;
two pre-existing terminal.rs dispatch test scaffolds now wire the real
outbox+pump pair onto their loopback sockets.

Verification: cargo fmt --check and workspace clippy -D warnings clean;
969 tests across freshell-ws + freshell-terminal pass locally (incl. the
30 authored scheduling tests and the pre-existing pane-reconcile,
restore, dedupe, keepalive, replay and multi-client suites); the real
WebSocket regression passes in the repo sandbox; coordinated npm run
check passes.
… fairness, dead-code removal

Address independent review findings on the writer/create-worker split:

- Keepalive major: the flush-anchored deadline doubled dead-peer detection
  (a flush epsilon after its tick slipped timeout to the following tick and
  left a full silent interval). The deadline is now anchored at the tick
  that queued the ping — tick-aligned, so an unanswered ping is detected at
  exactly the next tick one interval later (legacy one-unanswered-cycle
  parity). At most one outstanding ping; a pong observed before its flush
  receipt still counts, and a lost receipt reports the writer as gone
  instead of etching a wrong SendFailed label. Four tests pin: next-tick
  detection with a just-after-tick flush, unflushed-for-a-cycle timeout,
  answered-ping retirement with the new deadline armed, and receipt-loss
  reporting.
- A stop carrying a close code during an in-flight send now first lets the
  started frame finish (bounded 250ms flush — unambiguous continuation,
  never a retry), then attempts a whole Close (bounded): shutdown (4009)
  and failure closes keep their UX instead of an abrupt EOF when the socket
  was busy.
- Controls keep their preemption but a CONTROL_STREAK_LIMIT now forces one
  output frame through after 8 consecutive controls, so a sustained control
  stream cannot starve output all the way to the catastrophic monitor.
- Dropped the now-dead ConnectionOutputQueue adapter from backpressure.rs
  (zero callers after the writer took over routing); its exit/sync routing
  pins are covered by connection_writer tests. Refreshed stale module docs
  in backpressure (writer-based accounting, no more ticker/starve caveat)
  and output_queue, and the term09 integration header.
- A closed create-worker queue no longer makes handle_client_text report a
  misleading 'send_error'; the supervision branch's 'create_worker_exited'
  stands.
- New test: a full create queue yields a loud RATE_LIMITED reply, and a
  same-requestId retry is judged independently (dedupe guard cleared).
- Documented the ordinary-create behavior changes at the worker spawn site
  (limiter stamps dequeue time, post-shutdown dequeue skip without reply,
  late-lineage reap).

Full freshell-ws + freshell-terminal suites: 970 tests pass, all targets;
sandbox wire regression green.
…ve, budget/doc/coverage fixes

Review round 2 found my CONTROL_STREAK_LIMIT fairness rule violated the
ready < modes.sync < replay contract: after 8 consecutive controls (e.g.
idle-connection keepalive pings), an attach's replay could be leased ahead
of its own prelude. Fairness is now stamp-gated: every control and output
frame carries a per-connection admission stamp, and output may leapfrog the
control lane only when strictly older than the oldest pending control —
strictly-older output still interleaves under control floods, while a
prelude can never be overtaken by its newer replay. Pinned by a new test
using a saturated streak with a real TerminalAttachReady/TerminalModesSync/
replay sequence (which also restores a real-modes.sync-through-push_server
routing pin lost with the deleted adapter).

Keepalive now counts tick CYCLES identically to legacy's
pong_since_last_ping instead of comparing wall-clock deadlines: a ping must
be answered by the next tick, so detection lands at exactly one tick
boundary and cannot slip a full interval behind a just-after-tick flush or
timer jitter. A pong with no outstanding ping still counts as the next
ping's answer (legacy boolean parity). The flush receipt survives only as
liveness bookkeeping (pump-gone fast detection); its unused Instant payload
is gone. DIAG-01 teardown also upgrades a generic 'send_error' to the
writer's precise exit reason/close code when the writer had already
finished.

Control budget: an empty lane always admits ONE frame, even one larger than
the configured budget (a legitimate screenshot-size control must not close
an idle connection); continued flooding while it is in flight still
overflows with 4008. The output+control worst-case sum is now documented at
the budget site.

Also: output serialization no longer happens under the queue lock (leased
frames serialize after release); spawn_queue_cap documents its dual role
(server-wide restore gate + per-connection ordinary-create queue); the
2026-08-17 replay-sync plan's retired-adapter pin points at push_server;
discard_terminal restructured so rustfmt actually formats it.

Full freshell-ws + freshell-terminal suites: 977 tests pass, all targets;
sandbox wire regression green.
…cation nits

- The DIAG-01 reason upgrade consumed a completed writer JoinHandle but only
  marked it finished on the Ok arm; a writer panic left the handle
  'finished' and the following .await re-polled a consumed handle, panicking
  the connection task mid-teardown (skipping remove_connection, the
  create-worker join, and the lease sweep). Both arms now mark finished;
  the Err arm reports writer_task_failed. The upgrade additionally covers a
  stale 'writer_stopped' from a lost keepalive receipt.
- Pin the shutdown-edge create worker: a create dequeued after the shutdown
  latch is skipped without a reply or a PTY, and its guard still releases
  the dedupe reservation (drives spawn()'s real closure; the WsState test
  fixture is now shared as test_ws_state()).
- push/push_stamped in output_queue now delegate to one inner body.
- Keepalive doc no longer contradicts its stray-pong test; the
  duplicate-supression sliver (reply pushed before the dedupe guard drops)
  is documented at the drop site with the client-visible reasoning.
- The wire regression's header describes how it actually runs (plain cargo
  test; why its process-wide HOME isolation is safe), and push_server's
  measure-then-reserialize cost is now documented as deliberately deferred.

Full freshell-ws + freshell-terminal suites green (all targets, host); this
and prior commits' actual count is 973 (the previous message's ' 977' was a
transcription error).
@danshapiro
danshapiro merged commit cd6a15e into main Sep 5, 2026
3 checks passed
pull Bot pushed a commit to HinchK/freshell that referenced this pull request Sep 5, 2026
…heduling

Adds an additive terminalInterestV1 capability. After the server echoes it on
ready, the browser sends transient full terminal.interest snapshots (focused +
visible terminal IDs, socket-owned monotonic revision; invalid snapshots are
rejected without replacing state; stale revisions are no-ops). The connection
writer's output queue becomes a byte-fair per-terminal delivery queue with
three service classes — focused/visible/background, ~8:3:1 bytes while all are
continuously backlogged — with per-terminal FIFO, generation-scoped gap
coalescing, and sequenced terminal.exit retained (a connection whose sequenced
controls alone exceed the cap closes with 4008). Presentation interest only
reorders not-yet-sent bytes: it never attaches, resizes, spawns, or kills,
and one viewer never affects another. terminal.attach.priority remains the
pre-snapshot fallback for un-negotiated clients; detach drops queued delivery
and the fallback entry.

Ported onto the reviewed danshapiro#705 writer rather than replacing it: keeps the
join-error teardown reconciliation, tick-cycle keepalive, control-lane budget
with admit-one-when-empty, and stamp-gated leapfrog fairness. The delivery
queue additionally carries admission stamps (front_stamp) so the landed
control-vs-output arbitration survives the scheduling change. Frames are
serialized exactly once at admission (byte cost is an admission-time property
of fair scheduling), superseding the typed-message lease-time serialization;
the now-unused freshell-terminal OutputQueue machinery is removed, keeping
only shared output-frame identity extraction and the default cap.

Draft defect fixed during porting: update_priorities did not carry normalized
service credit across a lane's class migration, so demoting a long-served lane
made the destination class falsely underserved and the demoted lane would beat
the freshly-focused lane — the migration now converts the origin class's
weighted service into the destination's units (pinned by the reprioritization
tests while the steady-state ratio tests still pin 8:3:1).

Client: TerminalInterestReporter (mounted once in the workspace) publishes
coalesced snapshots off selected tab/active pane/zoom/visibility; the revision
lives on WsClient so remounts never rewind it; snapshots are never queued
across disconnects. ws mocks in 22 suites gained the new no-op method, and the
hello-capabilities shape test includes terminalInterestV1. The Node server
accepts-and-ignores terminal.interest (never advertises : Rust-only), matching
the auto-resume precedent. Protocol inventory frozen at 37 client/60 server.

Rust: 1037 tests pass across freshell-ws/-terminal/-protocol (all targets,
--no-fail-fast) including 17 delivery-queue units, interest units, four
writer-level reprioritization regressions, and the terminal_interest_wire
real-socket suite; term09 slow-reader wire suite unchanged and green. npm run
check green on the coordinated gate. Note: pane_reconcile_freshagent's tracing
assertion flaked once across three full-suite runs (passes isolated and on
file rerun; same deferred class noted pre-existing in that suite).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant