This document maps the code that drives a git fetch onto the crates and modules it lives in. It complements architecture.md, which describes the threads and channels at runtime. Here the focus is where the code lives and why it’s split the way it is, using the fetch path as the worked example.

The big split: protocol vs runtime

The single most important thing to internalise is that the protocol brain and the OS-facing body live in two different crates.

  • radicle-protocol is pure logic: state machines, the wire format, message types. It does no I/O, owns no threads, and touches no sockets. This is what makes it testable in isolation.
  • radicle-node is the runtime: the mio reactor, the worker thread pool, the control socket, and the glue that drives the protocol crate.
graph TB
    subgraph proto["radicle-protocol — pure logic, no I/O, no threads"]
        SVC["service.rs<br/>Service: state machine"]
        FET["fetcher/<br/>who's fetching what"]
        PWIRE["wire.rs<br/>Encode/Decode + Frame format"]
        PWORK["worker.rs<br/>FetchRequest / FetchResult types"]
    end

    subgraph node["radicle-node — runtime, threads, sockets"]
        NWIRE["wire.rs<br/>Wire: ReactionHandler"]
        NWORK["worker.rs<br/>worker pool, blocking git"]
        REACTOR["reactor.rs / runtime.rs<br/>mio event loop, channels"]
    end

    FETCHC["radicle-fetch<br/>actual git pack protocol"]

    NWIRE -->|"drives"| SVC
    NWIRE -->|"uses types from"| PWIRE
    NWORK -->|"runs"| FETCHC
    SVC -->|"owns"| FET
    NWORK -->|"FetchResult types"| PWORK

The seam between the two crates is a single re-export in radicle-node/src/lib.rs:

pub(crate) use radicle_protocol::service;

So when node code refers to crate::service::Service, it is literally radicle_protocol’s Service. The node crate re-exports the protocol crate and wraps it with threads and sockets.

Two files called wire.rs

A common source of confusion: there are two wire.rs files, and they do different jobs.

FileResponsibility
radicle-protocol/src/wire.rsThe wire format only: the Encode/Decode traits and the Frame types. No threads.
radicle-node/src/wire.rsThe Wire struct, the reactor’s ReactionHandler. The glue that talks to sockets and the worker pool.

When architecture.md refers to “the Wire”, it means the node-side reaction handler, not the protocol-side wire format.

The layers around a fetch

A fetch passes through several layers. The protocol crate provides the top of the stack (decision making), the node crate provides the bottom (execution).

graph TB
    REACTOR["Reactor (mio)<br/>radicle-node/reactor.rs"]
    NWIRE["Wire (ReactionHandler)<br/>radicle-node/wire.rs"]
    SVC["Service (state machine)<br/>radicle-protocol/service.rs"]
    FSVC["FetcherService<br/>fetcher/service.rs<br/>subscriber coalescing"]
    FSTATE["FetcherState<br/>fetcher/state.rs<br/>the active marker"]
    POOL["Worker pool<br/>radicle-node/worker.rs"]
    RFETCH["radicle-fetch<br/>git pack exchange"]

    REACTOR <--> NWIRE
    NWIRE -->|"service.fetch / service.fetched"| SVC
    SVC -->|"owns"| FSVC
    FSVC -->|"wraps"| FSTATE
    NWIRE -->|"Task (bounded chan)"| POOL
    POOL -->|"handle.fetch()"| RFETCH
    POOL -.->|"TaskResult via Handle"| REACTOR

The fetcher onion

The fetcher module inside radicle-protocol is itself a three-layer onion. Each layer has a single job, and the boundaries are deliberate so the innermost layer can be tested as a pure state machine.

LayerFileJob
Serviceservice.rs:1057 (fetched())Translates worker results into protocol events: announces inventory, updates routing, emits events.
FetcherServicefetcher/service.rs:22Coalesces multiple waiters (“subscribers”) on the same (rid, node, refs) fetch.
FetcherStatefetcher/state.rs:121The pure state machine: the active map plus per-node queues.

FetcherState is a clean command to event machine (fetcher/state.rs:153, handle()), which is why its tests live right beside it under fetcher/test/.

The active marker

For any repository there can be at most one fetch in flight. The fetcher enforces this with a single map at fetcher/state.rs:123:

active: BTreeMap<RepoId, ActiveFetch>,

Its entire lifecycle is two methods.

stateDiagram-v2
    [*] --> Idle
    Idle --> Active: fetch() inserts (state.rs#L190)
    Active --> Idle: fetched() removes (state.rs#L213)
    Active --> Active: fetch() again → AlreadyFetching / Queued (state.rs#L179)
    note right of Active
        If fetched() is never called,
        the repo is stuck Active forever.
        Every future fetch() returns
        AlreadyFetching / Queued.
    end note
  • Inserted at state.rs:190 when a fetch starts (event::Fetch::Started).
  • The guard at state.rs:179: if the repo is already in active, return AlreadyFetching or enqueue. This deduplication is what makes the marker a trap if it is never cleared.
  • Removed only by state.rs:213, fetched().

If fetched() is never called, the map entry is immortal and the repo can never be fetched again until the node restarts.

End-to-end flow

The clean path always ends in service.fetched(), which removes the active marker. The diagram below shows the file and line where each hop lives.

sequenceDiagram
    participant S as Service<br/>(protocol)
    participant W as Wire<br/>(node/wire.rs)
    participant P as Worker<br/>(node/worker.rs)
    participant F as radicle-fetch

    S->>S: fetch() inserts active marker<br/>state.rs:190
    S->>W: Io::Fetch action
    Note over W: wire.rs:1014
    W->>P: Task (FetchRequest::Initiator)<br/>wire.rs:1040
    P->>F: handle.fetch()<br/>worker.rs:260
    F-->>P: FetchResult::Initiator<br/>worker.rs:135
    P->>W: TaskResult via Handle<br/>worker.rs:108
    Note over W: worker_result, wire.rs:391
    W->>S: service.fetched() removes marker<br/>wire.rs:426

The two crates meet at exactly two points in this flow:

  1. Service emits an Io::Fetch action, which the node’s Wire turns into a Task for the worker pool (wire.rs:1014).
  2. The worker reports a TaskResult back through the Handle, which the node’s Wire turns into a service.fetched() call (wire.rs:391).

Everything between those two points is execution (threads, sockets, git); everything outside them is protocol logic.

Where it can leak

Two disconnect-timing edge cases can short-circuit the flow before service.fetched() runs, leaving the active marker stuck. Both live in the node’s wire.rs, on the boundary between the runtime and the protocol.

Leak 1 — fetch dispatch, wire.rs:1023:

let Some((fd, Peer::Connected { link, streams, .. })) = self.peers.lookup_mut(&remote)
else {
    log::debug!(target: "wire", "Peer {remote} is not connected: dropping fetch");
    continue;   // marker already inserted at state.rs:190, never removed
};

Leak 2 — worker result, wire.rs:416:

} else {
    log::debug!(target: "wire", "Peer {nid} is not connected; ignoring fetch result");
    return;   // returns before service.fetched()
};

There is also a legitimate cleanup path that does clear state on disconnect: wire.rs:470 (cleanup()) calls service.disconnected(), which in the protocol calls fetcher.cancel() (fetcher/state.rs:234). The bug is that the two leak points can race ahead of, or bypass, that path: a late worker result or a queued Io::Fetch arrives for a peer that is already gone, and the early return/continue skips both fetched() and cancel().

Suggested reading order

To build understanding from the inside out:

  1. fetcher/state.rs — small, pure, no I/O. The active/queues/Config model and fetch/fetched/cancel/dequeue.
  2. fetcher/service.rs — the subscriber layer wrapped around it.
  3. service.rs:1057 (fetched()) and the nearby Io::Fetch emission — how the state machine becomes I/O.
  4. radicle-node/src/wire.rsworker_result (391), the Io::Fetch handler (1014), and cleanup (470). The reactor-facing glue.
  5. radicle-node/src/worker.rsprocess/_process (92/125) and the handle.fetch() call into radicle-fetch (260).