Skip to main content

qualia_core_db/p2p/
sync_node.rs

1//! **libp2p sync node** (T3.1) — a standalone, noise-encrypted request-response node that drives the
2//! [`super::sync_ops`] op-transfer protocol between peers. This is the swarm behind the sync transport:
3//! it serves a local [`SyncOpRelay`] to inbound peers and exposes async `publish`/`pull` to a peer.
4//!
5//! Standalone by design — it does **not** touch the daemon's swarm (whose `QualiaRequest` enum is matched
6//! exhaustively). The blocking `SyncTransport` adapter (client-core) is a thin `block_on` wrapper over the
7//! async methods here; kept separate so libp2p stays out of the wasm-facing client crate.
8//!
9//! A dumb pipe: it moves opaque, already-signed operation frames and validates nothing — all trust stays
10//! in the consuming node's fail-closed inbox, exactly as the in-memory and HTTP relay transports.
11
12#![cfg(not(target_arch = "wasm32"))]
13
14use crate::p2p::sync_ops::{
15    SyncOpCodec, SyncOpRelay, SyncOpRequest, SyncOpResponse, SYNC_OP_PROTOCOL,
16};
17use libp2p::futures::StreamExt;
18use libp2p::request_response::{self, OutboundRequestId, ProtocolSupport};
19use libp2p::swarm::{NetworkBehaviour, SwarmEvent};
20use libp2p::{Multiaddr, PeerId, StreamProtocol, Swarm};
21use std::collections::HashMap;
22use tokio::sync::{mpsc, oneshot};
23
24#[derive(NetworkBehaviour)]
25struct SyncBehaviour {
26    rr: request_response::Behaviour<SyncOpCodec>,
27}
28
29enum Cmd {
30    Listen(Multiaddr, oneshot::Sender<Result<Multiaddr, String>>),
31    AddPeer(PeerId, Multiaddr),
32    Publish(PeerId, Vec<Vec<u8>>, oneshot::Sender<Result<u64, String>>),
33    Pull(
34        PeerId,
35        u64,
36        oneshot::Sender<Result<(Vec<Vec<u8>>, u64), String>>,
37    ),
38}
39
40enum Pending {
41    Publish(oneshot::Sender<Result<u64, String>>),
42    Pull(oneshot::Sender<Result<(Vec<Vec<u8>>, u64), String>>),
43}
44
45/// A running libp2p sync node. Spawn it on a tokio runtime; drive it with the async methods.
46pub struct Libp2pSyncNode {
47    pub peer_id: PeerId,
48    cmd_tx: mpsc::UnboundedSender<Cmd>,
49}
50
51impl Libp2pSyncNode {
52    /// Spawn a node on the current tokio runtime, serving `relay` to inbound peers.
53    pub fn spawn(relay: SyncOpRelay) -> Self {
54        let key = libp2p::identity::Keypair::generate_ed25519();
55        let peer_id = PeerId::from(key.public());
56        let (cmd_tx, cmd_rx) = mpsc::unbounded_channel();
57
58        let swarm = libp2p::SwarmBuilder::with_existing_identity(key)
59            .with_tokio()
60            .with_tcp(
61                libp2p::tcp::Config::default(),
62                libp2p::noise::Config::new,
63                libp2p::yamux::Config::default,
64            )
65            .expect("tcp transport")
66            .with_behaviour(|_| SyncBehaviour {
67                rr: request_response::Behaviour::new(
68                    [(StreamProtocol::new(SYNC_OP_PROTOCOL), ProtocolSupport::Full)],
69                    request_response::Config::default(),
70                ),
71            })
72            .expect("behaviour")
73            .with_swarm_config(|c| {
74                c.with_idle_connection_timeout(std::time::Duration::from_secs(60))
75            })
76            .build();
77
78        tokio::spawn(event_loop(swarm, relay, cmd_rx));
79        Self { peer_id, cmd_tx }
80    }
81
82    async fn call<R>(&self, make: impl FnOnce(oneshot::Sender<R>) -> Cmd) -> Result<R, String> {
83        let (tx, rx) = oneshot::channel();
84        self.cmd_tx
85            .send(make(tx))
86            .map_err(|_| "sync node stopped".to_string())?;
87        rx.await
88            .map_err(|_| "sync node dropped the response".to_string())
89    }
90
91    /// Listen on `addr` (e.g. `/ip4/127.0.0.1/tcp/0`), returning the actual bound multiaddr.
92    pub async fn listen(&self, addr: &str) -> Result<Multiaddr, String> {
93        let a: Multiaddr = addr.parse().map_err(|e| format!("bad multiaddr: {e}"))?;
94        self.call(|tx| Cmd::Listen(a, tx)).await.and_then(|r| r)
95    }
96
97    /// Teach this node a peer's address so `publish`/`pull` can reach (and auto-dial) it.
98    pub fn add_peer(&self, peer: PeerId, addr: Multiaddr) {
99        let _ = self.cmd_tx.send(Cmd::AddPeer(peer, addr));
100    }
101
102    /// Publish opaque signed-op frames to `peer`; returns how many were newly accepted.
103    pub async fn publish(&self, peer: PeerId, frames: Vec<Vec<u8>>) -> Result<u64, String> {
104        self.call(|tx| Cmd::Publish(peer, frames, tx))
105            .await
106            .and_then(|r| r)
107    }
108
109    /// Pull `peer`'s frames after `since`; returns them plus the next cursor.
110    pub async fn pull(&self, peer: PeerId, since: u64) -> Result<(Vec<Vec<u8>>, u64), String> {
111        self.call(|tx| Cmd::Pull(peer, since, tx))
112            .await
113            .and_then(|r| r)
114    }
115}
116
117async fn event_loop(
118    mut swarm: Swarm<SyncBehaviour>,
119    relay: SyncOpRelay,
120    mut cmd_rx: mpsc::UnboundedReceiver<Cmd>,
121) {
122    let mut pending: HashMap<OutboundRequestId, Pending> = HashMap::new();
123    let mut pending_listen: Option<oneshot::Sender<Result<Multiaddr, String>>> = None;
124
125    loop {
126        tokio::select! {
127            cmd = cmd_rx.recv() => match cmd {
128                None => break, // all handles dropped
129                Some(Cmd::Listen(addr, tx)) => match swarm.listen_on(addr) {
130                    Ok(_) => pending_listen = Some(tx),
131                    Err(e) => { let _ = tx.send(Err(e.to_string())); }
132                },
133                Some(Cmd::AddPeer(peer, addr)) => {
134                    // `Swarm::add_peer_address` replaces the deprecated request-response
135                    // `Behaviour::add_address` (libp2p ≥ 0.54): the address book now lives on the
136                    // swarm, shared across behaviours, not per-behaviour.
137                    swarm.add_peer_address(peer, addr);
138                }
139                Some(Cmd::Publish(peer, frames, tx)) => {
140                    let id = swarm.behaviour_mut().rr.send_request(&peer, SyncOpRequest::Publish { op_frames: frames });
141                    pending.insert(id, Pending::Publish(tx));
142                }
143                Some(Cmd::Pull(peer, since, tx)) => {
144                    let id = swarm.behaviour_mut().rr.send_request(&peer, SyncOpRequest::PullSince { cursor: since });
145                    pending.insert(id, Pending::Pull(tx));
146                }
147            },
148            event = swarm.select_next_some() => match event {
149                SwarmEvent::NewListenAddr { address, .. } => {
150                    if let Some(tx) = pending_listen.take() { let _ = tx.send(Ok(address)); }
151                }
152                SwarmEvent::Behaviour(SyncBehaviourEvent::Rr(request_response::Event::Message { message, .. })) => match message {
153                    // Inbound: serve our relay (dumb pipe — no validation here).
154                    request_response::Message::Request { request, channel, .. } => {
155                        let resp = relay.handle(request);
156                        let _ = swarm.behaviour_mut().rr.send_response(channel, resp);
157                    }
158                    // Outbound response: resolve the waiting caller.
159                    request_response::Message::Response { request_id, response } => {
160                        match (pending.remove(&request_id), response) {
161                            (Some(Pending::Publish(tx)), SyncOpResponse::Published { accepted }) => { let _ = tx.send(Ok(accepted)); }
162                            (Some(Pending::Pull(tx)), SyncOpResponse::Pulled { op_frames, next_cursor }) => { let _ = tx.send(Ok((op_frames, next_cursor))); }
163                            (Some(Pending::Publish(tx)), _) => { let _ = tx.send(Err("unexpected response to publish".into())); }
164                            (Some(Pending::Pull(tx)), _) => { let _ = tx.send(Err("unexpected response to pull".into())); }
165                            (None, _) => {}
166                        }
167                    }
168                },
169                SwarmEvent::Behaviour(SyncBehaviourEvent::Rr(request_response::Event::OutboundFailure { request_id, error, .. })) => {
170                    if let Some(p) = pending.remove(&request_id) {
171                        let msg = format!("libp2p outbound failure: {error}");
172                        match p {
173                            Pending::Publish(tx) => { let _ = tx.send(Err(msg)); }
174                            Pending::Pull(tx) => { let _ = tx.send(Err(msg)); }
175                        }
176                    }
177                }
178                _ => {}
179            }
180        }
181    }
182}
183
184/// A **blocking** libp2p sync client — the thin `block_on` wrapper the module docs promise. It owns a
185/// tokio runtime and a [`Libp2pSyncNode`], pins a single target peer, and exposes blocking
186/// `publish_frames`/`pull_frames` over opaque op frames. Keeping the runtime + libp2p types here lets
187/// the client-core `SyncTransport` adapter stay a plain synchronous caller (and keeps libp2p out of the
188/// wasm-facing crate). A dumb pipe like the async node beneath it: it moves already-signed frames and
189/// validates nothing — all trust stays in the consuming node's fail-closed inbox.
190pub struct BlockingSyncClient {
191    // Field order matters for drop: the node (and its `cmd_tx`) drops before the runtime, so the event
192    // loop sees its command channel close and exits cleanly before the runtime is torn down.
193    node: Libp2pSyncNode,
194    peer: PeerId,
195    rt: tokio::runtime::Runtime,
196}
197
198impl BlockingSyncClient {
199    /// Connect to a relay/peer: spawn a node on a dedicated multi-threaded runtime (whose worker drives
200    /// the event loop continuously), register the peer's address — the first `publish`/`pull` auto-dials
201    /// it over noise-encrypted TCP — and serve `relay` to inbound peers. `peer_id` is the base58 peer id;
202    /// `peer_addr` is a libp2p multiaddr (e.g. `/ip4/127.0.0.1/tcp/4001`).
203    pub fn connect(relay: SyncOpRelay, peer_id: &str, peer_addr: &str) -> Result<Self, String> {
204        let peer: PeerId = peer_id.parse().map_err(|e| format!("bad peer id: {e}"))?;
205        let addr: Multiaddr = peer_addr
206            .parse()
207            .map_err(|e| format!("bad multiaddr: {e}"))?;
208        let rt = tokio::runtime::Builder::new_multi_thread()
209            .worker_threads(1)
210            .enable_all()
211            .build()
212            .map_err(|e| format!("sync runtime: {e}"))?;
213        // Spawn inside the runtime context so the event-loop task is scheduled on the runtime's worker
214        // (which drives it in the background, independent of `block_on`).
215        let node = {
216            let _guard = rt.enter();
217            Libp2pSyncNode::spawn(relay)
218        };
219        node.add_peer(peer, addr);
220        Ok(Self { node, peer, rt })
221    }
222
223    /// This node's own peer id (so a peer can be told how to dial us back).
224    pub fn local_peer_id(&self) -> PeerId {
225        self.node.peer_id
226    }
227
228    /// Publish opaque signed-op frames to the pinned peer's relay; returns how many were newly accepted.
229    pub fn publish_frames(&self, frames: Vec<Vec<u8>>) -> Result<u64, String> {
230        self.rt.block_on(self.node.publish(self.peer, frames))
231    }
232
233    /// Pull the pinned peer's frames after `since`; returns them plus the next cursor.
234    pub fn pull_frames(&self, since: u64) -> Result<(Vec<Vec<u8>>, u64), String> {
235        self.rt.block_on(self.node.pull(self.peer, since))
236    }
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242
243    /// Two libp2p nodes exchange operations end-to-end over a real (noise-encrypted, localhost-TCP)
244    /// connection: B publishes to A, then pulls them back — the p2p sync transport, proven live.
245    #[test]
246    fn two_nodes_exchange_ops_over_libp2p() {
247        let rt = tokio::runtime::Builder::new_current_thread()
248            .enable_all()
249            .build()
250            .unwrap();
251        rt.block_on(async {
252            // Node A — the responder, holds the shared relay we can inspect.
253            let relay_a = SyncOpRelay::new();
254            let a = Libp2pSyncNode::spawn(relay_a.clone());
255            let a_addr = a.listen("/ip4/127.0.0.1/tcp/0").await.expect("A listen");
256
257            // Node B — dials A and exchanges ops.
258            let b = Libp2pSyncNode::spawn(SyncOpRelay::new());
259            b.add_peer(a.peer_id, a_addr);
260
261            let accepted = b
262                .publish(a.peer_id, vec![b"op-1".to_vec(), b"op-2".to_vec()])
263                .await
264                .expect("publish");
265            assert_eq!(accepted, 2, "both ops newly accepted by A");
266            assert_eq!(relay_a.len(), 2, "A's relay holds the published ops");
267
268            let (frames, cursor) = b.pull(a.peer_id, 0).await.expect("pull");
269            assert_eq!(frames, vec![b"op-1".to_vec(), b"op-2".to_vec()]);
270            assert_eq!(cursor, 2);
271        });
272    }
273
274    /// The **blocking** client (the wrapper the client-core transport drives) round-trips frames against
275    /// a listening responder node — no `.await` at the call site, its own runtime driving the event loop.
276    #[test]
277    fn blocking_client_round_trips_against_a_listening_node() {
278        // Responder A: listens and serves its relay on its own runtime; keep `a` + `rt_a` alive so its
279        // command channel (and thus its event loop) stays open for the whole test.
280        let rt_a = tokio::runtime::Builder::new_multi_thread()
281            .worker_threads(1)
282            .enable_all()
283            .build()
284            .unwrap();
285        let relay_a = SyncOpRelay::new();
286        let a = {
287            let _guard = rt_a.enter();
288            Libp2pSyncNode::spawn(relay_a.clone())
289        };
290        let a_addr = rt_a
291            .block_on(a.listen("/ip4/127.0.0.1/tcp/0"))
292            .expect("A listen");
293
294        // B: the blocking client, constructed from A's string peer id + multiaddr (exercises parsing).
295        let b = BlockingSyncClient::connect(
296            SyncOpRelay::new(),
297            &a.peer_id.to_string(),
298            &a_addr.to_string(),
299        )
300        .expect("connect");
301
302        let accepted = b
303            .publish_frames(vec![b"op-1".to_vec(), b"op-2".to_vec()])
304            .expect("publish");
305        assert_eq!(accepted, 2, "both frames newly accepted by A");
306        assert_eq!(relay_a.len(), 2, "A's relay holds the published frames");
307
308        let (frames, cursor) = b.pull_frames(0).expect("pull");
309        assert_eq!(frames, vec![b"op-1".to_vec(), b"op-2".to_vec()]);
310        assert_eq!(cursor, 2);
311    }
312}