This document collects what we know about the move of radicle-node from the mio reactor over Noise/TCP to iroh over QUIC. It covers the design decisions, the iroh behaviour that the node depends on, and the open risks. It describes the stack of changes on top of msnx (“node: Switch to iroh”) as of 2026-10-21. For the thread and channel diagram, see Node Architecture.
Change history
The migration is a stack of jj changes. The main ones are:
okuxyuqz“radicle: Add iroh address” andoxoqusxz“radicle: ChangeAddressfrom struct to enum” addAddress::Iroh.msnx“node: Switch to iroh” replaces the transport. It deletesreactor/,wire.rs,worker/channels.rsand themiodependency, and rewritesruntime.rson tokio and iroh.urxyxpwo“node/runtime: Implementiroh::protocol::ProtocolHandler” moves gossip and Git intoGossipProtocolHandlerandGitProtocolHandler.ykrsokwt“node: Removecrossbeam-channel” replaces crossbeam with std and tokiompsc.pzswmrxl“node/runtime: Publish external addresses via iroh” passesexternal_addressesto the iroh endpoint builder.ozpvwpws“node: Use iroh’sMemoryLookupto store addresses” gives iroh the known addresses of a peer before a dial. An earlier change, “node: Register direct addresses before dialing iroh peers”, did the same job and is no longer in the stack.
Architecture in brief
- The service stays synchronous.
Serviceruns on its own std thread (run_service). It receives every event as aServiceInputon a stdmpscchannel, and ticks when no input arrives for 250 ms. It sends eachIoto the tokio runtime on a tokio unboundedmpscchannel. Both channel types are re-exported fromruntime::mpsc. - tokio runs the network.
mainbuilds a multi-thread tokio runtime and callsblock_on(Runtime::run()), soRuntime::runruns on the main thread. Itsselect!loop dispatchesIo, joins network tasks and polls OS signals every 100 ms. - iroh owns the sockets. One iroh
Endpointreplaces the mio reactor. ARoutersends each inbound connection to a handler by its ALPN:radicle/gossip/1orradicle/git/1. - No worker pool. Each Git job runs in
tokio::task::spawn_blocking.SyncIoBridgegives the blockingradicle_fetchcodeio::Readandio::Writeon the QUIC stream. - Network presets. On a Test network, relays are off (
presets::Minimal). Otherwise the node usescrate::iroh::presets::Radicle.
Why crossbeam went away
Crossbeam was not new in msnx; the old reactor and worker pool already used it. In msnx only ServiceInput and Controller still used it, and ykrsokwt removed it. Crossbeam channels block the OS thread, so async code can’t await them. That suits the synchronous service thread, which is why std mpsc now does that job. Async code receives the output side on a tokio channel.
Design decisions
Separate connections for gossip and Git
Gossip and Git use separate QUIC connections, not separate streams on one connection. Each endpoint.connect(id, ALPN) call makes a new handshake. dispatch opens one gossip connection for each peer, and run_outgoing_git opens a new connection for every fetch. All connections share the one endpoint and its UDP socket.
ALPN is agreed once per connection, during the TLS handshake. Separate connections therefore give, without extra code:
- Dispatch by protocol. The
Routerpicks the handler, so the node needs no stream-type header or demultiplexer. Before,WireandStreamIddid this work. - Separate versions.
radicle/gossip/1andradicle/git/1can change separately. - Separate limits. The gossip handler sets
max_concurrent_bi_streams(1)andmax_concurrent_uni_streams(0). This works only because Git never uses that connection. - Separate lifecycles. A fetch failure or timeout closes only its Git connection. The gossip session stays up.
The cost is one handshake for each fetch. This is small next to a Git fetch (see iroh behaviour). The iroh maintainers support this pattern: “We’re quite a fan of using multiple connections between the same nodes for different ALPNs”, and “adding a 2nd connection is very cheap”. The only downside they name is that “the congestion controllers are not aware of each other”, which “is usually not a big issue”.
A fetch does not need a gossip session
Before, a fetch went over the gossip peer’s TCP connection, so the service refused a fetch to a peer without a session. Now:
Io::Fetchcarries the peer’s known addresses from the address database, with banned addresses removed. iroh falls back to discovery by node ID.- A fetch failure or timeout no longer disconnects the gossip peer.
- A gossip disconnect no longer cancels fetches to that peer.
dequeue_fetchesgoes through the queued fetches, not the connected sessions.
The test fetch_without_gossip_session covers this.
Duplicate gossip connections
Two peers can dial each other at the same time. GossipProtocolHandler::run settles this without any message exchange: it compares the two NodeIds byte by byte (PublicKey derives Ord). The node with the higher ID prefers its outbound connection, and the node with the lower ID prefers its inbound one. That is the same QUIC connection, so both sides keep it. A new connection replaces an existing one only if the new one is preferred and the old one is not. A self-connection is closed at once.
SharedForGossip::jobs is a generation counter, not a job ID. Each gossip connection takes a new value and stores it in GossipPeer. On exit, the connection removes the peer entry and sends ServiceInput::Disconnected only if the stored value is still its own. This stops a replaced connection from removing the new connection’s entry, or from sending a false disconnect.
radicle-fetch takes a reader and a writer
Transport and Handle in radicle-fetch now take separate Read and Write types, not a ConnectionStream. The SignalEof trait and its “eof” control message are gone. Before, the Git protocol shared one TCP connection with other streams, so the fetcher had to send a special message to say it was done. Now each fetch has its own QUIC stream, and the fetch ends with a zero-length write.
Shutdown uses two signals
Runtime::run stops two kinds of work, so it uses two signals:
stopping, anArc<AtomicBool>, is for the blocking control-socket threads. They can’t await a tokio channel.- A tokio
watchchannel is for the async iroh tasks.GossipProtocolHandler::runselects onshutdown.changed().
The order is:
- A signal or a control command sends
ServiceInput::Shutdown. run_servicesendsRuntimeOutput::Shutdown, and theselect!loop ends.- The runtime sets
stoppingand sends on thewatchchannel. - It waits up to
NETWORK_TASK_SHUTDOWN_TIMEOUT(3 s) for network tasks, then aborts them. - It waits up to
IROH_SHUTDOWN_TIMEOUT(3 s) forRouter::shutdown. - It joins the control thread, then the service thread.
The control listener is now non-blocking and checks stopping every 25 ms. The Command::Subscribe loop must also check stopping. If it doesn’t, a rad node events subscriber keeps its handler thread alive, and the join in step 6 hangs.
iroh behaviour that matters
These notes are from the iroh 1.1.0 source and docs.
- Connections to one peer share state. iroh runs one
RemoteStateActorfor each remote endpoint. Its doc comment says: “This actor manages all connections to the remote endpoint. It will trigger holepunching and select the best path.” A new gossip or Git connection joins the existing actor, so it reuses the known path and does not start hole punching again. - Path data outlives connections. The actor stays alive for 60 s after its last connection closes (
ACTOR_MAX_IDLE_TIMEOUT), “to keep data about previous path around for subsequent connections”. - 0-RTT is possible but not used. iroh caches TLS tickets for 0-RTT: by default 8 for each remote endpoint and 32 endpoints.
run_outgoing_gituses a plainconnect. 0-RTT data can be replayed, so adding it needs care. Endpointdoes not pool connections. Reuse is the application’s job. The maintainers point toiroh-willowas an example and advise againstWeakConnectionHandle.MemoryLookupdoes not take priority.AddressLookupServices::resolvequeries all lookup services at the same time (memory, Pkarr, mDNS, DHT) and merges the results.MemoryLookupusually answers first, because it does no network I/O. The dial starts as soon as the first addresses arrive. Later results add to the set of paths and do not replace earlier ones. An unknown node ID returnsNone, so other services handle it. A lookup starts only when no path is selected and no lookup is running. Addresses passed directly in anEndpointAddrgo in first.
Risks and open questions
- No limit on concurrent Git jobs.
GitProtocolHandleraccepts every inbound Git connection and starts aspawn_blockingjob for it.worker::Config::capacityis stored but not used, soconfig.workershas no effect. The only limit is tokio’s blocking pool, which allows 512 threads by default. A peer can open many Git connections and keep manyupload-packprocesses busy. Authorization still runs (is_authorized), but any public repository can be fetched many times in parallel. Suggested fix: atokio::sync::Semaphorearoundrun_git_worker, sized fromconfig.workers, plus a limit for each peer. Requests over the limit should fail at once with a clear close reason. Don’t setmax_blocking_threadstoconfig.workersinstead, because DNS lookups (lookup_host) share the blocking pool. - The Git connection has no stream limits. Unlike the gossip handler,
GitProtocolHandlerdoes not set stream limits. It accepts one stream, and the connection closes whenrunreturns. On the serving side,recv.take(limit)uses the defaultFetchPackSizeLimit. This caps the bytes the peer sends (its upload-pack request), not the size of the pack we send. - One handshake for each fetch. If this cost matters, keep one Git connection open for each peer and open a new stream for each fetch. That keeps the separate ALPN. 0-RTT is another option.
- Tor, I2P and proxies are not supported. The node fails at startup if a SOCKS5 proxy, Tor or I2P is configured. When dialing, Tor and I2P addresses are skipped with a warning. TODOs suggest custom iroh transports (
iroh::TransportAddr::Custom). - Port docs changed.
listen,ConnectAddressandAddressnow say UDP port where they said TCP port. Existing configs and firewall rules that open only TCP will not work. - Announcements always include iroh. The node announcement adds
Address::Irohwhen there is room, and each bootstrap seed gets an iroh connect address.
Small cleanups (suggested only)
- Rename
SharedForGossip::jobstogenerationorsession. It is a different counter fromSharedForWorker::jobs, which is a real job ID returned inTaskResult. - The “wake” connect to the control socket after
stoppingis set may be unneeded, because the listener checks the flag every 25 ms. It also runs only for a bound socket, not for a socket from systemd. stoppingcould useRelaxedordering, because it carries no other data.Release/Acquireis also correct.NETWORK_TASK_SHUTDOWN_TIMEOUThas a TODO asking whether it should be configurable.
Resolved
- The handle error variant
ReceiveServiceTimeountis renamed toReceiveServiceTimeoutin the working copy. crossbeam-channelis removed (ykrsokwt).
Sources
- How should I handle connection management/reuse · iroh Discussion #3087
- Endpoints - iroh docs
- Write your own Protocol - iroh docs
- The iroh 1.1.0 crate source:
socket::remote_map::remote_state,tlsandaddress_lookup.