0044 — Dedicated input lane
0044 — Dedicated input lane
TL;DR. Local input routing and encoding run on one dedicated OS thread from actor-published Send terminal-mode snapshots. A keystroke is gated and encoded in parallel with output work; the !Send Terminal stays actor-owned and receives bytes through a bounded non-blocking mailbox.
Status: Accepted Date: 2026-07-11
Context
ADR-0003 gives the server one current-thread tokio runtime; ADR-0014 puts
every per-client task and every !Send Terminal-owning pane actor on one
LocalSet. They time-share a single core. A large PTY-output broadcast tick
and an inbound keystroke therefore contend for the same thread.
Commit 284fcbd bounded that contention inside the pane actor’s select!
(input arm biased ahead of output; output coalesce capped at 48KB and
yielding). But the routing stage that runs before the mailbox — resolve
the wire pane id, walk the subscription set, check the input lease (ADR-0033),
try_send onto the actor’s mailbox — still executed on the main thread, behind
whatever output work the runtime was mid-poll on. The keystroke’s route waited
for a yield point.
The original lane moved only routing. Encoding still read live terminal state
on the actor, leaving half the latency path behind the output parser. phux-51n6.6
adds the exact Send state boundary needed to move that remaining stage.
Decision
Spawn one dedicated OS thread — the input lane — at server start. Each per-client
read loop, on decoding a local INPUT_* frame or ROUTE_INPUT command,
hands a Send RoutedInput (client id, wire pane id, decoded event, authority
policy, and for commands a reply sender) to the lane over a bounded channel
instead of routing inline. The lane blocks on that channel and runs the same
existing subscription/lease or headless lease handler off the main runtime.
The pane actor and its Terminal remain unmoved. After construction, PTY
output batches, resize, and restored-seed replay, the actor publishes a watch
snapshot containing every value read by the encoders: libghostty’s exact key
options, effective mouse tracking/format, DEC 1004/2004, grid dimensions, and
cell pixels. The lane owns one stateful key/mouse/focus/paste encoder set per
generational pane and applies those snapshots. It hands encoded bytes to the
actor through a bounded mailbox with try_send; the actor only forwards them
to its PTY writer bridge.
The exact key and mouse capture/apply API is pinned in libghostty-rs. In particular, modifyOtherKeys and mouse mode precedence come from libghostty’s own terminal-derived options, not a phux reimplementation.
This is sound because the crossed state is fully Send: SharedState is
Arc<Mutex<ServerState>>, snapshots are copyable values, and encoded bytes use
a Send mpsc::Sender<Vec<u8>>. Making the original lane compile required
one wart fix: dead UnsubscribeFromEventsRequest carried a
*const Sender<Outbound> (raw
pointer, !Send) that poisoned the whole state tree; it is now a usize
address with identical identity semantics.
Satellite-tagged pane ids stay on the main thread (their delivery is a hub-link
relay, not a mailbox send). Tests that drive handle_client directly pass
None and route inline — identical behavior, on-thread. Local ROUTE_INPUT
waits for the lane’s correlated result before its command acknowledgement is
emitted, preserving its existing error and lease-check semantics.
Why
- Root, not per-
select!. 284fcbd made the actor fair once input is in the mailbox; this makes route and encode finish off the contended thread. The actor’s biasedselect!only forwards the ready bytes. - The
!Sendconstraint is respected, not fought. TheTerminalnever moves; only provably-Sendrouting state, snapshots, and bytes cross. - Lease and ordering correctness are preserved by construction (below), so no new correctness surface is opened.
Tradeoffs
- Snapshot publication is eventual at actor mutation boundaries. Modes are published after each bounded output batch, seed, and resize. Input racing an unparsed PTY chunk observes the previous snapshot, matching the old actor scheduling possibility where biased input ran before that output chunk.
- One extra OS thread for the server’s lifetime. It parks on the channel (no busy-wait) and joins on shutdown.
- A same-client input-vs-lease timing shift. A client’s
INPUT_*now routes on the lane while itsACQUIRE_INPUT/RELEASE_INPUT(an L2COMMAND) still runs inline, so their relative order is no longer strictly wire-ordered. The lease gate is re-evaluated atomically under the stateMutexat delivery, so every outcome stays legal under the fire-and-forget input contract (SPEC §12.2): a key racing just past its sender’s ownRELEASE_INPUTis delivered if the wheel is free and dropped if another client grabbed it. Cross-client exclusion never weakens. - Per-client input order is unchanged: the read loop enqueues
INPUT_*and localROUTE_INPUTin wire order, the channel and lane are FIFO, and the mailbox is FIFO. Both input surfaces share this one queue.
Alternatives
- Make the whole runtime multi-threaded. Rejected: the
Terminalis!Send(ADR-0003/0014); a work-stealing pool cannot host the actor. - A second full tokio runtime. Unnecessary: routing and encoding are
synchronous (non-blocking
try_send, no await), so a plain thread withblocking_recvis simpler and has no runtime-in-runtime hazards. - Only strengthen the actor
select!priority. Already done (284fcbd); it cannot address contention before the mailbox, which is on the main thread. - Mirror the lease/subscription state into a separate
Sendstructure the lane owns. Rejected: two sources of truth for the lease is exactly the correctness surface this ADR avoids by reusing shared destination-resolution helpers under the oneMutex.