Skip to main content

truffle_core/transport/
websocket.rs

1//! WebSocket transport — [`StreamTransport`] implementation over WebSocket.
2//!
3//! Uses `tokio-tungstenite` for the WS protocol and delegates all raw TCP
4//! connectivity to Layer 3's [`NetworkProvider`].
5//!
6//! # Connection flow
7//!
8//! **Client (connect)**:
9//! 1. `NetworkProvider::dial_tcp(addr, port)` -> raw `TcpStream`
10//! 2. WebSocket client handshake (`tokio_tungstenite::client_async_with_config`)
11//! 3. Send [`Handshake`] as a text frame (JSON)
12//! 4. Receive peer's [`Handshake`], validate protocol version
13//! 5. Split stream into read/write halves, return [`WsFramedStream`]
14//!
15//! **Server (listen)**:
16//! 1. `NetworkProvider::listen_tcp(port)` -> incoming `TcpStream`s
17//! 2. For each: WebSocket server handshake (`tokio_tungstenite::accept_async_with_config`)
18//! 3. Receive peer's [`Handshake`], validate, send own [`Handshake`]
19//! 4. Split stream into read/write halves, yield [`WsFramedStream`] via [`StreamListener`]
20//!
21//! # Heartbeat
22//!
23//! After the handshake, a background task sends WebSocket Ping frames at
24//! `WsConfig::ping_interval` on the write half. The read half updates a
25//! shared `last_pong` timestamp when it receives a Pong. The heartbeat task
26//! checks this timestamp on each ping cycle and closes the connection if
27//! `pong_timeout` has been exceeded.
28
29use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
30use std::sync::Arc;
31use std::time::Duration;
32
33use futures_util::stream::SplitSink;
34use futures_util::stream::SplitStream;
35use futures_util::{SinkExt, StreamExt};
36use tokio::net::TcpStream;
37use tokio::sync::Mutex;
38use tokio_tungstenite::tungstenite::protocol::WebSocketConfig;
39use tokio_tungstenite::tungstenite::Message;
40use tokio_tungstenite::WebSocketStream;
41
42use crate::network::{NetworkProvider, PeerAddr};
43
44use super::{
45    resolve_dial_addr, FramedStream, Handshake, StreamListener, StreamTransport, TransportError,
46    WsConfig, PROTOCOL_VERSION,
47};
48
49/// Handshake timeout — maximum time to wait for handshake exchange.
50const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(10);
51
52// ---------------------------------------------------------------------------
53// WebSocketTransport
54// ---------------------------------------------------------------------------
55
56/// WebSocket-based [`StreamTransport`] implementation.
57///
58/// Generic over the [`NetworkProvider`] type `N`. Owns a shared reference
59/// to Layer 3 for establishing raw TCP connections, and a [`WsConfig`]
60/// for protocol parameters.
61///
62/// # Example
63///
64/// ```ignore
65/// use std::sync::Arc;
66/// use truffle_core::transport::{WsConfig, websocket::WebSocketTransport};
67///
68/// let ws = WebSocketTransport::new(Arc::new(provider), WsConfig::default());
69/// let stream = ws.connect(&peer_addr).await?;
70/// ```
71pub struct WebSocketTransport<N: NetworkProvider> {
72    /// Layer 3 network provider for raw TCP dial/listen.
73    network: Arc<N>,
74    /// WebSocket configuration.
75    config: WsConfig,
76}
77
78impl<N: NetworkProvider + 'static> WebSocketTransport<N> {
79    /// Create a new WebSocket transport.
80    ///
81    /// - `network`: An `Arc<N>` where `N: NetworkProvider`.
82    /// - `config`: WebSocket configuration (port, heartbeat intervals, etc.).
83    pub fn new(network: Arc<N>, config: WsConfig) -> Self {
84        Self { network, config }
85    }
86
87    /// Build the local handshake message using the network provider's identity.
88    fn local_handshake(&self) -> Handshake {
89        let identity = self.network.local_identity();
90        Handshake {
91            peer_id: identity.id,
92            capabilities: vec!["ws".to_string(), "binary".to_string()],
93            protocol_version: PROTOCOL_VERSION,
94        }
95    }
96
97    /// Build a tungstenite `WebSocketConfig` from our `WsConfig`.
98    fn ws_protocol_config(&self) -> WebSocketConfig {
99        let mut config = WebSocketConfig::default();
100        config.max_message_size = Some(self.config.max_message_size);
101        config.max_frame_size = Some(self.config.max_message_size);
102        config
103    }
104
105    /// Perform the client-side handshake: send our handshake, receive theirs.
106    async fn client_handshake(
107        ws: &mut WebSocketStream<TcpStream>,
108        local_hs: &Handshake,
109    ) -> Result<Handshake, TransportError> {
110        // Send our handshake as a text frame
111        let hs_json = serde_json::to_string(local_hs)
112            .map_err(|e| TransportError::Serialize(e.to_string()))?;
113        ws.send(Message::Text(hs_json.into()))
114            .await
115            .map_err(|e| TransportError::HandshakeFailed(format!("send handshake: {e}")))?;
116
117        // Receive peer's handshake
118        let remote_hs = receive_handshake(ws).await?;
119
120        // Validate protocol version
121        if remote_hs.protocol_version != PROTOCOL_VERSION {
122            return Err(TransportError::VersionMismatch {
123                local: PROTOCOL_VERSION,
124                remote: remote_hs.protocol_version,
125            });
126        }
127
128        Ok(remote_hs)
129    }
130}
131
132/// Receive and parse a handshake message from a WebSocket stream.
133async fn receive_handshake(
134    ws: &mut WebSocketStream<TcpStream>,
135) -> Result<Handshake, TransportError> {
136    match ws.next().await {
137        Some(Ok(Message::Text(text))) => serde_json::from_str::<Handshake>(&text)
138            .map_err(|e| TransportError::HandshakeFailed(format!("parse handshake: {e}"))),
139        Some(Ok(other)) => Err(TransportError::HandshakeFailed(format!(
140            "expected text frame for handshake, got: {other:?}"
141        ))),
142        Some(Err(e)) => Err(TransportError::HandshakeFailed(format!(
143            "receive handshake: {e}"
144        ))),
145        None => Err(TransportError::HandshakeFailed(
146            "connection closed before handshake".to_string(),
147        )),
148    }
149}
150
151impl<N: NetworkProvider + 'static> StreamTransport for WebSocketTransport<N> {
152    type Stream = WsFramedStream;
153
154    async fn connect(&self, addr: &PeerAddr) -> Result<Self::Stream, TransportError> {
155        // Step 1: Dial TCP via Layer 3
156        let dial_addr = resolve_dial_addr(addr);
157        tracing::debug!(addr = %dial_addr, port = self.config.port, "ws: dialing peer");
158
159        let tcp_stream = self
160            .network
161            .dial_tcp(&dial_addr, self.config.port)
162            .await
163            .map_err(|e| TransportError::ConnectFailed(format!("dial tcp: {e}")))?;
164
165        // Step 2: WebSocket client upgrade (with max_message_size config)
166        let ws_url = format!("ws://{dial_addr}:{}/ws", self.config.port);
167        let ws_config = self.ws_protocol_config();
168        let (mut ws, _response) =
169            tokio_tungstenite::client_async_with_config(ws_url, tcp_stream, Some(ws_config))
170                .await
171                .map_err(|e| TransportError::ConnectFailed(format!("ws upgrade: {e}")))?;
172
173        // Step 3: Exchange handshake (with timeout)
174        let local_hs = self.local_handshake();
175        let remote_hs = tokio::time::timeout(HANDSHAKE_TIMEOUT, Self::client_handshake(&mut ws, &local_hs))
176            .await
177            .map_err(|_| TransportError::Timeout("handshake timed out".to_string()))??;
178
179        tracing::info!(
180            remote_peer = %remote_hs.peer_id,
181            remote_version = remote_hs.protocol_version,
182            "ws: connected"
183        );
184
185        // Step 4: Build framed stream with split read/write halves
186        Ok(WsFramedStream::new(
187            ws,
188            remote_hs.peer_id,
189            dial_addr,
190            self.config.ping_interval,
191            self.config.pong_timeout,
192        ))
193    }
194
195    async fn listen(&self) -> Result<StreamListener<Self::Stream>, TransportError> {
196        let port = self.config.port;
197        tracing::debug!(port, "ws: starting listener");
198
199        // Step 1: Listen on TCP via Layer 3
200        let mut tcp_listener = self
201            .network
202            .listen_tcp(port)
203            .await
204            .map_err(|e| TransportError::ListenFailed(format!("listen tcp: {e}")))?;
205
206        // Step 2: Spawn accept loop that upgrades each connection to WS
207        let (tx, rx) = tokio::sync::mpsc::channel::<WsFramedStream>(64);
208        let local_hs = self.local_handshake();
209        let ping_interval = self.config.ping_interval;
210        let pong_timeout = self.config.pong_timeout;
211        let ws_config = self.ws_protocol_config();
212
213        tokio::spawn(async move {
214            loop {
215                match tcp_listener.incoming.recv().await {
216                    Some(incoming) => {
217                        let tx = tx.clone();
218                        let local_hs = local_hs.clone();
219                        let remote_addr = incoming.remote_addr.clone();
220                        let ws_config = ws_config;
221
222                        tokio::spawn(async move {
223                            // WS server upgrade (with max_message_size config)
224                            let mut ws =
225                                match tokio_tungstenite::accept_async_with_config(
226                                    incoming.stream,
227                                    Some(ws_config),
228                                )
229                                .await
230                                {
231                                    Ok(ws) => ws,
232                                    Err(e) => {
233                                        tracing::warn!(
234                                            remote = %remote_addr,
235                                            "ws: upgrade failed: {e}"
236                                        );
237                                        return;
238                                    }
239                                };
240
241                            // Server-side handshake (with timeout)
242                            let remote_hs = match tokio::time::timeout(
243                                HANDSHAKE_TIMEOUT,
244                                server_handshake_standalone(&mut ws, &local_hs),
245                            )
246                            .await
247                            {
248                                Ok(Ok(hs)) => hs,
249                                Ok(Err(e)) => {
250                                    tracing::warn!(
251                                        remote = %remote_addr,
252                                        "ws: handshake failed: {e}"
253                                    );
254                                    return;
255                                }
256                                Err(_) => {
257                                    tracing::warn!(
258                                        remote = %remote_addr,
259                                        "ws: handshake timed out"
260                                    );
261                                    return;
262                                }
263                            };
264
265                            tracing::info!(
266                                remote_peer = %remote_hs.peer_id,
267                                remote_addr = %remote_addr,
268                                "ws: accepted connection"
269                            );
270
271                            let stream = WsFramedStream::new(
272                                ws,
273                                remote_hs.peer_id,
274                                remote_addr,
275                                ping_interval,
276                                pong_timeout,
277                            );
278
279                            if tx.send(stream).await.is_err() {
280                                tracing::debug!("ws: listener channel closed");
281                            }
282                        });
283                    }
284                    None => {
285                        tracing::debug!("ws: tcp listener channel closed");
286                        break;
287                    }
288                }
289            }
290        });
291
292        Ok(StreamListener::new(rx, port))
293    }
294}
295
296/// Standalone server-side handshake (non-generic, used in spawned tasks).
297async fn server_handshake_standalone(
298    ws: &mut WebSocketStream<TcpStream>,
299    local_hs: &Handshake,
300) -> Result<Handshake, TransportError> {
301    // Receive peer's handshake first
302    let remote_hs = receive_handshake(ws).await?;
303
304    // Validate protocol version
305    if remote_hs.protocol_version != PROTOCOL_VERSION {
306        return Err(TransportError::VersionMismatch {
307            local: PROTOCOL_VERSION,
308            remote: remote_hs.protocol_version,
309        });
310    }
311
312    // Send our handshake
313    let hs_json = serde_json::to_string(local_hs)
314        .map_err(|e| TransportError::Serialize(e.to_string()))?;
315    ws.send(Message::Text(hs_json.into()))
316        .await
317        .map_err(|e| TransportError::HandshakeFailed(format!("send handshake: {e}")))?;
318
319    Ok(remote_hs)
320}
321
322// ---------------------------------------------------------------------------
323// WsFramedStream
324// ---------------------------------------------------------------------------
325
326/// A WebSocket-backed [`FramedStream`].
327///
328/// Uses split read/write halves to avoid mutex contention between the
329/// heartbeat task and the main send/recv path:
330///
331/// - **Write half** (`SplitSink`): Shared via `Arc<Mutex<_>>` between
332///   `send()` and the heartbeat task (which sends Ping frames).
333/// - **Read half** (`SplitStream`): Owned exclusively by `recv()` — no
334///   mutex needed.
335/// - **Heartbeat**: Sends Ping on the write half at `ping_interval`. Tracks
336///   last Pong via `Arc<AtomicU64>` (epoch millis). If `last_pong` exceeds
337///   `pong_timeout`, the connection is closed.
338pub struct WsFramedStream {
339    /// Write half of the WebSocket stream, shared with heartbeat task.
340    write: Arc<Mutex<SplitSink<WebSocketStream<TcpStream>, Message>>>,
341    /// Read half of the WebSocket stream, owned exclusively by recv().
342    read: SplitStream<WebSocketStream<TcpStream>>,
343    /// Remote peer ID (from handshake).
344    remote_peer_id: String,
345    /// Remote address string.
346    remote_addr: String,
347    /// Handle to the heartbeat task (aborted on close/drop).
348    heartbeat_handle: Option<tokio::task::JoinHandle<()>>,
349    /// Timestamp (epoch millis) of the last received Pong.
350    last_pong: Arc<AtomicU64>,
351    /// Flag indicating the connection has been closed.
352    closed: Arc<AtomicBool>,
353}
354
355impl std::fmt::Debug for WsFramedStream {
356    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
357        f.debug_struct("WsFramedStream")
358            .field("remote_peer_id", &self.remote_peer_id)
359            .field("remote_addr", &self.remote_addr)
360            .field("closed", &self.closed.load(Ordering::Relaxed))
361            .finish_non_exhaustive()
362    }
363}
364
365// SAFETY: All fields are Send. `SplitStream` is Send because
366// `WebSocketStream<TcpStream>` is Send. The Arc-wrapped fields are Sync.
367// We need the explicit Sync impl because `SplitStream` is not Sync,
368// but `WsFramedStream` is only accessed via `&mut self` (exclusive ref)
369// so Sync is safe.
370unsafe impl Sync for WsFramedStream {}
371
372/// Get the current epoch time in milliseconds.
373fn epoch_millis() -> u64 {
374    std::time::SystemTime::now()
375        .duration_since(std::time::UNIX_EPOCH)
376        .unwrap_or_default()
377        .as_millis() as u64
378}
379
380impl WsFramedStream {
381    /// Create a new framed stream with split read/write halves and heartbeat.
382    fn new(
383        ws: WebSocketStream<TcpStream>,
384        remote_peer_id: String,
385        remote_addr: String,
386        ping_interval: Duration,
387        pong_timeout: Duration,
388    ) -> Self {
389        let (write, read) = ws.split();
390        let write = Arc::new(Mutex::new(write));
391        let last_pong = Arc::new(AtomicU64::new(epoch_millis()));
392        let closed = Arc::new(AtomicBool::new(false));
393
394        // Spawn heartbeat task
395        let hb_write = write.clone();
396        let hb_last_pong = last_pong.clone();
397        let hb_closed = closed.clone();
398        let hb_addr = remote_addr.clone();
399        let heartbeat_handle = tokio::spawn(async move {
400            heartbeat_loop(hb_write, hb_last_pong, hb_closed, ping_interval, pong_timeout, &hb_addr)
401                .await;
402        });
403
404        Self {
405            write,
406            read,
407            remote_peer_id,
408            remote_addr,
409            heartbeat_handle: Some(heartbeat_handle),
410            last_pong,
411            closed,
412        }
413    }
414
415    /// Get the remote peer ID (from the transport handshake).
416    pub fn remote_peer_id(&self) -> &str {
417        &self.remote_peer_id
418    }
419}
420
421/// Background heartbeat loop that sends Ping frames and monitors Pong timestamps.
422///
423/// On each `ping_interval` tick:
424/// 1. Check if `last_pong` is within `pong_timeout` — if not, close.
425/// 2. Send a Ping frame on the write half.
426///
427/// This design avoids the old bug where `select!` between `ping_interval` (10s)
428/// and `pong_timeout` (30s) always picked the shorter timer, making timeout
429/// detection dead code.
430async fn heartbeat_loop(
431    write: Arc<Mutex<SplitSink<WebSocketStream<TcpStream>, Message>>>,
432    last_pong: Arc<AtomicU64>,
433    closed: Arc<AtomicBool>,
434    ping_interval: Duration,
435    pong_timeout: Duration,
436    remote_addr: &str,
437) {
438    let mut interval = tokio::time::interval(ping_interval);
439    // Skip the first immediate tick
440    interval.tick().await;
441
442    loop {
443        interval.tick().await;
444
445        // If the connection was closed externally, stop.
446        if closed.load(Ordering::Acquire) {
447            return;
448        }
449
450        // Check if last pong is within timeout
451        let last = last_pong.load(Ordering::Acquire);
452        let now = epoch_millis();
453        let elapsed = Duration::from_millis(now.saturating_sub(last));
454
455        if elapsed > pong_timeout {
456            tracing::warn!(
457                remote = %remote_addr,
458                elapsed = ?elapsed,
459                "heartbeat: pong timeout after {pong_timeout:?}"
460            );
461            // Close the connection
462            closed.store(true, Ordering::Release);
463            let mut w = write.lock().await;
464            let _ = w.close().await;
465            return;
466        }
467
468        // Send a ping
469        {
470            let mut w = write.lock().await;
471            let ping_data = b"truffle-ping".to_vec();
472            if let Err(e) = w.send(Message::Ping(ping_data.into())).await {
473                tracing::debug!(remote = %remote_addr, "heartbeat: ping send failed: {e}");
474                closed.store(true, Ordering::Release);
475                return;
476            }
477        }
478    }
479}
480
481impl FramedStream for WsFramedStream {
482    async fn send(&mut self, data: &[u8]) -> Result<(), TransportError> {
483        if self.closed.load(Ordering::Acquire) {
484            return Err(TransportError::ConnectionClosed(
485                "connection already closed".to_string(),
486            ));
487        }
488        let mut w = self.write.lock().await;
489        w.send(Message::Binary(data.to_vec().into()))
490            .await
491            .map_err(|e| TransportError::WebSocket(format!("send: {e}")))
492    }
493
494    async fn recv(&mut self) -> Result<Option<Vec<u8>>, TransportError> {
495        if self.closed.load(Ordering::Acquire) {
496            return Ok(None);
497        }
498        loop {
499            match self.read.next().await {
500                Some(Ok(Message::Binary(data))) => return Ok(Some(data.to_vec())),
501                Some(Ok(Message::Text(text))) => {
502                    // Layer 4 treats text frames as binary data
503                    return Ok(Some(text.as_bytes().to_vec()));
504                }
505                Some(Ok(Message::Ping(_))) => {
506                    // We received a Ping — tungstenite auto-sends Pong at the
507                    // protocol level. Just skip and continue reading.
508                    continue;
509                }
510                Some(Ok(Message::Pong(_))) => {
511                    // Update last_pong timestamp for the heartbeat checker
512                    self.last_pong.store(epoch_millis(), Ordering::Release);
513                    continue;
514                }
515                Some(Ok(Message::Close(_))) => {
516                    self.closed.store(true, Ordering::Release);
517                    return Ok(None);
518                }
519                Some(Ok(Message::Frame(_))) => {
520                    // Raw frame — skip
521                    continue;
522                }
523                Some(Err(e)) => {
524                    self.closed.store(true, Ordering::Release);
525                    return Err(TransportError::WebSocket(format!("recv: {e}")));
526                }
527                None => {
528                    self.closed.store(true, Ordering::Release);
529                    return Ok(None);
530                }
531            }
532        }
533    }
534
535    async fn close(&mut self) -> Result<(), TransportError> {
536        // Abort heartbeat task
537        if let Some(handle) = self.heartbeat_handle.take() {
538            handle.abort();
539        }
540
541        self.closed.store(true, Ordering::Release);
542
543        let mut w = self.write.lock().await;
544        w.close()
545            .await
546            .map_err(|e| TransportError::WebSocket(format!("close: {e}")))
547    }
548
549    fn peer_addr(&self) -> String {
550        self.remote_addr.clone()
551    }
552}
553
554impl Drop for WsFramedStream {
555    fn drop(&mut self) {
556        // Ensure heartbeat task is cleaned up
557        if let Some(handle) = self.heartbeat_handle.take() {
558            handle.abort();
559        }
560    }
561}
562
563// ---------------------------------------------------------------------------
564// Unit tests
565// ---------------------------------------------------------------------------
566
567#[cfg(test)]
568mod unit_tests {
569    use super::*;
570
571    #[test]
572    fn resolve_dial_addr_prefers_ip() {
573        let addr = PeerAddr {
574            ip: Some("100.64.0.1".parse().unwrap()),
575            hostname: "peer".to_string(),
576            dns_name: Some("peer.tailnet.ts.net".to_string()),
577        };
578        assert_eq!(resolve_dial_addr(&addr), "100.64.0.1");
579    }
580
581    #[test]
582    fn resolve_dial_addr_falls_back_to_dns() {
583        let addr = PeerAddr {
584            ip: None,
585            hostname: "peer".to_string(),
586            dns_name: Some("peer.tailnet.ts.net".to_string()),
587        };
588        assert_eq!(resolve_dial_addr(&addr), "peer.tailnet.ts.net");
589    }
590
591    #[test]
592    fn resolve_dial_addr_falls_back_to_hostname() {
593        let addr = PeerAddr {
594            ip: None,
595            hostname: "peer".to_string(),
596            dns_name: None,
597        };
598        assert_eq!(resolve_dial_addr(&addr), "peer");
599    }
600}