This document describes the high-level architecture and concurrency model
of radicle-node: the threads it runs, how they communicate, and how a
git fetch flows through the system. The networking event loop itself is
described in more detail in event-loop.md.
Threads
The node is organised around a single core thread plus a few supporting threads. State that belongs to the protocol lives entirely on the core thread, so it needs no locking; blocking work is pushed out to a worker pool.
service— the core thread. Runs the reactor event loop, which drives theWire(the protocol/wire-format handler) and, through it, theService(the protocol state machine: gossip, repositories, peers). Spawned inruntime.rsviaReactor::new.control— listens on the Unix control socket forradCLI commands and spawns a short-lived thread per connection. Seecontrol.rsand control-protocol.md.signals— receives OS signals (SIGTERM/SIGINT/SIGHUP) and triggers graceful shutdown.worker#0..N— a fixed pool (sizeconfig.workers) of threads that perform blocking git fetch/clone/pull operations. Spawned inworker.rs.
Component diagram
graph TB subgraph external["External"] CLI["rad CLI"] PEERS["Remote peers<br/>(TCP)"] OS["OS signals"] end subgraph node["radicle-node process"] subgraph reactorThread["service thread (single-threaded event loop)"] REACTOR["Reactor<br/>mio::Poll"] WIRE["Wire<br/>(ReactionHandler)"] SERVICE["Service<br/>(protocol state machine)"] REACTOR --- WIRE WIRE --- SERVICE end CTRL["control thread<br/>(Unix socket listener)"] CTRLCONN["per-connection<br/>command threads"] SIG["signals thread"] subgraph pool["Worker pool"] W0["worker#0"] W1["worker#1"] WN["worker#N"] end end PEERS <-->|"TCP I/O (non-blocking)"| REACTOR OS --> SIG CLI <-->|"Unix socket<br/>line-delimited JSON"| CTRL CTRL -->|spawns| CTRLCONN CTRLCONN -->|"Control cmd<br/>(unbounded chan + Waker)"| REACTOR SIG -->|"Shutdown (control chan)"| REACTOR WIRE -->|"Task: fetch request<br/>(bounded chan, cap 1024)"| pool pool -->|"TaskResult<br/>(via Handle → control chan + Waker)"| REACTOR WIRE <-->|"git protocol bytes<br/>(per-stream bounded chans)"| pool
Channels
All cross-thread communication is by message passing over channels; the
reactor is woken from its poll() via an mio Waker.
- Control channel (unbounded) — the single entry point back into the
reactor. Control commands from the CLI, worker results, and shutdown
signals all funnel into it (
reactor.rs), each followed by a wake. - Worker task channel (bounded, cap 1024) —
Wire→ worker pool. Carries aTaskdescribing a fetch to perform (runtime.rs). - Worker result path — worker → reactor. A worker reports a
TaskResultback through theHandle, which enqueues it on the control channel (runtime/handle.rs). - Per-stream channels (bounded, cap 64) — bidirectional git protocol
bytes between
Wireand a worker for the duration of a fetch, keyed byStreamId(worker/channels.rs).
Where the worker channels are created
There are two distinct sets of channels for talking to the workers, created in different places and at different times.
-
The task channel is created once, at startup, in
Runtime::init(runtime.rs):let (worker_send, worker_recv) = chan::bounded::<worker::Task>(MAX_PENDING_TASKS);worker_recvis cloned into every worker thread, so they all receive from the same channel — it acts as a work-stealing queue.worker_sendis held byWireto dispatch tasks. -
The per-stream channels are created per fetch, in
Channels::pair(worker/channels.rs):let (l_send, r_recv) = chan::bounded::<ChannelEvent<T>>(MAX_WORKER_CHANNEL_SIZE); let (r_send, l_recv) = chan::bounded::<ChannelEvent<T>>(MAX_WORKER_CHANNEL_SIZE);Two channels are created because the git stream is bidirectional: one carries
Wire→ worker, the other worker →Wire. They are bundled into twoChannelshalves — one half stays withWire, the other is moved into theTasksent to the worker.pairis called fromWirewhen a stream is opened or registered (Streams::open/registerinwire.rs).
How workers reach the network
A worker never touches the TCP socket. The reactor is the sole owner of
the socket; the worker only ever reads and writes its per-stream
channels, and Wire relays between the two.
The Channels handed to a worker expose io::Read/io::Write (via
ChannelReader/ChannelWriter in worker/channels.rs), so the git
fetch code believes it is doing ordinary blocking socket I/O — but those
reads and writes are backed by channels, not the network. Wire
translates in both directions:
- Worker → peer. The worker writes git bytes into its channel.
Wiredrains them (flushinwire.rs), wraps each chunk in a wireFrame::git(stream, data), and emits areactor::Action::Send; the reactor performs the actual TCP write. - Peer → worker. The reactor reads from the socket and hands the
bytes to
Wireas aSessionEvent::Data.Wiredecodes the frames and, for a git frame, pushes the payload into the matching stream’s channel (channels.send(ChannelEvent::Data(..))). The worker, blocked inChannelReader::read, wakes and consumes it.
graph LR subgraph worker["worker thread"] GIT["radicle_fetch<br/>clone/pull"] CH["Channels<br/>io::Read / io::Write"] GIT <--> CH end subgraph service["service thread"] WIRE["Wire<br/>Frame encode/decode<br/>(StreamId mux/demux)"] REACTOR["Reactor<br/>mio::Poll"] WIRE <--> REACTOR end PEER["Remote peer"] CH <-->|"ChannelEvent::Data<br/>(per-stream chan)"| WIRE REACTOR <-->|"TCP bytes<br/>(non-blocking)"| PEER
Two consequences follow:
- Multiplexing. A single TCP connection to a peer carries many
concurrent git streams plus gossip.
Wiretags every git frame with aStreamId, which is how it demultiplexes inbound bytes to the right worker channel and tags outbound bytes back onto the right stream. - No blocking on the core thread. The worker blocks on channel reads (on its own thread), while the reactor only ever does non-blocking socket I/O and quick channel sends. Neither blocks the other.
Git fetch flow
Git operations are blocking, so they run on the worker pool rather than
on the core thread. While a fetch is in flight, network frames are
relayed between Wire and the worker over the per-stream channels.
sequenceDiagram participant S as Service participant W as Wire (reactor) participant P as Worker thread participant N as Remote peer S->>W: Io::Fetch action W->>W: Streams::open() → bidirectional channels W->>P: Task (FetchRequest::Initiator) [bounded chan] W->>N: Frame::Control::Open N-->>W: git pack frames W-->>P: ChannelEvent::Data [per-stream chan] Note over P: blocking radicle_fetch::clone()/pull() P-->>W: git request bytes [per-stream chan] W-->>N: forward frames P->>W: TaskResult [Handle → control chan + Waker] W->>S: service.fetched(rid, nid, result) W->>N: Frame::Control::Close
An incoming fetch (where a peer fetches from us) follows the mirror of
this: Wire receives a Frame::Control::Open, registers a stream and
dispatches a Task with FetchRequest::Responder, then forwards
incoming frames to the worker over the per-stream channel until the
worker produces a result.
Why it’s structured this way
- No locks in the hot path. All protocol state lives on the single
servicethread, so the reactor,Wire, andServicenever contend on shared state. The reactor multiplexes many connections on that one thread. - Blocking work is isolated. Git fetches would stall the event loop, so they are handed to the worker pool and their results delivered back asynchronously through the control channel.
- One synchronization point. Everything that needs to re-enter the
core thread — CLI commands, worker results, shutdown — arrives through
the reactor’s control channel and wakes it via the mio
Waker.