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                    app_id: "test".to_string(),
133                    // RFC 017: align `device_id` with fixture input.
134                    device_id: id.to_string(),
135                    device_name: format!("Test Node {id}"),
136                    tailscale_hostname: format!("truffle-test-{id}"),
137                    tailscale_id: id.to_string(),
138                    dns_name: None,
139                    ip: Some("127.0.0.1".parse().unwrap()),
140                },
141                local_addr: PeerAddr {
142                    ip: Some("127.0.0.1".parse().unwrap()),
143                    hostname: format!("truffle-test-{id}"),
144                    dns_name: None,
145                },
146                peer_event_tx,
147                mock_peers: Arc::new(tokio::sync::RwLock::new(Vec::new())),
148            }
149        }
150
151        fn event_sender(&self) -> tokio::sync::broadcast::Sender<NetworkPeerEvent> {
152            self.peer_event_tx.clone()
153        }
154    }
155
156    impl NetworkProvider for MockNetworkProvider {
157        fn local_identity(&self) -> NodeIdentity {
158            self.identity.clone()
159        }
160        fn local_addr(&self) -> PeerAddr {
161            self.local_addr.clone()
162        }
163        fn peer_events(&self) -> tokio::sync::broadcast::Receiver<NetworkPeerEvent> {
164            self.peer_event_tx.subscribe()
165        }
166
167        async fn start(&mut self) -> Result<(), NetworkError> {
168            Ok(())
169        }
170        async fn stop(&mut self) -> Result<(), NetworkError> {
171            Ok(())
172        }
173        async fn peers(&self) -> Vec<NetworkPeer> {
174            self.mock_peers.read().await.clone()
175        }
176        async fn dial_tcp(
177            &self,
178            _addr: &str,
179            _port: u16,
180        ) -> Result<tokio::net::TcpStream, NetworkError> {
181            Err(NetworkError::DialFailed("mock".into()))
182        }
183        async fn listen_tcp(&self, _port: u16) -> Result<NetworkTcpListener, NetworkError> {
184            Err(NetworkError::ListenFailed("mock".into()))
185        }
186        async fn unlisten_tcp(&self, _port: u16) -> Result<(), NetworkError> {
187            Ok(())
188        }
189        async fn bind_udp(&self, _port: u16) -> Result<NetworkUdpSocket, NetworkError> {
190            Err(NetworkError::NotRunning)
191        }
192        async fn ping(&self, _addr: &str) -> Result<PingResult, NetworkError> {
193            Ok(PingResult {
194                latency: Duration::from_millis(1),
195                connection: "direct".to_string(),
196                peer_addr: None,
197            })
198        }
199        async fn health(&self) -> HealthInfo {
200            HealthInfo {
201                state: "running".to_string(),
202                healthy: true,
203                ..Default::default()
204            }
205        }
206    }
207
208    fn ws_config(port: u16) -> WsConfig {
209        WsConfig {
210            port,
211            ping_interval: Duration::from_secs(300),
212            pong_timeout: Duration::from_secs(300),
213            ..Default::default()
214        }
215    }
216
217    async fn random_port() -> u16 {
218        let l = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
219        l.local_addr().unwrap().port()
220    }
221
222    fn make_loopback_peer(id: &str) -> NetworkPeer {
223        NetworkPeer {
224            id: id.to_string(),
225            hostname: format!("truffle-test-{id}"),
226            ip: "127.0.0.1".parse().unwrap(),
227            online: true,
228            cur_addr: Some("127.0.0.1:41641".to_string()),
229            relay: None,
230            os: Some("linux".to_string()),
231            last_seen: Some("2026-03-25T12:00:00Z".to_string()),
232            key_expiry: None,
233            dns_name: None,
234        }
235    }
236
237    async fn make_test_node(
238        id: &str,
239        ws_port: u16,
240    ) -> (
241        Node<MockNetworkProvider>,
242        tokio::sync::broadcast::Sender<NetworkPeerEvent>,
243    ) {
244        let provider = MockNetworkProvider::new(id);
245        let event_tx = provider.event_sender();
246        let network = Arc::new(provider);
247        let ws_transport = Arc::new(WebSocketTransport::new(network.clone(), ws_config(ws_port)));
248        let session = Arc::new(PeerRegistry::new(network.clone(), ws_transport));
249        session.start().await;
250
251        let codec: Arc<dyn EnvelopeCodec> = Arc::new(JsonCodec);
252        let node = Node::from_parts(network, session, codec);
253        (node, event_tx)
254    }
255
256    #[tokio::test]
257    async fn test_send_and_wait_timeout() {
258        let ws_port = random_port().await;
259        let (node, event_tx) = make_test_node("node-a", ws_port).await;
260
261        // Inject a peer so send doesn't fail with UnknownPeer.
262        let _ = event_tx.send(NetworkPeerEvent::Joined(make_loopback_peer("peer-b")));
263        tokio::time::sleep(Duration::from_millis(50)).await;
264
265        // send_typed will fail at session layer (no real WS), but we want to
266        // test the timeout path. Use a filter that never matches.
267        let result = send_and_wait(
268            &node,
269            "peer-b",
270            "test-ns",
271            "ping",
272            &serde_json::json!({}),
273            Duration::from_millis(100),
274            |_msg| -> Option<()> { None },
275        )
276        .await;
277
278        assert!(matches!(
279            result,
280            Err(RequestError::Timeout) | Err(RequestError::Send(_))
281        ));
282    }
283
284    #[tokio::test]
285    async fn test_send_and_wait_send_failure_unknown_peer() {
286        let ws_port = random_port().await;
287        let (node, _event_tx) = make_test_node("node-a", ws_port).await;
288
289        // No peers injected — send should fail with UnknownPeer.
290        let result = send_and_wait(
291            &node,
292            "nonexistent",
293            "test-ns",
294            "ping",
295            &serde_json::json!({}),
296            Duration::from_secs(1),
297            |_msg| -> Option<()> { Some(()) },
298        )
299        .await;
300
301        assert!(matches!(result, Err(RequestError::Send(_))));
302    }
303
304    #[tokio::test]
305    async fn test_send_and_wait_filter_matches() {
306        // We can't easily inject a message through the WS layer in unit tests,
307        // so we test the filter logic directly on NamespacedMessage values.
308        let msg = NamespacedMessage {
309            from: "peer-b".to_string(),
310            namespace: "test-ns".to_string(),
311            msg_type: "response".to_string(),
312            payload: serde_json::json!({"value": 42}),
313            timestamp: None,
314        };
315
316        // Filter that looks for msg_type == "response"
317        let filter = |m: &NamespacedMessage| -> Option<i64> {
318            if m.msg_type == "response" {
319                m.payload.get("value")?.as_i64()
320            } else {
321                None
322            }
323        };
324
325        assert_eq!(filter(&msg), Some(42));
326
327        // Filter that doesn't match
328        let non_matching = NamespacedMessage {
329            from: "peer-b".to_string(),
330            namespace: "test-ns".to_string(),
331            msg_type: "other".to_string(),
332            payload: serde_json::json!({}),
333            timestamp: None,
334        };
335        assert_eq!(filter(&non_matching), None);
336    }
337}