Skip to main content

truffle_core/
request_reply.rs

1//! Request/reply utility for correlated message exchange.
2//!
3//! Provides [`send_and_wait`], a function that sends a namespaced message to a
4//! peer and waits for a reply matching a caller-provided filter. This
5//! eliminates the subscribe → send → loop → timeout boilerplate that
6//! subsystems like file transfer repeat at every call site.
7//!
8//! # Example
9//!
10//! ```ignore
11//! use truffle_core::request_reply::{send_and_wait, RequestError};
12//!
13//! let port = send_and_wait(
14//!     &node, "peer-id", "my-ns", "request", &payload,
15//!     Duration::from_secs(10),
16//!     |msg| {
17//!         if msg.msg_type == "response" && msg.from == "peer-id" {
18//!             Some(msg.payload.clone())
19//!         } else {
20//!             None
21//!         }
22//!     },
23//! ).await?;
24//! ```
25
26use std::time::Duration;
27
28use tokio::sync::broadcast;
29
30use crate::network::NetworkProvider;
31use crate::node::{NamespacedMessage, Node, NodeError};
32
33/// Errors returned by [`send_and_wait`].
34#[derive(Debug, thiserror::Error)]
35pub enum RequestError {
36    /// The initial send failed (unknown peer, offline, connection error).
37    #[error("send failed: {0}")]
38    Send(NodeError),
39    /// No matching reply arrived within the timeout.
40    #[error("timed out waiting for reply")]
41    Timeout,
42    /// The namespace subscription channel was closed.
43    #[error("subscription channel closed")]
44    ChannelClosed,
45}
46
47/// Send a namespaced message and wait for a reply that matches a predicate.
48///
49/// 1. Subscribes to `namespace` (before sending, to avoid missing the reply).
50/// 2. Sends the message via [`Node::send_typed`].
51/// 3. Loops on incoming messages, calling `filter` on each.
52/// 4. Returns the first `Some(R)` from the filter, or times out.
53///
54/// The filter receives **every** message on the namespace, not just from the
55/// target peer — the caller decides what constitutes a valid reply.
56pub async fn send_and_wait<N, F, R>(
57    node: &Node<N>,
58    peer_id: &str,
59    namespace: &str,
60    msg_type: &str,
61    payload: &serde_json::Value,
62    timeout: Duration,
63    filter: F,
64) -> Result<R, RequestError>
65where
66    N: NetworkProvider + 'static,
67    F: Fn(&NamespacedMessage) -> Option<R>,
68{
69    // Subscribe BEFORE sending so we don't miss a fast reply.
70    let mut rx = node.subscribe(namespace);
71
72    node.send_typed(peer_id, namespace, msg_type, payload)
73        .await
74        .map_err(RequestError::Send)?;
75
76    let deadline = tokio::time::Instant::now() + timeout;
77
78    loop {
79        let remaining = deadline.duration_since(tokio::time::Instant::now());
80        if remaining.is_zero() {
81            return Err(RequestError::Timeout);
82        }
83
84        match tokio::time::timeout(remaining, rx.recv()).await {
85            Ok(Ok(msg)) => {
86                if let Some(result) = filter(&msg) {
87                    return Ok(result);
88                }
89                // Not our reply — keep waiting.
90            }
91            Ok(Err(broadcast::error::RecvError::Lagged(n))) => {
92                tracing::warn!(missed = n, "request_reply: receiver lagged");
93                // Continue — the reply may still arrive.
94            }
95            Ok(Err(broadcast::error::RecvError::Closed)) => {
96                return Err(RequestError::ChannelClosed);
97            }
98            Err(_elapsed) => {
99                return Err(RequestError::Timeout);
100            }
101        }
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108    use std::sync::Arc;
109    use tokio::time::Duration;
110
111    use crate::envelope::codec::JsonCodec;
112    use crate::envelope::EnvelopeCodec;
113    use crate::network::*;
114    use crate::session::PeerRegistry;
115    use crate::transport::websocket::WebSocketTransport;
116    use crate::transport::WsConfig;
117
118    // ── Mock network provider (same pattern as node.rs tests) ───────
119
120    struct MockNetworkProvider {
121        identity: NodeIdentity,
122        local_addr: PeerAddr,
123        peer_event_tx: tokio::sync::broadcast::Sender<NetworkPeerEvent>,
124        mock_peers: Arc<tokio::sync::RwLock<Vec<NetworkPeer>>>,
125    }
126
127    impl MockNetworkProvider {
128        fn new(id: &str) -> Self {
129            let (peer_event_tx, _) = tokio::sync::broadcast::channel(64);
130            Self {
131                identity: NodeIdentity {
132                    id: id.to_string(),
133                    hostname: format!("truffle-test-{id}"),
134                    name: format!("Test Node {id}"),
135                    dns_name: None,
136                    ip: Some("127.0.0.1".parse().unwrap()),
137                },
138                local_addr: PeerAddr {
139                    ip: Some("127.0.0.1".parse().unwrap()),
140                    hostname: format!("truffle-test-{id}"),
141                    dns_name: None,
142                },
143                peer_event_tx,
144                mock_peers: Arc::new(tokio::sync::RwLock::new(Vec::new())),
145            }
146        }
147
148        fn event_sender(&self) -> tokio::sync::broadcast::Sender<NetworkPeerEvent> {
149            self.peer_event_tx.clone()
150        }
151    }
152
153    impl NetworkProvider for MockNetworkProvider {
154        fn local_identity(&self) -> NodeIdentity { self.identity.clone() }
155        fn local_addr(&self) -> PeerAddr { self.local_addr.clone() }
156        fn peer_events(&self) -> tokio::sync::broadcast::Receiver<NetworkPeerEvent> {
157            self.peer_event_tx.subscribe()
158        }
159
160        async fn start(&mut self) -> Result<(), NetworkError> { Ok(()) }
161        async fn stop(&mut self) -> Result<(), NetworkError> { Ok(()) }
162        async fn peers(&self) -> Vec<NetworkPeer> { self.mock_peers.read().await.clone() }
163        async fn dial_tcp(&self, _addr: &str, _port: u16) -> Result<tokio::net::TcpStream, NetworkError> {
164            Err(NetworkError::DialFailed("mock".into()))
165        }
166        async fn listen_tcp(&self, _port: u16) -> Result<NetworkTcpListener, NetworkError> {
167            Err(NetworkError::ListenFailed("mock".into()))
168        }
169        async fn unlisten_tcp(&self, _port: u16) -> Result<(), NetworkError> { Ok(()) }
170        async fn bind_udp(&self, _port: u16) -> Result<NetworkUdpSocket, NetworkError> {
171            Err(NetworkError::NotRunning)
172        }
173        async fn ping(&self, _addr: &str) -> Result<PingResult, NetworkError> {
174            Ok(PingResult { latency: Duration::from_millis(1), connection: "direct".to_string(), peer_addr: None })
175        }
176        async fn health(&self) -> HealthInfo {
177            HealthInfo { state: "running".to_string(), healthy: true, ..Default::default() }
178        }
179    }
180
181    fn ws_config(port: u16) -> WsConfig {
182        WsConfig {
183            port,
184            ping_interval: Duration::from_secs(300),
185            pong_timeout: Duration::from_secs(300),
186            ..Default::default()
187        }
188    }
189
190    async fn random_port() -> u16 {
191        let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
192        l.local_addr().unwrap().port()
193    }
194
195    fn make_loopback_peer(id: &str) -> NetworkPeer {
196        NetworkPeer {
197            id: id.to_string(),
198            hostname: format!("truffle-test-{id}"),
199            ip: "127.0.0.1".parse().unwrap(),
200            online: true,
201            cur_addr: Some("127.0.0.1:41641".to_string()),
202            relay: None,
203            os: Some("linux".to_string()),
204            last_seen: Some("2026-03-25T12:00:00Z".to_string()),
205            key_expiry: None,
206            dns_name: None,
207        }
208    }
209
210    async fn make_test_node(
211        id: &str,
212        ws_port: u16,
213    ) -> (
214        Node<MockNetworkProvider>,
215        tokio::sync::broadcast::Sender<NetworkPeerEvent>,
216    ) {
217        let provider = MockNetworkProvider::new(id);
218        let event_tx = provider.event_sender();
219        let network = Arc::new(provider);
220        let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config(ws_port)));
221        let session = Arc::new(PeerRegistry::new(network.clone(), ws_transport));
222        session.start().await;
223
224        let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
225        let node = Node::from_parts(network, session, codec);
226        (node, event_tx)
227    }
228
229    #[tokio::test]
230    async fn test_send_and_wait_timeout() {
231        let ws_port = random_port().await;
232        let (node, event_tx) = make_test_node("node-a", ws_port).await;
233
234        // Inject a peer so send doesn't fail with UnknownPeer.
235        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-b")));
236        tokio::time::sleep(Duration::from_millis(50)).await;
237
238        // send_typed will fail at session layer (no real WS), but we want to
239        // test the timeout path. Use a filter that never matches.
240        let result = send_and_wait(
241            &node,
242            "peer-b",
243            "test-ns",
244            "ping",
245            &serde_json::json!({}),
246            Duration::from_millis(100),
247            |_msg| -> Option<()> { None },
248        )
249        .await;
250
251        assert!(matches!(result, Err(RequestError::Timeout) | Err(RequestError::Send(_))));
252    }
253
254    #[tokio::test]
255    async fn test_send_and_wait_send_failure_unknown_peer() {
256        let ws_port = random_port().await;
257        let (node, _event_tx) = make_test_node("node-a", ws_port).await;
258
259        // No peers injected — send should fail with UnknownPeer.
260        let result = send_and_wait(
261            &node,
262            "nonexistent",
263            "test-ns",
264            "ping",
265            &serde_json::json!({}),
266            Duration::from_secs(1),
267            |_msg| -> Option<()> { Some(()) },
268        )
269        .await;
270
271        assert!(matches!(result, Err(RequestError::Send(_))));
272    }
273
274    #[tokio::test]
275    async fn test_send_and_wait_filter_matches() {
276        // We can't easily inject a message through the WS layer in unit tests,
277        // so we test the filter logic directly on NamespacedMessage values.
278        let msg = NamespacedMessage {
279            from: "peer-b".to_string(),
280            namespace: "test-ns".to_string(),
281            msg_type: "response".to_string(),
282            payload: serde_json::json!({"value": 42}),
283            timestamp: None,
284        };
285
286        // Filter that looks for msg_type == "response"
287        let filter = |m: &NamespacedMessage| -> Option<i64> {
288            if m.msg_type == "response" {
289                m.payload.get("value")?.as_i64()
290            } else {
291                None
292            }
293        };
294
295        assert_eq!(filter(&msg), Some(42));
296
297        // Filter that doesn't match
298        let non_matching = NamespacedMessage {
299            from: "peer-b".to_string(),
300            namespace: "test-ns".to_string(),
301            msg_type: "other".to_string(),
302            payload: serde_json::json!({}),
303            timestamp: None,
304        };
305        assert_eq!(filter(&non_matching), None);
306    }
307}