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 drivesWire(the reactor’sReactionHandler, incrates/radicle-node/src/wire.rs) and, through it, theService(the protocol state machine: gossip, repositories, peers). Note thatServicelives in theradicle-protocolcrate (crates/radicle-protocol/src/service.rs) and is re-exported by the node; the wire format (Encode/Decode,Frame) is also inradicle-protocol(crates/radicle-protocol/src/wire.rs), distinct from the node-sideWirehandler. The thread itself is spawned inruntime.rsviaReactor::new. See crate-organisation.md for how the crates divide this work.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.
Migration to iroh
The change “node: Switch to iroh” and the changes after it replace the mio reactor with iroh, which runs over QUIC. The sections above describe the old design. The diagrams below show the new design as it is at the tip of the iroh changes.
Startup
main in radicle-node/src/main.rs starts the node in two steps. First, Runtime::init does all synchronous setup and starts the service thread. Then execute creates the tokio runtime and runs Runtime::run on it with block_on. Runtime::run runs on the main thread, and the tasks it starts run on the tokio worker threads.
graph TD MAIN["main()"] --> PARSE["parse_options()<br/>initialize_logging()"] MAIN --> EXEC["execute(options)"] EXEC --> CFG["load config, unlock keystore,<br/>verify fingerprint"] EXEC --> SIGI["signals::install(notify)"] SIGI -.->|spawns| SIGT(["signals thread"]) EXEC --> INIT["Runtime::init(...)"] INIT --> OPEN["open storage, COB cache,<br/>notifications, node database"] INIT --> SVC["Service::new + initialize"] INIT --> CHANS["create channels:<br/>std mpsc (ServiceInput)<br/>tokio mpsc (RuntimeOutput)"] INIT -.->|spawns| SVCT(["service thread<br/>run_service"]) INIT --> HANDLE["Handle::new"] INIT --> BIND["Runtime::bind<br/>(control socket)"] EXEC --> BUILD["tokio::runtime::Builder<br/>::new_multi_thread().enable_all().build()"] BUILD -.->|"starts (one per core)"| WORKERS(["tokio worker threads"]) BUILD -.->|"starts empty, grows on demand"| BPOOL(["tokio blocking pool"]) EXEC --> BLOCKON["rt_tokio.block_on(Runtime::run())<br/>(on the main thread)"] BLOCKON --> AD["AddressDiscovery::new"] BLOCKON --> EP["Endpoint::builder(...).bind()"] BLOCKON --> RT["Router::builder(endpoint)<br/>.accept(ALPN_GOSSIP, ALPN_GIT).spawn()"] BLOCKON -.->|spawns| CTRLT(["control-listener thread<br/>control::listen"]) BLOCKON --> LOOP["select! loop until Shutdown"] LOOP --> DISPATCH["dispatch(io) → JoinSet::spawn"] DISPATCH -.->|runs on| WORKERS DISPATCH -.->|"Git jobs via spawn_blocking"| BPOOL
Solid arrows are calls. Dashed arrows lead to threads that the call starts.
Components
graph TB subgraph external["External"] CLI["rad CLI"] PEERS["Remote peers<br/>(QUIC via iroh)"] OS["OS signals"] end subgraph node["radicle-node process"] SIG["signals thread"] CTRL["control-listener thread<br/>(non-blocking Unix socket)"] CTRLCONN["per-connection<br/>command threads"] subgraph serviceThread["service thread (std thread)"] SERVICE["Service<br/>(protocol state machine)<br/>run_service loop"] end subgraph tokioRt["tokio multi-thread runtime"] subgraph mainThread["main thread (block_on)"] RUN["Runtime::run<br/>select! loop + dispatch"] end subgraph workerThreads["worker threads (one per core)"] ENDPOINT["iroh Endpoint<br/>+ MemoryLookup"] ROUTER["iroh Router"] GOSSIP["GossipProtocolHandler<br/>one task per peer<br/>ALPN radicle/gossip/1"] GITH["GitProtocolHandler<br/>one task per Git connection<br/>ALPN radicle/git/1"] end subgraph blockingPool["blocking pool (up to 512 threads)"] BLOCKING["spawn_blocking<br/>Worker::process<br/>(SyncIoBridge)"] end end end PEERS <-->|"QUIC"| ENDPOINT ENDPOINT --- ROUTER ROUTER -->|"inbound gossip"| GOSSIP ROUTER -->|"inbound Git"| GITH RUN -->|"Io::Connect → connect(ALPN gossip)"| GOSSIP RUN -->|"Io::Fetch → connect(ALPN Git)"| GITH RUN -->|"Io::Write<br/>(tokio mpsc, cap 32)"| GOSSIP GITH --> BLOCKING OS --> SIG SIG -->|"Signal (std mpsc)"| RUN CLI <-->|"Unix socket<br/>line-delimited JSON"| CTRL CTRL -->|spawns| CTRLCONN CTRLCONN -->|"ServiceInput::User"| SERVICE RUN -->|"ServiceInput::Shutdown"| SERVICE GOSSIP -->|"ServiceInput::Connected / Message / Disconnected"| SERVICE BLOCKING -->|"ServiceInput::Worker (TaskResult)"| SERVICE SERVICE -->|"RuntimeOutput::Io<br/>(tokio unbounded mpsc)"| RUN
The main differences from the old design:
- The service stays synchronous.
Serviceruns on its own std thread. It receives every event as aServiceInputon a stdmpscchannel. It ticks when no input arrives for 250 ms. It sends eachIoto the tokio runtime on a tokio unbounded channel. - iroh owns the network. One iroh
Endpointreplaces the mio reactor, and QUIC does the multiplexing thatWireandStreamIddid before. ARoutersends each inbound connection to a handler by its ALPN. - Gossip and Git use separate connections. Each gossip peer has one connection with one bidirectional stream. Each Git fetch or upload has its own connection. A fetch does not need a gossip session, and a fetch failure does not disconnect the gossip peer.
- No worker pool. Each Git job runs in
tokio::task::spawn_blocking.SyncIoBridgegives the blockingradicle_fetchcodeio::Readandio::Writeon the QUIC stream. The per-stream channels and the task queue are gone. - Address lookup. The node gives iroh the known direct addresses through a
MemoryLookupbefore it dials. If no direct address works, iroh falls back to relays and discovery.