swactor/docs/development_history/distribution/IROH_TRANSPORT.md
Zachery Aaron Shores-Chmielewski 2f767a6ed8 feat: add support for iroh as the transport layer (#40)
Iroh gives us a lot as a transport layer. Lets make use of it.
Co-authored-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
Co-committed-by: Zachery Aaron Shores-Chmielewski <zacheryasc@gmail.com>
2026-02-15 09:47:05 +00:00

31 KiB
Raw Blame History

iroh P2P Transport — Development History

Covers the integration of iroh as an alternative P2P transport for the distribution layer: removing SocketAddr from all protocol types, adding TCP address hints at the wire-frame level, feature-gating TCP, implementing the iroh driver, updating the node binary for transport selection, and removing inherently flaky multi-threaded gossip tests.

~29 files changed · ~1,360 insertions · ~970 deletions (excluding Cargo.lock)

Branch: iroh


Table of Contents

  1. Overview & Motivation
  2. What Was Built
  3. Development Phases
  4. Protocol Layer: Transport-Agnostic Refactor
  5. TCP Driver: Address Book & Wire Frame Hints
  6. Feature-Gated TCP Transport
  7. IrohDriver — QUIC P2P Transport
  8. Node Binary: Transport Selection
  9. Flaky Multi-Threaded Gossip Tests
  10. Design Decisions & Tradeoffs
  11. Known Gaps & Future Improvements
  12. Test Coverage Summary

1. Overview & Motivation

The distribution layer used raw TCP with no encryption, no NAT traversal, and SocketAddr baked into every protocol type — from NodeRecord to SwimAction to MemberEntry. This created two problems:

  1. No security or reachability: TCP provides no built-in authentication, encryption, or NAT hole-punching. Nodes behind NAT or across WAN boundaries cannot form clusters without manual port-forwarding.

  2. Transport is not pluggable: SocketAddr in protocol types meant every handler, every message, and every test was coupled to TCP addressing. Adding a new transport required modifying the entire protocol stack.

iroh provides QUIC-based peer-to-peer connections with built-in TLS (ed25519 authentication), automatic NAT hole-punching with relay server fallback, and identity-based addressing. The swactor NodeId([u8; 32]) and iroh PublicKey are both ed25519 public keys, making identity alignment trivial — the same 32 bytes serve as both the SWIM node identifier and the iroh network address.

From DOCKER_REALIZATION.md §13:

"No TLS — All TCP traffic is plaintext. Fine for a test cluster on a private network; not suitable for production."

This work closes that gap by making the entire protocol layer transport-agnostic (addressed by NodeId only) and providing iroh as a production-grade alternative to TCP.


2. What Was Built

Component Location Nature
Protocol refactor 12 source + 7 test files in crates/distribution/ Refactor: remove SocketAddr from all protocol types
TCP address book crates/distribution/src/driver.rs Enhance: NodeId → SocketAddr mapping + wire frame hints
Feature gates Cargo.toml, lib.rs Config: tcp and iroh features
IrohDriver crates/distribution/src/iroh_driver.rs (510 lines) New: iroh QUIC transport
iroh tests crates/distribution/tests/iroh_driver.rs (69 lines) New: 3 integration tests
Node binary crates/node/Cargo.toml, crates/node/src/main.rs Enhance: --transport tcp|iroh selection
Simulation cleanup crates/simulation/ Fix: remove 5 flaky MT gossip tests

3. Development Phases

Phase 1 — Remove SocketAddr from all protocol and message types

The largest change. Every SocketAddr in the protocol layer was removed — NodeRecord, MemberEntry, SwimAction, NodeAction, messages, Kademlia types, DistributedNode, and all 7 test files. After this phase, the entire protocol stack addresses nodes exclusively by NodeId. Transport-specific addressing lives in the driver layer.

Phase 2 — TCP driver address book and wire frame hints

The TCP driver gained a PeerAddressBook (HashMap<NodeId, SocketAddr>) and the wire frame format was extended with AddressHint sections. The driver learns addresses from incoming frames and includes its own address as a hint on every outgoing message. Join responses include all known member addresses.

Phase 3 — Feature-gate TCP transport

TCP-specific modules (transport.rs, driver.rs) gated behind #[cfg(feature = "tcp")]. The distribution crate compiles cleanly with --no-default-features, producing a transport-agnostic library with just the protocol state machines.

Phase 4 — IrohDriver implementation

New iroh_driver.rs module (510 lines) behind #[cfg(feature = "iroh")]. Same driver pattern as NodeDriver: sync API wrapping a tokio runtime, with QUIC stream-per-message transport. Identity alignment via shared ed25519-dalek bytes between iroh's SecretKey and swactor's Keypair.

Phase 5 — Node binary transport selection

CLI gained --transport <tcp|iroh> and --seed-node-id <hex> flags. Transport-specific code gated by features: cargo run -p node --features tcp or cargo run -p node --features iroh.

Phase 6 — Testing and flaky test removal

3 new iroh driver integration tests. Investigated and removed 5 flaky multi-threaded gossip property tests whose failures were inherent to the sleep-based MT simulation harness.


4. Protocol Layer: Transport-Agnostic Refactor

The Problem

SocketAddr appeared in 19 types across 12 source files:

  • NodeRecord.addr, MemberEntry.addr — membership data
  • SwimAction::SendPing.to_addr, SwimAction::SendPingReq.relay_addr — probe actions
  • NodeAction::SendPing.to_addr, NodeAction::SendJoinRequest.to_addr — driver actions
  • Ping.from_addr, PingReq.target_addr, JoinRequest.addr — wire messages
  • NodeEntry.addr — Kademlia routing table
  • LookupAction::Query.addr, LookupAction::Done — lookup results
  • DistributedNodeConfig.listen_addr, DistributedNode::join() — node config
  • SwimNode.self_addr, SwimNode::new(self_addr) — SWIM state

Every protocol handler took SocketAddr parameters. Every test constructed SocketAddr literals. Adding a non-TCP transport would require threading a different address type through every layer — or worse, making address types generic (adds complexity everywhere for a concern that belongs in the driver).

The Solution

Remove all SocketAddr from protocol types. Nodes are addressed by NodeId only. The driver (TCP or iroh) maintains its own address resolution.

Types changed:

Type Before After
NodeRecord { node_id, addr, state, incarnation } { node_id, state, incarnation }
MemberEntry { node_id, addr, state, incarnation } { node_id, state, incarnation }
NodeEntry { node_id, addr } { node_id }
SwimAction::SendPing { to, to_addr, sequence } { to, sequence }
SwimAction::SendPingReq { relay, relay_addr, target, target_addr, sequence } { relay, target, sequence }
NodeAction::SendPing { to, to_addr, sequence, piggyback } { to, sequence, piggyback }
NodeAction::SendAck { to, to_addr, sequence, piggyback } { to, sequence, piggyback }
NodeAction::SendPingReq 5 fields with addrs { relay, target, sequence, piggyback }
NodeAction::SendJoinResponse { to, to_addr, members } { to, members }
Ping { from, from_addr, sequence, piggyback } { from, sequence, piggyback }
PingReq { from, target, target_addr, sequence, piggyback } { from, target, sequence, piggyback }
JoinRequest { from, addr } { from }
FindNodeResponse.closest Vec<(NodeId, SocketAddr)> Vec<NodeId>
LookupAction::Query { node_id, addr } { node_id }
LookupAction::Done.closest Vec<(NodeId, SocketAddr)> Vec<NodeId>

Methods changed:

Method Removed parameter
SwimNode::new() self_addr: SocketAddr
MemberList::apply() addr: SocketAddr
RoutingTable::insert() addr: SocketAddr
SwimNode::handle_ping() from_addr: SocketAddr
SwimNode::handle_ping_req() target_addr: SocketAddr
SwimNode::handle_join_request() from_addr: SocketAddr
DistributedNode::handle_ping() from_addr: SocketAddr
DistributedNode::handle_ping_req() target_addr: SocketAddr
DistributedNode::handle_join_request() from_addr: SocketAddr

Removed entirely:

  • SwimNode::join() — join initiation moved to driver layer
  • SwimNode::self_addr() — no transport address in protocol layer
  • NodeAction::SendJoinRequest — driver sends join directly
  • DistributedNode::listen_addr() — driver-level concern
  • DistributedNode::join() — delegated to driver
  • DistributedNodeConfig.listen_addr — driver-level concern

Snapshot fields changed:

MemberInfo.addr, NeighborInfo.addr, and DistributionNodeSnapshot.listen_addr changed from SocketAddr/String to Option<String>. The protocol layer leaves them as None; the driver enriches them from its own address resolution.


5. TCP Driver: Address Book & Wire Frame Hints

The Problem

With SocketAddr removed from protocol types, the TCP driver needs its own mechanism to resolve NodeId → SocketAddr for outgoing messages, and to learn addresses from incoming messages.

PeerAddressBook

struct PeerAddressBook(HashMap<NodeId, SocketAddr>);

impl PeerAddressBook {
    fn learn(&mut self, node_id: NodeId, addr: SocketAddr);
    fn resolve(&self, node_id: &NodeId) -> Option<SocketAddr>;
    fn all_hints(&self) -> Vec<AddressHint>;
}

The driver learns addresses from two sources:

  1. Incoming TCP connections: the sender's SocketAddr is extracted from the wire frame's address hint section
  2. Join responses: all member addresses from the responding node's address book

Wire Frame Extension

The TCP frame format gained an AddressHint section:

Before: [4B frame_len][32B dest][4B tag_len][tag_bytes][payload_bytes]
After:  [4B frame_len][32B dest][4B tag_len][tag_bytes][4B hints_len][hints_bytes][payload_bytes]

Where hints_bytes is JSON-serialized Vec<AddressHint>:

#[derive(Serialize, Deserialize)]
pub struct AddressHint {
    pub node_id: NodeId,
    pub addr: SocketAddr,
}

For most messages, the hint section contains 1 entry — the sender's own (NodeId, listen_addr). For join responses, it contains all known member addresses from the sender's address book.

Backward Compatibility

The hints_len field enables forward parsing — old code that doesn't understand hints can skip the section by reading hints_len bytes. However, old and new wire formats are not interoperable without version negotiation (a known limitation).

Snapshot Enrichment

NodeDriver::snapshot() calls self.node.snapshot() (which returns None for all address fields), then enriches MemberInfo.addr and NeighborInfo.addr from the address book, and fills listen_addr from the driver's own listen address.


6. Feature-Gated TCP Transport

crates/distribution/Cargo.toml

[features]
default = ["tcp"]
tcp = []
iroh = ["dep:iroh", "dep:tokio"]

[dependencies]
iroh = { version = "0.96", optional = true }
tokio = { version = "1", features = ["rt-multi-thread"], optional = true }

crates/distribution/src/lib.rs

#[cfg(feature = "tcp")]
pub mod transport;
#[cfg(feature = "tcp")]
pub mod driver;
#[cfg(feature = "iroh")]
pub mod iroh_driver;

Always compiled (no feature gates): types, crypto, messages, codec, swim/, kademlia/, node, registry, cache, snapshot.

Test Restructuring

transport_and_codec.rs was restructured: codec tests (JSON round-trip, registry dispatch) remain at the top level; TCP-specific tests (TcpTransport, TcpAcceptor, wire frame encoding) moved into a #[cfg(feature = "tcp")] mod tcp_transport block.

Verification

cargo check -p distribution --no-default-features compiles cleanly — the distribution crate produces a transport-agnostic library with just protocol state machines, crypto, and codec.


7. IrohDriver — QUIC P2P Transport

crates/distribution/src/iroh_driver.rs (510 lines)

IrohDriver
  ├── node: DistributedNode       — pure state machine
  ├── endpoint: iroh::Endpoint    — QUIC endpoint with TLS
  ├── rt: tokio::runtime::Runtime — owned async runtime
  └── connections: HashMap<NodeId, iroh::Connection>  — connection cache

Design: Sync API, Async Internals

The driver exposes a synchronous API (tick(), recv(), join()) matching the existing NodeDriver pattern, while internally owning a tokio runtime for iroh's async QUIC operations. All async calls go through rt.block_on():

pub fn tick(&mut self) {
    let actions = self.node.tick();
    self.send_actions(&actions);  // internally calls rt.block_on()
}

pub fn recv(&mut self) {
    let incoming = self.rt.block_on(async { self.receive_pending().await });
    for (tag, payload, from_key) in incoming {
        let response_actions = self.dispatch_incoming(&tag, &payload, from);
        self.send_actions(&response_actions);
    }
}

This keeps the main loop pattern identical between TCP and iroh — the caller runs a 100ms tick loop without caring which transport is underneath.

Identity Alignment

iroh uses ed25519 for endpoint identity. The swactor Keypair wraps the same ed25519_dalek crate. Identity alignment is achieved by reconstructing a swactor Keypair from iroh's SecretKey bytes:

let iroh_secret = endpoint.secret_key().to_bytes();
let keypair = Keypair::from_bytes(&iroh_secret);
let node = DistributedNode::with_keypair(keypair, config.node);

This ensures driver.node_id() and the iroh endpoint's public key are the same 32 bytes — messages addressed to a NodeId are routable by iroh without any translation layer.

ALPN Protocol Negotiation

const ALPN: &[u8] = b"swactor/swim/1";

iroh uses ALPN (Application-Layer Protocol Negotiation) to multiplex protocols on a single QUIC endpoint. The ALPN string identifies the SWIM protocol version, enabling future protocol upgrades without port changes.

Message Framing Over QUIC

Each SWIM message is one QUIC stream:

Unidirectional: [4B tag_len][tag_bytes][payload_bytes]
Bidirectional:  request on send side, response on recv side (JoinRequest → JoinResponse)

Unidirectional streams for fire-and-forget messages (Ping, Ack, PingReq, JoinResponse). One stream per message — clean isolation, no head-of-line blocking between messages.

Bidirectional streams for request-response (JoinRequest → JoinResponse). The joiner opens a bidi stream, writes the request, calls finish(), then reads the response from the recv side.

Connection Caching

connections: HashMap<NodeId, Connection>

On send, the driver checks the cache:

  • Hit + open: reuse the connection
  • Hit + closed: remove stale entry, reconnect
  • Miss: endpoint.connect(target_key, ALPN).await, cache the new connection

On write failure, the driver evicts the stale connection and retries once (same pattern as the TCP driver's stale connection fix from DOCKER_REALIZATION.md §12.3).

Receiving Messages

receive_pending() polls two sources:

  1. New incoming connections: endpoint.accept() with 1ms timeout, read all available streams from each new connection
  2. Cached connections: iterate existing connections, accept pending streams

Both uni and bidi streams are polled with 1ms timeouts. Messages are collected into a Vec<(tag, payload, remote_id)> and dispatched synchronously after the async poll completes.

Join Protocol

Same one-RTT protocol as TCP, adapted for iroh addressing:

  1. Joiner calls join(&[PublicKey]) — for each seed, opens a bidi stream, sends JoinRequest, reads JoinResponse
  2. Seed receives JoinRequest on a bidi stream, generates response via node.handle_join_request(), writes JoinResponse back on the same stream
  3. Joiner processes JoinResponse via node.handle_join_response(), populating the member list and routing table

Seeds are identified by iroh PublicKey rather than SocketAddr. iroh handles relay-assisted connection establishment, NAT traversal, and address discovery internally.


8. Node Binary: Transport Selection

crates/node/Cargo.toml

[features]
default = ["tcp"]
tcp = ["distribution/tcp"]
iroh = ["distribution/iroh", "dep:iroh"]

[dependencies]
distribution = { path = "../distribution" }
iroh = { version = "0.96", optional = true }

The iroh crate is a direct dependency of the node binary (not just transitive through distribution) because main.rs references iroh::RelayMode and iroh::PublicKey directly for CLI argument parsing.

CLI Changes

swactor-node --transport <tcp|iroh>
             [--listen <IP:PORT>]        # TCP mode
             [--seed <IP:PORT>]          # TCP mode
             [--seed-node-id <hex>]      # iroh mode
             [--dashboard-port <PORT>]
             [--actors <N>]
Arg Mode Purpose
--transport both tcp (default) or iroh
--listen TCP Required: listen address
--seed TCP Seed node address
--seed-node-id iroh Seed node's ed25519 public key (64-char hex)

Transport Dispatch

match args.transport.as_str() {
    #[cfg(feature = "tcp")]
    "tcp" => run_tcp(args, node_config, &handle, &dash, &stop),
    #[cfg(feature = "iroh")]
    "iroh" => run_iroh(args, node_config, &handle, &dash, &stop),
    other => { /* error: unknown transport */ }
}

Both run_tcp() and run_iroh() follow the same main loop pattern: create driver → optional join → spawn actors → snapshot loop with 100ms sleep. The only difference is driver construction and seed addressing.


9. Flaky Multi-Threaded Gossip Tests

The Problem

5 multi-threaded gossip property tests failed intermittently:

  • partition_heals_and_converges_mt — delivery_ratio as low as 0.74 (expected >0.98)
  • all_nodes_receive_all_keys_in_ring_1000_mt — delivery_ratio of 1.25 (impossible >1.0)
  • convergence_curve_is_monotonic_mt — delivery_ratio undershoot
  • fullmesh_converges_in_log_n_rounds_mt — convergence bound exceeded
  • lww_ensures_single_final_value_mt — timing-dependent value check

Root Cause 1: Sleep-Based Settling

The MT simulation harness uses thread::sleep(settle_ms) (8–10ms) to wait for message processing between rounds:

// sim.rs — MT harness
let settle_ms = (ticks_per_round as u64 * 2).max(10);
thread::sleep(Duration::from_millis(settle_ms));

Under thread scheduling pressure, gossip messages don't propagate fully before snapshots are taken. For the partition-heal test, gossip must cross a 2-edge bridge between two 25-node groups — under non-deterministic scheduling, this can take much longer than 10ms, causing undershoot.

This is inherent to the sleep-based approach. No amount of parameter tuning makes it reliable — increasing settle times slows the test suite without eliminating the race.

Root Cause 2: Snapshot Duplication

The snapshot extraction loop iterates events in reverse and breaks when the tick counter changes:

for event in log.iter().rev() {
    if event.tick != current_round_tick {
        break;
    }
    if let GossipEventKind::StateSnapshot { ref snapshot } = event.kind {
        round_snapshots.push((event.node_name.clone(), snapshot.clone()));
    }
}

In the MT harness, snapshot events can arrive with the same tick counter value but from different processing windows (the tick counter is an AtomicU64 read by actors on different threads). This causes round_snapshots to contain duplicate entries for the same node, inflating delivery_ratio above 1.0.

Resolution

All 5 MT gossip tests were removed. The single-threaded variants test the exact same protocol properties deterministically — the MT tests added no protocol coverage, only testing the harness's timing assumptions.

The MT simulation harness code (run_simulation_multi_threaded, heal_partition_via_handle) remains available for future use if a proper synchronization mechanism replaces the sleep-based approach.


10. Design Decisions & Tradeoffs

10.1 Transport-Only Integration (Keep SWIM, Swap Transport)

Choice: iroh replaces TCP at the transport layer only. SWIM protocol, Kademlia DHT, and all state machines are unchanged.

Why: SWIM and Kademlia are transport-agnostic protocols — they produce NodeActions that say "send this to NodeId X", not "send this to IP:port". The refactor to remove SocketAddr makes this separation explicit in the type system. Adding iroh required zero changes to protocol logic.

Tradeoff: iroh could provide additional capabilities (e.g., topic-based pubsub, blob sync) that could simplify parts of SWIM dissemination. These are left for future work.

10.2 Sync API with Owned Tokio Runtime

Choice: IrohDriver owns a tokio::runtime::Runtime and exposes a synchronous API via rt.block_on().

Why: The existing main loop pattern is synchronous — tick(), recv(), sleep(100ms). Rewriting the entire driver/binary to be async would be a larger change with no benefit, since the state machine is inherently synchronous. The owned runtime is contained — it doesn't leak async into the caller.

Tradeoff: block_on() burns a thread while waiting. For a single driver this is fine. For embedding multiple drivers in one process, a shared runtime would be more efficient.

10.3 Stream-Per-Message Over QUIC

Choice: Each SWIM message opens a new QUIC stream (uni for fire-and-forget, bidi for request-response).

Why: Clean isolation between messages — no framing needed beyond the tag/payload format, no head-of-line blocking between messages. QUIC streams are lightweight (no TCP handshake, just a stream ID on an existing connection). Opening and closing a stream is comparable to sending a single UDP packet in terms of overhead.

Tradeoff: Higher stream-management overhead than persistent streams. For high-frequency messaging, a persistent stream with multiplexed framing would be more efficient. The stream-per-message pattern is easy to swap later without changing the driver's public API.

10.4 Address Hints in TCP Wire Frame (Not Protocol Layer)

Choice: TCP addressing travels in the wire frame header as AddressHint sections, not in SWIM message payloads.

Why: Address hints are a TCP transport concern. iroh doesn't need them — nodes are addressed by public key, and iroh handles routing internally. Putting hints in the protocol messages would re-couple the protocol to a specific addressing scheme. The frame-level approach keeps protocol messages clean and lets each transport carry whatever metadata it needs.

Tradeoff: The wire frame format is now transport-specific (TCP frames have hints, QUIC streams don't). This is acceptable because the frame format is already transport-specific (TCP has length-prefix framing, QUIC doesn't need it).

10.5 Keypair Reconstruction from iroh SecretKey

Choice: Construct a swactor Keypair from iroh's SecretKey bytes rather than generating a separate identity.

Why: Both use ed25519-dalek internally. Sharing the key material means driver.node_id() and endpoint.id() are the same 32-byte public key. Any message addressed to a NodeId is directly routable by iroh without a lookup table. If they were separate keys, we'd need a NodeId → iroh::PublicKey mapping — another address book, duplicating the TCP driver's problem.

Tradeoff: Ties swactor identity to iroh identity. If iroh ever changes its key format or the ed25519-dalek versions diverge, the byte-level reconstruction would break. This is mitigated by both depending on the same ed25519-dalek version via iroh 0.96.

10.6 Removing Flaky Tests Over Fixing Them

Choice: Removed all 5 MT gossip tests rather than increasing sleep timeouts or adding retry logic.

Why: The flakiness is inherent to the sleep-based synchronization model, not to insufficient timeout values. Increasing sleep times makes the test suite slower without eliminating the race — it just makes failures rarer, which is worse (harder to reproduce, blocks CI intermittently). The ST variants test the same properties deterministically and have never failed.

Tradeoff: No MT gossip testing. If the gossip protocol has concurrency bugs (e.g., data races in the GossipActor), the ST tests won't catch them. The right fix is a proper synchronization mechanism in the MT harness (barriers, message-count-based settling) — not sleep-and-hope.


11. Known Gaps & Future Improvements

Gap Effort Impact Notes
iroh cluster integration tests Medium High Two IrohDrivers joining and verifying SWIM convergence over real QUIC connections. Current tests verify identity/snapshot but not multi-node communication.
Actor-to-actor transport over iroh Large High Currently only SWIM messages go over iroh. Actor messages still require the existing swactor transport layer.
iroh Docker test scenarios Medium Medium Add iroh transport variant to Docker compose with --transport iroh and --seed-node-id flags.
Persistent QUIC streams Small Medium Replace stream-per-message with persistent streams for high-frequency SWIM probes. Reduces stream setup overhead.
iroh relay server configuration Small Medium CLI currently hardcodes RelayMode::Default (n0 production relays). Add --relay-url flag for custom relay servers.
Shared tokio runtime Small Low Allow passing an existing runtime to IrohDriver::new() instead of creating one per driver instance.
MT gossip harness fix Medium Low Replace sleep-based settling with barrier or message-count synchronization. Would re-enable MT property tests.
Wire format version negotiation Medium Medium TCP hint-extended frames and old frames are not interoperable. Version header would enable rolling upgrades.

12. Test Coverage Summary

Changes to Existing Tests

All 7 test files in crates/distribution/tests/ updated for the NodeId-only API — removed SocketAddr construction, removed address parameters from handler calls, updated action pattern matching. TCP-specific tests gated behind #[cfg(feature = "tcp")].

New Tests — 3 iroh Driver Tests

Test Assertion
iroh_driver_creates_with_unique_identity Two drivers have different node_id() values
iroh_driver_snapshot_contains_node_id Snapshot has non-empty node_id and empty member list
iroh_driver_identity_matches_iroh_endpoint Snapshot node_id hex matches driver.node_id() bytes

Run with: cargo test -p distribution --features iroh

Removed Tests — 5 Flaky MT Gossip Tests

Test Reason
all_nodes_receive_all_keys_in_ring_1000_mt delivery_ratio > 1.0 from snapshot duplication
fullmesh_converges_in_log_n_rounds_mt Convergence bound exceeded under thread pressure
convergence_curve_is_monotonic_mt delivery_ratio undershoot from incomplete settling
partition_heals_and_converges_mt delivery_ratio 0.74 from slow cross-partition gossip
lww_ensures_single_final_value_mt Timing-dependent value convergence check

Final Test Counts

Crate Tests Change
distribution 153 +3 (iroh), net same (protocol refactor, no new/removed)
simulation (gossip) 31 -5 (removed MT variants)
simulation (distribution) 21 unchanged
Total 205 -2 net

Verification

  • cargo check -p distribution --no-default-features — compiles without TCP
  • cargo check -p distribution --features "tcp,iroh" — compiles with both
  • cargo test -p distribution — 150 tests pass (TCP default)
  • cargo test -p distribution --features iroh — 153 tests pass (TCP + iroh)
  • cargo test -p simulation — 52 tests pass (31 gossip + 21 distribution)
  • cargo build -p node --features tcp — binary builds
  • cargo build -p node --features iroh — binary builds

Files Created/Modified

Action File Purpose
Created crates/distribution/src/iroh_driver.rs IrohDriver (QUIC P2P transport)
Created crates/distribution/tests/iroh_driver.rs 3 iroh integration tests
Modified crates/distribution/Cargo.toml Feature flags, iroh/tokio deps
Modified crates/distribution/src/lib.rs Feature-gated module exports
Modified crates/distribution/src/types.rs Removed SocketAddr from NodeRecord
Modified crates/distribution/src/messages.rs Removed SocketAddr from wire messages
Modified crates/distribution/src/node.rs Removed SocketAddr from handlers, added with_keypair()
Modified crates/distribution/src/snapshot.rs Address fields → Option<String>
Modified crates/distribution/src/driver.rs PeerAddressBook, wire frame hints, enriched snapshot
Modified crates/distribution/src/transport.rs Extended frame format with hints section
Modified crates/distribution/src/swim/node.rs Removed SocketAddr from NodeAction, handlers
Modified crates/distribution/src/swim/probe.rs Removed SocketAddr from SwimAction
Modified crates/distribution/src/swim/member_list.rs Removed addr from MemberEntry
Modified crates/distribution/src/swim/dissemination.rs Removed addr from membership_update()
Modified crates/distribution/src/kademlia/routing_table.rs Removed addr from NodeEntry, insert()
Modified crates/distribution/src/kademlia/lookup.rs Removed addr from LookupAction
Modified crates/distribution/tests/swim_probe.rs Updated for NodeId-only API
Modified crates/distribution/tests/swim_node.rs Updated for NodeId-only API
Modified crates/distribution/tests/swim_dissemination.rs Updated for NodeId-only API
Modified crates/distribution/tests/kademlia_routing.rs Updated for NodeId-only API
Modified crates/distribution/tests/kademlia_lookup.rs Updated for NodeId-only API
Modified crates/distribution/tests/node_integration.rs Updated for NodeId-only API
Modified crates/distribution/tests/registry.rs Updated for NodeId-only API
Modified crates/distribution/tests/transport_and_codec.rs TCP tests gated, codec tests ungated
Modified crates/distribution/tests/types_and_crypto.rs Removed SocketAddr from NodeRecord test
Modified crates/node/Cargo.toml Feature flags, iroh dep
Modified crates/node/src/main.rs Transport selection CLI
Modified crates/simulation/src/distribution/sim.rs Updated for NodeId-only API
Modified crates/simulation/tests/gossip_properties.rs Removed 5 flaky MT tests