Threading and I/O
Threading and I/O
TL;DR. Why the server runs on a single current-thread tokio runtime with
a LocalSet (libghostty’s Terminal is !Send, so it cannot move across
threads), and why server state nonetheless lives behind an
Arc<Mutex<ServerState>> using std::sync — the lock is never held across
an await, so cross-task, test, and embed paths can share it without a
multi-threaded runtime. A multiplexer is I/O-bound; work-stealing buys
nothing on the hot path.
One current-thread runtime with a LocalSet
A terminal multiplexer is I/O-bound, not CPU-bound: the work is
poll-many-fds-fanout-bytes. A single-threaded executor is simpler and fast
enough. We pick tokio over mio or polling because the ecosystem we need
(tokio-uds for Unix sockets, signal-hook-tokio for signals, tokio-util frame
codecs) is mature and not worth reinventing. The hot path gains nothing from
work-stealing, so the server does not use the multi-threaded runtime.
The current-thread choice is not only a performance call — it is forced by
the engine. libghostty’s Terminal is !Send: it cannot move across
threads, so the tasks that feed and read it run on a LocalSet pinned to the
runtime thread. A multi-threaded runtime would refuse to spawn those tasks
at all.
fn main() -> std::io::Result<()> {
tokio::runtime::Builder::new_current_thread()
.enable_io()
.enable_time()
.build()?
.block_on(phux_server::run())
}
Shared state behind a std::sync Mutex
Server state lives behind an Arc<Mutex<ServerState>> from std::sync (not
tokio::sync). Both facts hold at once and do not contradict: the runtime is
single-threaded for the !Send engine, and the state is still wrapped in a
mutex so that multiple tasks — plus the test and embed paths that drive the
server in-process — share one consistent view.
The rule that makes a synchronous mutex safe on an async runtime is that the
lock is never held across an .await. Each acquisition is a short
critical section: take the lock, read or mutate ServerState, drop the lock,
then await any I/O. Holding a std::sync::Mutex across a yield point would
risk deadlocking the single thread; the discipline of dropping it first is
what keeps that from happening and what lets group operations such as
KILL_TERMINALS apply all-or-nothing under a single acquisition.
This reconciles the earlier server-design sketch, which described the state as actor-owned: the shared-mutex shape is the one that ships, and it coexists with the current-thread/LocalSet runtime rather than competing with it.
Because the state is shared with the input lane below, ServerState must be
Send (so Arc<Mutex<ServerState>> is Send). That is a real constraint on
what may live in it: message types reachable from a TerminalHandle cannot
carry !Send payloads (a raw pointer, an Rc). The event-unsubscribe request
identifies a subscriber by a usize address rather than a
*const Sender<Outbound> for exactly this reason.
The dedicated input lane (ADR-0044)
Local input routing and encoding run on their own OS thread, the input
lane, not on the LocalSet. The actor publishes a copyable snapshot after each
output batch, seed replay, and resize. It contains libghostty’s exact key
options, resolved mouse tracking/format, DEC 1004/2004, and grid/cell geometry.
The lane owns one stateful encoder set per generational pane, applies the latest
snapshot, and try_sends bytes through a bounded actor mailbox. The actor’s
input arm only forwards those bytes to the PTY writer.
The lane is a plain thread with a bounded channel and blocking_recv, not a
second tokio runtime: gating and encoding are synchronous, and both handoffs are
non-blocking. Per-client order is preserved because the read loop, lane channel,
single encoder thread, and encoded-byte mailbox are FIFO. Lease/subscription
semantics are unchanged because production and inline test paths share the same
destination-resolution helpers under the same Mutex. Satellite-tagged input
remains structured through the hub relay and is encoded by the destination
server’s local lane.
Hot paths that could go multi-threaded later
The input lane above is the first realized fan-out off the main thread. Others
can follow the same rule — cross only Send state, leave the !Send engine
put — if a future profile demands it:
- PTY-byte feed and per-client capability rewriting on outbound terminal
frames. Each terminal is independent and could move to
spawn_blockingor a dedicated worker thread. - Compression of large snapshot bodies before transmission.
None but the input lane is parallelized today; the single-thread shape is sufficient for the rest at the current scale.