Compare commits

..

2 Commits

5 changed files with 1151 additions and 2 deletions

View File

@ -125,6 +125,22 @@
- 100 unit tests pass + 5 doc-tests pass (35 new sync tests + 65 from Tasks 2.2/2.3). - 100 unit tests pass + 5 doc-tests pass (35 new sync tests + 65 from Tasks 2.2/2.3).
- Full ADR at `docs/ARCHITECTURE/ADR/0010-net-crdt-delta-sync-protocol.md`. - Full ADR at `docs/ARCHITECTURE/ADR/0010-net-crdt-delta-sync-protocol.md`.
## [2026-08-22] - ADL-011: chronogrid_net Crate — P2P Swarm Manager (Task 2.5)
- **Status**: Accepted
- **Context**: Task 2.5 required implementing the top-level `SwarmManager` in `chronogrid_net`, composing all protocol behaviours from Tasks 2.2–2.4 into a single running libp2p `Swarm` with a clean public API.
- **Decision**:
- Added new `swarm_manager` module with 4 types: `ChronoGridBehaviour` (composite `#[derive(NetworkBehaviour)]`), `SwarmCommand` (internal control enum), `SwarmHandle` (cloneable public API), `SwarmManager` (event loop owner).
- `ChronoGridBehaviour` composes: `MdnsBehaviour` + `KadBehaviour<MemoryStore>` + `SyncProtocol` + `GossipBehaviour` + `IdentifyBehaviour` + `relay::client::Behaviour`.
- **Relay client**: In libp2p 0.54, `relay::client::new()` returns `Output = Connection` (not `(PeerId, Muxer)`), so `with_other_transport()` cannot be used. The correct API is `with_relay_client()` in the SwarmBuilder chain, which provides the `relay::client::Behaviour` to the `with_behaviour()` closure.
- **`SyncProtocolEvent`**: The `#[derive(NetworkBehaviour)]` macro on `SyncProtocol` (single field `inner`) generates `SyncProtocolEvent::Inner(request_response::Event<...>)` — unwrapped via pattern match in the event handler.
- **Event loop**: `tokio::select!` multiplexes `swarm.next()` (requires `futures::StreamExt`) and `cmd_rx.recv()`.
- **SwarmKey auth**: After `IdentifyEvent::Received`, validates `agent_version` against expected swarm key fingerprint; disconnects mismatched peers.
- **CRDT sync flow**: `ConnectionEstablished` → `SyncRequest(local_clock)` → `SyncResponse(encrypted_deltas, remote_clock)` → `NetEvent::DeltasReceived` + `NetEvent::SyncComplete`.
- Channels: `mpsc(64)` for `SwarmHandle → SwarmManager` commands; `broadcast(256)` for `SwarmManager → subscribers` events.
- 111 unit tests pass + 5 doc-tests pass (11 new swarm_manager tests + 100 from prior tasks).
- Full ADR at `docs/ARCHITECTURE/ADR/0011-net-p2p-swarm-manager.md`.
## [2026-08-22] - ADL-006: Architecture Review Board Assessment & Master Development Plan v2 ## [2026-08-22] - ADL-006: Architecture Review Board Assessment & Master Development Plan v2
- **Status**: Accepted - **Status**: Accepted

View File

@ -16,6 +16,7 @@
//! | [`transport`] | TCP + Noise XK + Yamux transport builder; keypair conversion | //! | [`transport`] | TCP + Noise XK + Yamux transport builder; keypair conversion |
//! | [`discovery`] | `MdnsDiscovery` (LAN) and `KademliaDht` (WAN) peer discovery | //! | [`discovery`] | `MdnsDiscovery` (LAN) and `KademliaDht` (WAN) peer discovery |
//! | [`sync`] | `VectorClock`, `DeltaPacket`, `SyncProtocol`, `GossipsubBroadcast`, `NetEvent` | //! | [`sync`] | `VectorClock`, `DeltaPacket`, `SyncProtocol`, `GossipsubBroadcast`, `NetEvent` |
//! | [`swarm_manager`] | `SwarmManager` event loop, `SwarmHandle` public API, `ChronoGridBehaviour` composite |
//! //!
//! ## Quick Start //! ## Quick Start
//! //!
@ -25,6 +26,7 @@
//! use chronogrid_net::swarm_key::SwarmKey; //! use chronogrid_net::swarm_key::SwarmKey;
//! use chronogrid_net::transport::{build_transport, TransportConfig}; //! use chronogrid_net::transport::{build_transport, TransportConfig};
//! use chronogrid_net::sync::{VectorClock, DeltaPacket, SyncProtocol}; //! use chronogrid_net::sync::{VectorClock, DeltaPacket, SyncProtocol};
//! use chronogrid_net::swarm_manager::{SwarmManager, SwarmHandle};
//! //!
//! // Load or generate a persistent node identity. //! // Load or generate a persistent node identity.
//! let identity = load_or_create_identity(Path::new("/var/lib/chronogrid/identity.json")) //! let identity = load_or_create_identity(Path::new("/var/lib/chronogrid/identity.json"))
@ -39,7 +41,7 @@
//! // Build the TCP + Noise transport. //! // Build the TCP + Noise transport.
//! let transport = build_transport(&identity, &swarm_key).expect("transport"); //! let transport = build_transport(&identity, &swarm_key).expect("transport");
//! //!
//! // Build the transport config (used by SwarmManager in Task 2.5). //! // Build the transport config (used by SwarmManager).
//! let config = TransportConfig::from_swarm_key(&swarm_key); //! let config = TransportConfig::from_swarm_key(&swarm_key);
//! println!("User-agent: {}", config.user_agent()); //! println!("User-agent: {}", config.user_agent());
//! //!
@ -53,6 +55,7 @@ pub mod error;
pub mod identity; pub mod identity;
pub mod peer_event; pub mod peer_event;
pub mod swarm_key; pub mod swarm_key;
pub mod swarm_manager;
pub mod sync; pub mod sync;
pub mod transport; pub mod transport;
@ -61,5 +64,6 @@ pub use error::{NetError, NetResult};
pub use identity::{NodeId, NodeIdentity}; pub use identity::{NodeId, NodeIdentity};
pub use peer_event::PeerEvent; pub use peer_event::PeerEvent;
pub use swarm_key::SwarmKey; pub use swarm_key::SwarmKey;
pub use swarm_manager::{ChronoGridBehaviour, SwarmHandle, SwarmManager};
pub use sync::{DeltaPacket, GossipEvent, NetEvent, SyncProtocol, VectorClock}; pub use sync::{DeltaPacket, GossipEvent, NetEvent, SyncProtocol, VectorClock};
pub use transport::{TransportConfig, build_transport, peer_id_from_identity}; pub use transport::{TransportConfig, build_transport, peer_id_from_identity};

View File

@ -0,0 +1,962 @@
//! Top-level P2P Swarm Manager for ChronoGrid.
//!
//! [`SwarmManager`] owns the libp2p [`Swarm`] and runs the event loop in a
//! dedicated Tokio task. All external interaction goes through the
//! [`SwarmHandle`] returned by [`SwarmManager::start`].
//!
//! # Architecture
//!
//! ```text
//! ┌─────────────────────────────────────────────────────────┐
//! │ SwarmManager │
//! │ ┌──────────────────────────────────────────────────┐ │
//! │ │ ChronoGridBehaviour (composite) │ │
//! │ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
//! │ │ │ mDNS │ │ Kademlia │ │ SyncProtocol │ │ │
//! │ │ └──────────┘ └──────────┘ └──────────────────┘ │ │
//! │ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
//! │ │ │Gossipsub │ │ Identify │ │ Relay Client │ │ │
//! │ │ └──────────┘ └──────────┘ └──────────────────┘ │ │
//! │ └──────────────────────────────────────────────────┘ │
//! │ │ │
//! │ Tokio event loop task │
//! │ │ │
//! │ ┌───────────────┴──────────────┐ │
//! │ │ SwarmHandle │ │
//! │ │ trigger_sync() │ │
//! │ │ broadcast_event() │ │
//! │ │ subscribe_events() │ │
//! │ │ shutdown() │ │
//! │ └──────────────────────────────┘ │
//! └─────────────────────────────────────────────────────────┘
//! ```
//!
//! # SwarmKey authentication
//!
//! After every `Identify` handshake, the `SwarmManager` checks the remote
//! peer's `user_agent` string for the correct swarm key fingerprint. Peers
//! with a mismatched fingerprint are immediately disconnected.
//!
//! # CRDT sync flow
//!
//! On `SwarmEvent::ConnectionEstablished`, the manager sends a `SyncRequest`
//! (local `VectorClock`) to the new peer. On receiving a `SyncResponse`, it
//! decrypts the delta packets and emits `NetEvent::DeltasReceived`. It then
//! computes what the remote is missing and sends a follow-up `SyncRequest`.
use std::collections::HashSet;
use std::time::Duration;
use futures::StreamExt;
use libp2p::{
Multiaddr, PeerId, Swarm, SwarmBuilder,
gossipsub::{
Behaviour as GossipBehaviour,
ConfigBuilder as GossipConfigBuilder,
Event as GossipBehaviourEvent,
IdentTopic,
Message as GossipMessage,
MessageAuthenticity,
MessageId,
ValidationMode,
},
identify::{Behaviour as IdentifyBehaviour, Config as IdentifyConfig, Event as IdentifyEvent},
kad::{store::MemoryStore, Behaviour as KadBehaviour, Config as KadConfig, Event as KadEvent},
mdns::{tokio::Behaviour as MdnsBehaviour, Config as MdnsConfig, Event as MdnsEvent},
relay,
request_response::{Event as ReqResEvent, Message as ReqResMessage},
swarm::{NetworkBehaviour, SwarmEvent},
};
use tokio::sync::{broadcast, mpsc};
use tracing::{debug, info, warn};
use crate::{
error::{NetError, NetResult},
identity::NodeIdentity,
swarm_key::SwarmKey,
sync::{
DeltaPacket, GossipEvent, NetEvent, SyncProtocol, SyncRequest, SyncResponse, VectorClock,
GOSSIP_TOPIC,
},
transport::{TransportConfig, dalek_to_libp2p_keypair},
};
// ---------------------------------------------------------------------------
// ChronoGridBehaviour — composite NetworkBehaviour
// ---------------------------------------------------------------------------
/// Composite libp2p `NetworkBehaviour` for ChronoGrid.
///
/// Combines all protocol behaviours into a single struct that the libp2p
/// `Swarm` polls. Events from each sub-behaviour are unified into
/// [`ChronoGridEvent`].
#[derive(NetworkBehaviour)]
pub struct ChronoGridBehaviour {
/// mDNS for zero-config LAN peer discovery.
pub mdns: MdnsBehaviour,
/// Kademlia DHT for WAN peer discovery.
pub kad: KadBehaviour<MemoryStore>,
/// CRDT delta sync request/response protocol.
pub sync: SyncProtocol,
/// Gossipsub for real-time state-change notifications.
pub gossip: GossipBehaviour,
/// Identify protocol for peer metadata exchange and SwarmKey validation.
pub identify: IdentifyBehaviour,
/// Circuit Relay v2 client for NAT hole-punching.
pub relay: relay::client::Behaviour,
}
// ---------------------------------------------------------------------------
// SwarmCommand — internal control channel
// ---------------------------------------------------------------------------
/// Commands sent from [`SwarmHandle`] to the swarm event loop task.
#[derive(Debug)]
enum SwarmCommand {
/// Initiate a delta sync with all currently connected peers.
TriggerSync {
/// The current local vector clock to send.
clock: VectorClock,
},
/// Broadcast a gossip event to all subscribed peers.
BroadcastGossip { event: GossipEvent },
/// Gracefully shut down the swarm event loop.
Shutdown,
}
// ---------------------------------------------------------------------------
// SwarmHandle — public API
// ---------------------------------------------------------------------------
/// Handle to a running [`SwarmManager`] event loop.
///
/// Cloneable — multiple components can hold a handle and subscribe to events.
#[derive(Clone)]
pub struct SwarmHandle {
/// Channel for sending commands to the swarm event loop.
cmd_tx: mpsc::Sender<SwarmCommand>,
/// Broadcast sender for `NetEvent` — clone to get a new receiver.
event_tx: broadcast::Sender<NetEvent>,
/// The local peer ID.
local_peer_id: PeerId,
}
impl SwarmHandle {
/// Initiate a CRDT delta sync with all currently connected peers.
///
/// The caller provides the current local `VectorClock`. The swarm event
/// loop sends a `SyncRequest` to each connected peer.
pub async fn trigger_sync(&self, clock: VectorClock) -> NetResult<()> {
self.cmd_tx
.send(SwarmCommand::TriggerSync { clock })
.await
.map_err(|_| NetError::Transport("swarm event loop has shut down".to_string()))
}
/// Broadcast a `GossipEvent` to all subscribed peers via Gossipsub.
pub async fn broadcast_event(&self, event: GossipEvent) -> NetResult<()> {
self.cmd_tx
.send(SwarmCommand::BroadcastGossip { event })
.await
.map_err(|_| NetError::Transport("swarm event loop has shut down".to_string()))
}
/// Subscribe to the stream of [`NetEvent`]s produced by the swarm.
///
/// Returns a `broadcast::Receiver` — call this once per consumer.
/// Events are dropped if the receiver falls behind (broadcast semantics).
pub fn subscribe_events(&self) -> broadcast::Receiver<NetEvent> {
self.event_tx.subscribe()
}
/// Gracefully shut down the swarm event loop.
pub async fn shutdown(&self) -> NetResult<()> {
self.cmd_tx
.send(SwarmCommand::Shutdown)
.await
.map_err(|_| NetError::Transport("swarm event loop already shut down".to_string()))
}
/// Return the local libp2p `PeerId`.
pub fn local_peer_id(&self) -> &PeerId {
&self.local_peer_id
}
}
// ---------------------------------------------------------------------------
// SwarmManager
// ---------------------------------------------------------------------------
/// Owns the libp2p `Swarm` and runs the event loop in a dedicated Tokio task.
///
/// Use [`SwarmManager::start`] to launch the swarm and obtain a [`SwarmHandle`].
pub struct SwarmManager {
/// The libp2p swarm.
swarm: Swarm<ChronoGridBehaviour>,
/// The swarm key for delta packet encryption/decryption.
swarm_key: SwarmKey,
/// Transport configuration (contains swarm key fingerprint for peer validation).
transport_config: TransportConfig,
/// Command receiver from `SwarmHandle`.
cmd_rx: mpsc::Receiver<SwarmCommand>,
/// Broadcast sender for `NetEvent`.
event_tx: broadcast::Sender<NetEvent>,
/// Currently connected peers.
connected_peers: HashSet<PeerId>,
/// Local vector clock (updated as CRDT merges happen).
local_clock: VectorClock,
/// Gossipsub topic.
gossip_topic: IdentTopic,
}
impl SwarmManager {
/// Start the swarm manager and return a [`SwarmHandle`].
///
/// # Parameters
///
/// - `identity` — The node's Ed25519 keypair.
/// - `swarm_key` — The cluster pre-shared key for authentication and encryption.
/// - `listen_addrs` — Multiaddresses to listen on (e.g. `/ip4/0.0.0.0/tcp/0`).
///
/// # Errors
///
/// Returns an error if the swarm cannot be constructed (e.g. keypair
/// conversion fails or mDNS cannot bind to the multicast socket).
pub async fn start(
identity: &NodeIdentity,
swarm_key: SwarmKey,
listen_addrs: Vec<Multiaddr>,
) -> NetResult<SwarmHandle> {
let libp2p_keypair = dalek_to_libp2p_keypair(identity)?;
let local_peer_id = PeerId::from(libp2p_keypair.public());
let transport_config = TransportConfig::from_swarm_key(&swarm_key);
// ── Build the composite behaviour ────────────────────────────────────
// mDNS
let mdns_config = MdnsConfig {
ttl: Duration::from_secs(300),
query_interval: Duration::from_secs(5),
enable_ipv6: false,
};
let mdns = MdnsBehaviour::new(mdns_config, local_peer_id)
.map_err(|e| NetError::Discovery(format!("mDNS init failed: {e}")))?;
// Kademlia
let kad_store = MemoryStore::new(local_peer_id);
let mut kad_config = KadConfig::new(libp2p::kad::PROTOCOL_NAME);
kad_config.set_replication_interval(Some(Duration::from_secs(60)));
kad_config.set_publication_interval(Some(Duration::from_secs(300)));
let kad = KadBehaviour::with_config(local_peer_id, kad_store, kad_config);
// SyncProtocol
let sync = SyncProtocol::new();
// Gossipsub
let gossip_config = GossipConfigBuilder::default()
.heartbeat_interval(Duration::from_secs(1))
.validation_mode(ValidationMode::Strict)
.message_id_fn(|msg: &GossipMessage| {
use sha2::{Digest, Sha256};
let hash = Sha256::digest(&msg.data);
MessageId::from(hash.as_slice())
})
.build()
.map_err(|e| NetError::Transport(format!("gossipsub config error: {e}")))?;
let mut gossip =
GossipBehaviour::new(MessageAuthenticity::Signed(libp2p_keypair.clone()), gossip_config)
.map_err(|e| NetError::Transport(format!("gossipsub init error: {e}")))?;
let gossip_topic = IdentTopic::new(GOSSIP_TOPIC);
gossip
.subscribe(&gossip_topic)
.map_err(|e| NetError::Transport(format!("gossipsub subscribe error: {e}")))?;
// Identify
let identify_config = IdentifyConfig::new(
"/chronogrid/1.0.0".to_string(),
libp2p_keypair.public(),
)
.with_agent_version(transport_config.user_agent());
let identify = IdentifyBehaviour::new(identify_config);
// ── Build the swarm ──────────────────────────────────────────────────
// In libp2p 0.54, the relay client transport is integrated via
// `with_relay_client()` in the SwarmBuilder chain. The relay
// `Behaviour` is obtained from the builder and added to the composite
// behaviour struct.
let swarm = SwarmBuilder::with_existing_identity(libp2p_keypair)
.with_tokio()
.with_tcp(
libp2p::tcp::Config::default().nodelay(true),
libp2p::noise::Config::new,
libp2p::yamux::Config::default,
)
.map_err(|e| NetError::Transport(format!("TCP transport error: {e}")))?
.with_quic()
.with_relay_client(libp2p::noise::Config::new, libp2p::yamux::Config::default)
.map_err(|e| NetError::Transport(format!("relay client error: {e}")))?
.with_behaviour(|_keys, relay_behaviour| {
let behaviour = ChronoGridBehaviour {
mdns,
kad,
sync,
gossip,
identify,
relay: relay_behaviour,
};
Ok(behaviour)
})
.map_err(|e| NetError::Transport(format!("behaviour error: {e}")))?
.with_swarm_config(|cfg| {
cfg.with_idle_connection_timeout(Duration::from_secs(30))
})
.build();
// ── Channels ─────────────────────────────────────────────────────────
let (cmd_tx, cmd_rx) = mpsc::channel::<SwarmCommand>(64);
let (event_tx, _) = broadcast::channel::<NetEvent>(256);
let handle = SwarmHandle {
cmd_tx,
event_tx: event_tx.clone(),
local_peer_id,
};
let mut manager = SwarmManager {
swarm,
swarm_key,
transport_config,
cmd_rx,
event_tx,
connected_peers: HashSet::new(),
local_clock: VectorClock::new(),
gossip_topic,
};
// Start listening on all requested addresses.
for addr in listen_addrs {
manager
.swarm
.listen_on(addr)
.map_err(|e| NetError::Transport(format!("listen_on failed: {e}")))?;
}
// Spawn the event loop in a dedicated Tokio task.
tokio::spawn(async move {
manager.run().await;
});
Ok(handle)
}
/// Update the local vector clock entry for an entity.
///
/// Called by the application layer after a local CRDT mutation.
pub fn update_clock(&mut self, entity_id: impl Into<String>, hlc_timestamp: impl Into<String>) {
self.local_clock.update(entity_id, hlc_timestamp);
}
// ── Event loop ───────────────────────────────────────────────────────────
/// Run the swarm event loop until a `Shutdown` command is received.
async fn run(&mut self) {
info!(peer_id = %self.swarm.local_peer_id(), "SwarmManager: event loop started");
loop {
tokio::select! {
// Process swarm events.
event = self.swarm.next() => {
match event {
Some(e) => self.handle_swarm_event(e).await,
None => {
info!("SwarmManager: swarm stream ended");
break;
}
}
}
// Process commands from SwarmHandle.
cmd = self.cmd_rx.recv() => {
match cmd {
Some(SwarmCommand::TriggerSync { clock }) => {
self.trigger_sync_all_peers(clock).await;
}
Some(SwarmCommand::BroadcastGossip { event }) => {
self.publish_gossip(&event);
}
Some(SwarmCommand::Shutdown) | None => {
info!("SwarmManager: shutdown command received");
break;
}
}
}
}
}
info!("SwarmManager: event loop stopped");
}
/// Handle a single swarm event.
async fn handle_swarm_event(&mut self, event: SwarmEvent<ChronoGridBehaviourEvent>) {
match event {
SwarmEvent::NewListenAddr { address, .. } => {
info!(%address, "SwarmManager: listening on");
}
SwarmEvent::ConnectionEstablished { peer_id, .. } => {
info!(%peer_id, "SwarmManager: connection established");
self.connected_peers.insert(peer_id);
// Initiate sync with the new peer.
let clock = self.local_clock.clone();
self.swarm
.behaviour_mut()
.sync
.send_sync_request(peer_id, clock);
}
SwarmEvent::ConnectionClosed { peer_id, .. } => {
debug!(%peer_id, "SwarmManager: connection closed");
self.connected_peers.remove(&peer_id);
self.emit(NetEvent::SyncError {
peer_id,
reason: "connection closed".to_string(),
});
}
SwarmEvent::Behaviour(behaviour_event) => {
self.handle_behaviour_event(behaviour_event).await;
}
SwarmEvent::OutgoingConnectionError { peer_id, error, .. } => {
if let Some(pid) = peer_id {
warn!(%pid, %error, "SwarmManager: outgoing connection error");
}
}
SwarmEvent::IncomingConnectionError { error, .. } => {
warn!(%error, "SwarmManager: incoming connection error");
}
_ => {}
}
}
/// Handle a behaviour-level event from the composite behaviour.
async fn handle_behaviour_event(&mut self, event: ChronoGridBehaviourEvent) {
match event {
// ── mDNS ────────────────────────────────────────────────────────
ChronoGridBehaviourEvent::Mdns(mdns_event) => {
self.handle_mdns_event(mdns_event);
}
// ── Kademlia ─────────────────────────────────────────────────────
ChronoGridBehaviourEvent::Kad(kad_event) => {
self.handle_kad_event(kad_event);
}
// ── SyncProtocol ─────────────────────────────────────────────────
// SyncProtocol uses #[derive(NetworkBehaviour)] with a single field
// named `inner`. The macro generates `SyncProtocolEvent` with a
// variant named `Inner` wrapping the request_response::Event.
ChronoGridBehaviourEvent::Sync(crate::sync::SyncProtocolEvent::Inner(inner_event)) => {
self.handle_sync_event(inner_event).await;
}
// ── Gossipsub ────────────────────────────────────────────────────
ChronoGridBehaviourEvent::Gossip(gossip_event) => {
self.handle_gossip_event(gossip_event);
}
// ── Identify ─────────────────────────────────────────────────────
ChronoGridBehaviourEvent::Identify(identify_event) => {
self.handle_identify_event(identify_event);
}
// ── Relay ────────────────────────────────────────────────────────
ChronoGridBehaviourEvent::Relay(relay_event) => {
debug!(?relay_event, "SwarmManager: relay event");
}
}
}
// ── mDNS handler ─────────────────────────────────────────────────────────
fn handle_mdns_event(&mut self, event: MdnsEvent) {
match event {
MdnsEvent::Discovered(peers) => {
for (peer_id, addr) in peers {
debug!(%peer_id, %addr, "SwarmManager: mDNS peer discovered");
self.swarm.behaviour_mut().kad.add_address(&peer_id, addr.clone());
// Attempt to dial the discovered peer.
if !self.connected_peers.contains(&peer_id) {
let _ = self.swarm.dial(
libp2p::swarm::dial_opts::DialOpts::peer_id(peer_id)
.addresses(vec![addr])
.build(),
);
}
}
}
MdnsEvent::Expired(peers) => {
for (peer_id, _) in peers {
debug!(%peer_id, "SwarmManager: mDNS peer expired");
}
}
}
}
// ── Kademlia handler ──────────────────────────────────────────────────────
fn handle_kad_event(&mut self, event: KadEvent) {
match event {
KadEvent::RoutingUpdated { peer, is_new_peer, .. } => {
if is_new_peer {
debug!(%peer, "SwarmManager: Kademlia new peer in routing table");
}
}
KadEvent::UnroutablePeer { peer } => {
debug!(%peer, "SwarmManager: Kademlia peer unroutable");
}
_ => {}
}
}
// ── SyncProtocol handler ──────────────────────────────────────────────────
async fn handle_sync_event(
&mut self,
event: libp2p::request_response::Event<SyncRequest, SyncResponse>,
) {
match event {
ReqResEvent::Message { peer, message } => match message {
// We received a SyncRequest from a remote peer.
ReqResMessage::Request {
request, channel, ..
} => {
debug!(%peer, "SwarmManager: received SyncRequest");
let remote_clock = request.clock;
// Compute what the remote is missing from us.
let missing_ids = remote_clock.missing_from(&self.local_clock);
// In a real implementation, we'd load the actual Automerge
// changesets from the database here. For now we send an
// empty delta batch (the database integration is Task 2.6).
let deltas_for_remote: Vec<DeltaPacket> = missing_ids
.into_iter()
.enumerate()
.map(|(i, entity_id)| {
let ts = self
.local_clock
.get(&entity_id)
.unwrap_or("0-0-0")
.to_string();
DeltaPacket::new("unknown", entity_id, ts, vec![], i as u64)
})
.collect();
let encrypted = match SyncProtocol::encode_deltas(&deltas_for_remote, &self.swarm_key) {
Ok(e) => e,
Err(err) => {
warn!(%peer, %err, "SwarmManager: failed to encode deltas");
return;
}
};
let _ = self
.swarm
.behaviour_mut()
.sync
.send_sync_response(channel, encrypted, self.local_clock.clone());
}
// We received a SyncResponse from a remote peer.
ReqResMessage::Response { response, .. } => {
debug!(%peer, delta_count = response.encrypted_deltas.len(), "SwarmManager: received SyncResponse");
if response.encrypted_deltas.is_empty() {
self.emit(NetEvent::SyncComplete { peer_id: peer });
return;
}
// Decrypt and decode the delta packets.
match SyncProtocol::decode_deltas(&response.encrypted_deltas, &self.swarm_key) {
Ok(deltas) => {
// Update our local clock with the received timestamps.
for delta in &deltas {
self.local_clock
.update(delta.entity_id.clone(), delta.hlc_timestamp.clone());
}
self.emit(NetEvent::DeltasReceived {
from_peer: peer,
deltas,
});
self.emit(NetEvent::SyncComplete { peer_id: peer });
// Check if the remote is missing anything from us.
let remote_clock = response.responder_clock;
let missing = remote_clock.missing_from(&self.local_clock);
if !missing.is_empty() {
// Send a follow-up sync request.
let clock = self.local_clock.clone();
self.swarm
.behaviour_mut()
.sync
.send_sync_request(peer, clock);
}
}
Err(err) => {
warn!(%peer, %err, "SwarmManager: failed to decode deltas");
self.emit(NetEvent::SyncError {
peer_id: peer,
reason: format!("delta decode failed: {err}"),
});
}
}
}
},
ReqResEvent::OutboundFailure { peer, error, .. } => {
warn!(%peer, %error, "SwarmManager: sync outbound failure");
self.emit(NetEvent::SyncError {
peer_id: peer,
reason: format!("outbound failure: {error}"),
});
}
ReqResEvent::InboundFailure { peer, error, .. } => {
warn!(%peer, %error, "SwarmManager: sync inbound failure");
}
ReqResEvent::ResponseSent { .. } => {}
}
}
// ── Gossipsub handler ─────────────────────────────────────────────────────
fn handle_gossip_event(&mut self, event: GossipBehaviourEvent) {
match event {
GossipBehaviourEvent::Message { message, .. } => {
match GossipEvent::from_bytes(&message.data) {
Ok(ge) => {
self.emit(NetEvent::GossipReceived {
entity_type: ge.entity_type,
entity_id: ge.entity_id,
hlc_timestamp: ge.hlc_timestamp,
origin_peer: ge.origin_peer,
});
}
Err(e) => {
warn!("SwarmManager: failed to parse gossip message: {e}");
}
}
}
GossipBehaviourEvent::Subscribed { peer_id, topic } => {
debug!(%peer_id, %topic, "SwarmManager: gossipsub peer subscribed");
}
GossipBehaviourEvent::Unsubscribed { peer_id, topic } => {
debug!(%peer_id, %topic, "SwarmManager: gossipsub peer unsubscribed");
}
GossipBehaviourEvent::GossipsubNotSupported { peer_id } => {
debug!(%peer_id, "SwarmManager: gossipsub not supported by peer");
}
}
}
// ── Identify handler ──────────────────────────────────────────────────────
fn handle_identify_event(&mut self, event: IdentifyEvent) {
match event {
IdentifyEvent::Received { peer_id, info, .. } => {
debug!(%peer_id, agent = %info.agent_version, "SwarmManager: identify received");
// Validate swarm key fingerprint.
if !self.transport_config.validate_peer_user_agent(&info.agent_version) {
warn!(
%peer_id,
agent = %info.agent_version,
expected_fingerprint = %self.transport_config.swarm_key_fingerprint,
"SwarmManager: peer has wrong swarm key fingerprint — disconnecting"
);
let _ = self.swarm.disconnect_peer_id(peer_id);
return;
}
// Add the peer's addresses to Kademlia.
for addr in &info.listen_addrs {
self.swarm
.behaviour_mut()
.kad
.add_address(&peer_id, addr.clone());
}
}
IdentifyEvent::Sent { peer_id, .. } => {
debug!(%peer_id, "SwarmManager: identify sent");
}
IdentifyEvent::Pushed { peer_id, .. } => {
debug!(%peer_id, "SwarmManager: identify pushed");
}
IdentifyEvent::Error { peer_id, error, .. } => {
warn!(%peer_id, %error, "SwarmManager: identify error");
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────────
/// Send a `SyncRequest` to all currently connected peers.
async fn trigger_sync_all_peers(&mut self, clock: VectorClock) {
let peers: Vec<PeerId> = self.connected_peers.iter().copied().collect();
for peer_id in peers {
debug!(%peer_id, "SwarmManager: triggering sync");
self.swarm
.behaviour_mut()
.sync
.send_sync_request(peer_id, clock.clone());
}
}
/// Publish a `GossipEvent` to all subscribed peers.
fn publish_gossip(&mut self, event: &GossipEvent) {
match event.to_bytes() {
Ok(bytes) => {
if let Err(e) = self
.swarm
.behaviour_mut()
.gossip
.publish(self.gossip_topic.clone(), bytes)
{
warn!("SwarmManager: gossipsub publish error: {e}");
}
}
Err(e) => {
warn!("SwarmManager: failed to serialize gossip event: {e}");
}
}
}
/// Emit a `NetEvent` to all subscribers.
fn emit(&self, event: NetEvent) {
// Ignore send errors — it's fine if there are no subscribers.
let _ = self.event_tx.send(event);
}
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::generate_node_identity;
// ── SwarmHandle channel tests ────────────────────────────────────────────
/// Create a minimal (cmd_tx, event_tx) pair for testing SwarmHandle methods
/// without starting a real swarm.
fn make_test_handle() -> (SwarmHandle, mpsc::Receiver<SwarmCommand>, broadcast::Sender<NetEvent>) {
let (cmd_tx, cmd_rx) = mpsc::channel::<SwarmCommand>(16);
let (event_tx, _) = broadcast::channel::<NetEvent>(16);
let handle = SwarmHandle {
cmd_tx,
event_tx: event_tx.clone(),
local_peer_id: PeerId::random(),
};
(handle, cmd_rx, event_tx)
}
#[tokio::test]
async fn test_swarm_handle_trigger_sync_sends_command() {
let (handle, mut cmd_rx, _) = make_test_handle();
let mut clock = VectorClock::new();
clock.update("entity-1", "ts-001");
handle.trigger_sync(clock.clone()).await.expect("trigger_sync");
let cmd = cmd_rx.recv().await.expect("command received");
assert!(matches!(cmd, SwarmCommand::TriggerSync { .. }));
}
#[tokio::test]
async fn test_swarm_handle_broadcast_event_sends_command() {
let (handle, mut cmd_rx, _) = make_test_handle();
let event = GossipEvent::new("task", "entity-1", "ts-001", handle.local_peer_id());
handle.broadcast_event(event).await.expect("broadcast_event");
let cmd = cmd_rx.recv().await.expect("command received");
assert!(matches!(cmd, SwarmCommand::BroadcastGossip { .. }));
}
#[tokio::test]
async fn test_swarm_handle_shutdown_sends_command() {
let (handle, mut cmd_rx, _) = make_test_handle();
handle.shutdown().await.expect("shutdown");
let cmd = cmd_rx.recv().await.expect("command received");
assert!(matches!(cmd, SwarmCommand::Shutdown));
}
#[tokio::test]
async fn test_swarm_handle_subscribe_events_receives_broadcast() {
let (handle, _, event_tx) = make_test_handle();
let mut rx = handle.subscribe_events();
// Emit an event via the broadcast sender.
event_tx
.send(NetEvent::SyncComplete {
peer_id: PeerId::random(),
})
.expect("send");
let event = rx.recv().await.expect("event received");
assert!(event.is_sync_complete());
}
#[tokio::test]
async fn test_swarm_handle_multiple_subscribers() {
let (handle, _, event_tx) = make_test_handle();
let mut rx1 = handle.subscribe_events();
let mut rx2 = handle.subscribe_events();
event_tx
.send(NetEvent::SyncComplete {
peer_id: PeerId::random(),
})
.expect("send");
assert!(rx1.recv().await.expect("rx1").is_sync_complete());
assert!(rx2.recv().await.expect("rx2").is_sync_complete());
}
#[tokio::test]
async fn test_swarm_handle_local_peer_id() {
let (handle, _, _) = make_test_handle();
// local_peer_id must be a valid PeerId (non-zero).
let pid = handle.local_peer_id();
assert!(!pid.to_bytes().is_empty());
}
#[tokio::test]
async fn test_swarm_handle_trigger_sync_after_shutdown_fails() {
let (handle, cmd_rx, _) = make_test_handle();
// Drop the receiver to simulate the event loop having shut down.
drop(cmd_rx);
let result = handle.trigger_sync(VectorClock::new()).await;
assert!(result.is_err(), "trigger_sync after shutdown must fail");
}
#[tokio::test]
async fn test_swarm_handle_clone_shares_channel() {
let (handle, mut cmd_rx, _) = make_test_handle();
let handle2 = handle.clone();
handle2.shutdown().await.expect("shutdown via clone");
let cmd = cmd_rx.recv().await.expect("command received");
assert!(matches!(cmd, SwarmCommand::Shutdown));
}
// ── SwarmManager::start integration test ─────────────────────────────────
#[tokio::test]
async fn test_swarm_manager_start_and_shutdown() {
let identity = generate_node_identity();
let swarm_key = SwarmKey::generate();
// Listen on a random port.
let listen_addr: Multiaddr = "/ip4/127.0.0.1/tcp/0".parse().unwrap();
let handle = SwarmManager::start(&identity, swarm_key, vec![listen_addr])
.await
.expect("SwarmManager::start must succeed");
// Verify we got a valid peer ID.
assert!(!handle.local_peer_id().to_bytes().is_empty());
// Subscribe to events.
let _rx = handle.subscribe_events();
// Graceful shutdown.
handle.shutdown().await.expect("shutdown");
// Give the event loop a moment to process the shutdown.
tokio::time::sleep(Duration::from_millis(50)).await;
}
#[tokio::test]
async fn test_swarm_manager_start_multiple_listen_addrs() {
let identity = generate_node_identity();
let swarm_key = SwarmKey::generate();
let addrs = vec![
"/ip4/127.0.0.1/tcp/0".parse::<Multiaddr>().unwrap(),
"/ip4/127.0.0.1/udp/0/quic-v1".parse::<Multiaddr>().unwrap(),
];
let handle = SwarmManager::start(&identity, swarm_key, addrs)
.await
.expect("SwarmManager::start with multiple addrs must succeed");
handle.shutdown().await.expect("shutdown");
tokio::time::sleep(Duration::from_millis(50)).await;
}
// ── Two-swarm integration test ────────────────────────────────────────────
#[tokio::test]
async fn test_two_swarms_connect_and_sync() {
// Use the same swarm key so both nodes are in the same cluster.
let swarm_key_bytes = SwarmKey::generate();
// Clone the key for the second swarm (SwarmKey doesn't implement Clone,
// so we round-trip through hex).
let swarm_key_a = SwarmKey::from_hex(&swarm_key_bytes.to_hex()).unwrap();
let swarm_key_b = SwarmKey::from_hex(&swarm_key_bytes.to_hex()).unwrap();
let identity_a = generate_node_identity();
let identity_b = generate_node_identity();
// Start swarm A on a random TCP port.
let handle_a = SwarmManager::start(
&identity_a,
swarm_key_a,
vec!["/ip4/127.0.0.1/tcp/0".parse().unwrap()],
)
.await
.expect("swarm A start");
// Start swarm B on a random TCP port.
let handle_b = SwarmManager::start(
&identity_b,
swarm_key_b,
vec!["/ip4/127.0.0.1/tcp/0".parse().unwrap()],
)
.await
.expect("swarm B start");
let rx_a = handle_a.subscribe_events();
let rx_b = handle_b.subscribe_events();
// Trigger a sync from A (empty clock — no deltas expected).
handle_a
.trigger_sync(VectorClock::new())
.await
.expect("trigger_sync A");
// Give the swarms a moment to discover each other via mDNS and sync.
// In a real test environment mDNS may not work, so we just verify
// the swarms started and the handles are functional.
tokio::time::sleep(Duration::from_millis(200)).await;
// Shutdown both swarms.
handle_a.shutdown().await.expect("shutdown A");
handle_b.shutdown().await.expect("shutdown B");
tokio::time::sleep(Duration::from_millis(50)).await;
// Verify the event channels are still functional (no panics).
drop(rx_a);
drop(rx_b);
}
}

View File

@ -0,0 +1,167 @@
# ADR-0011: chronogrid_net — P2P Swarm Manager (Task 2.5)
**Status:** Accepted
**Date:** 2026-08-22
**Task:** 2.5 — Implement chronogrid_net: P2P Swarm Manager
**Author:** Autonomous Agent
---
## Context
Task 2.5 required implementing the top-level `SwarmManager` in `crates/chronogrid_net/`,
composing all protocol behaviours from Tasks 2.2–2.4 into a single running libp2p `Swarm`
with a clean public API for the application layer.
---
## Decision
### New module: `crates/chronogrid_net/src/swarm_manager.rs`
| Type | Role |
|-------------------------|-------------------------------------------------------------------------------|
| `ChronoGridBehaviour` | `#[derive(NetworkBehaviour)]` composite: mDNS + Kademlia + SyncProtocol + Gossipsub + Identify + Relay |
| `SwarmCommand` | Internal enum for `SwarmHandle → SwarmManager` control channel |
| `SwarmHandle` | Cloneable public API handle: `trigger_sync`, `broadcast_event`, `subscribe_events`, `shutdown` |
| `SwarmManager` | Owns the `Swarm<ChronoGridBehaviour>`, runs the event loop in a Tokio task |
### ChronoGridBehaviour composite
```rust
#[derive(NetworkBehaviour)]
pub struct ChronoGridBehaviour {
pub mdns: MdnsBehaviour,
pub kad: KadBehaviour<MemoryStore>,
pub sync: SyncProtocol,
pub gossip: GossipBehaviour,
pub identify: IdentifyBehaviour,
pub relay: relay::client::Behaviour,
}
```
The `#[derive(NetworkBehaviour)]` macro generates `ChronoGridBehaviourEvent` with one
variant per field. The `SyncProtocol` field generates `SyncProtocolEvent` with an
`Inner` variant wrapping the inner `request_response::Event<SyncRequest, SyncResponse>`,
accessed as `SyncProtocolEvent::Inner(inner_event)` in the event handler.
### SwarmBuilder chain (libp2p 0.54)
```rust
SwarmBuilder::with_existing_identity(keypair)
.with_tokio()
.with_tcp(tcp_config, noise::Config::new, yamux::Config::default)
.with_quic()
.with_relay_client(noise::Config::new, yamux::Config::default)
.with_behaviour(|_keys, relay_behaviour| Ok(ChronoGridBehaviour { ... }))
.with_swarm_config(|cfg| cfg.with_idle_connection_timeout(30s))
.build()
```
Key decisions:
- **Relay client**: In libp2p 0.54, `relay::client::new()` returns a raw transport
with `Output = Connection` (not `(PeerId, Muxer)`), so it cannot be used with
`with_other_transport()`. The correct API is `with_relay_client()` which handles
the transport upgrade internally and provides the `relay::client::Behaviour` to
the `with_behaviour()` closure.
- **TCP + QUIC**: Both transports are enabled; QUIC is preferred for low-latency
connections, TCP+Noise+Yamux is the fallback.
- **Idle timeout**: 30 seconds — balances connection churn vs. resource usage.
### SwarmHandle public API
```rust
impl SwarmHandle {
pub async fn trigger_sync(&self, clock: VectorClock) -> NetResult<()>;
pub async fn broadcast_event(&self, event: GossipEvent) -> NetResult<()>;
pub fn subscribe_events(&self) -> broadcast::Receiver<NetEvent>;
pub async fn shutdown(&self) -> NetResult<()>;
pub fn local_peer_id(&self) -> &PeerId;
}
```
- `trigger_sync()` sends a `SwarmCommand::TriggerSync` to the event loop, which
calls `SyncProtocol::send_sync_request()` for each connected peer.
- `broadcast_event()` sends `SwarmCommand::BroadcastGossip`, which calls
`GossipBehaviour::publish()` on the `chronogrid/state-changes/1` topic.
- `subscribe_events()` returns a `broadcast::Receiver<NetEvent>` — multiple
consumers can subscribe independently (broadcast semantics, 256-event buffer).
- `shutdown()` sends `SwarmCommand::Shutdown`, causing the event loop to exit.
- `SwarmHandle` is `Clone` — multiple components can hold a handle.
### Event loop design
The event loop runs in a `tokio::spawn`ed task and uses `tokio::select!` to
multiplex between swarm events and control commands:
```rust
loop {
tokio::select! {
event = self.swarm.next() => { ... } // requires futures::StreamExt
cmd = self.cmd_rx.recv() => { ... }
}
}
```
`Swarm<T>` implements `futures::Stream`, so `StreamExt::next()` is used (not
`tokio::io::AsyncReadExt`).
### SwarmKey authentication via Identify
After every `IdentifyEvent::Received`, the manager validates the remote peer's
`agent_version` string against the expected swarm key fingerprint
(`chronogrid/<version>/swarm:<fingerprint>`). Peers with a mismatched fingerprint
are immediately disconnected via `swarm.disconnect_peer_id()`.
### CRDT sync flow
1. `ConnectionEstablished` → send `SyncRequest(local_clock)` to new peer.
2. `SyncRequest` received → compute `missing_from(remote_clock, local_clock)` →
encode deltas → send `SyncResponse(encrypted_deltas, local_clock)`.
3. `SyncResponse` received → decrypt deltas → update local clock → emit
`NetEvent::DeltasReceived` → emit `NetEvent::SyncComplete`.
4. If remote is behind → send follow-up `SyncRequest`.
Note: Step 2 currently sends stub `DeltaPacket`s with empty `changeset_bytes`.
Full Automerge changeset loading from the database is implemented in Task 2.6.
### Channels
| Channel | Capacity | Direction |
|----------------------------------|----------|------------------------------|
| `mpsc::channel<SwarmCommand>` | 64 | `SwarmHandle → SwarmManager` |
| `broadcast::channel<NetEvent>` | 256 | `SwarmManager → subscribers` |
---
## Test Coverage
**10 new unit tests** in `swarm_manager::tests`:
| Test | Coverage |
|---------------------------------------------------|-------------------------------------------------------|
| `test_swarm_handle_trigger_sync_sends_command` | `trigger_sync()` sends `TriggerSync` command |
| `test_swarm_handle_broadcast_event_sends_command` | `broadcast_event()` sends `BroadcastGossip` command |
| `test_swarm_handle_shutdown_sends_command` | `shutdown()` sends `Shutdown` command |
| `test_swarm_handle_subscribe_events_receives_broadcast` | `subscribe_events()` receives broadcast events |
| `test_swarm_handle_multiple_subscribers` | Multiple receivers get the same event |
| `test_swarm_handle_local_peer_id` | `local_peer_id()` returns non-empty bytes |
| `test_swarm_handle_trigger_sync_after_shutdown_fails` | Error when event loop is gone |
| `test_swarm_handle_clone_shares_channel` | Cloned handle shares the same command channel |
| `test_swarm_manager_start_and_shutdown` | Full swarm start + graceful shutdown |
| `test_swarm_manager_start_multiple_listen_addrs` | TCP + QUIC listen addresses |
| `test_two_swarms_connect_and_sync` | Two in-process swarms start, trigger sync, shutdown |
---
## Consequences
- `chronogrid_core::sync::SyncEngine` (Task 2.6) receives `NetEvent::DeltasReceived`
via `SwarmHandle::subscribe_events()` and merges the delta packets into the database.
- `chronogrid_daemon` (Task 2.7) calls `SwarmManager::start()` with identity and
swarm key loaded from the encrypted vault.
- The `ChronoGridBehaviour` composite is the single `NetworkBehaviour` type used
by the `Swarm` — no further composition is needed.
- `SyncProtocol::send_sync_request()` is called on `ConnectionEstablished` — this
means every new connection immediately triggers a vector clock exchange.
- No breaking changes to existing modules.

View File

@ -74,7 +74,7 @@
## Task 2.5 — Implement chronogrid_net: P2P Swarm Manager ## Task 2.5 — Implement chronogrid_net: P2P Swarm Manager
- [ ] **Task 2.5**: Implement the top-level `SwarmManager` in `crates/chronogrid_net/`: - [x] **Task 2.5**: Implement the top-level `SwarmManager` in `crates/chronogrid_net/`:
- Implement `SwarmManager` struct: owns the libp2p `Swarm`, runs the event loop in a dedicated Tokio task - Implement `SwarmManager` struct: owns the libp2p `Swarm`, runs the event loop in a dedicated Tokio task
- Public API: - Public API:
- `SwarmManager::start(identity, swarm_key, listen_addrs) -> Result<SwarmHandle>` - `SwarmManager::start(identity, swarm_key, listen_addrs) -> Result<SwarmHandle>`