Skip to main content

truffle_core/session/
mod.rs

1//! Layer 5: Session — Peer identity, connection lifecycle, message routing.
2//!
3//! The [`PeerRegistry`] is the central component. It consumes peer discovery
4//! events from Layer 3 ([`NetworkProvider`]) and manages transport connections
5//! from Layer 4 ([`StreamTransport`], [`RawTransport`]).
6//!
7//! # Layer rules
8//!
9//! - Layer 5 does NOT know what the data means (no namespaces, no envelopes)
10//! - Layer 5 does NOT inspect payloads
11//! - Layer 5 does NOT do peer discovery — it consumes Layer 3 events
12//! - Peers exist because Layer 3 says they exist, NOT because of connections
13//! - Connections are lazy — established on first `send()`
14//! - Layer 5 does NOT implement any transport protocol — it delegates to Layer 4
15
16pub mod reconnect;
17
18#[cfg(test)]
19mod tests;
20
21use std::collections::{HashMap, HashSet};
22use std::net::IpAddr;
23use std::sync::Arc;
24use std::time::{Duration, Instant};
25
26use tokio::sync::{broadcast, mpsc, RwLock};
27
28use self::reconnect::ReconnectBackoff;
29
30use crate::network::{NetworkPeer, NetworkPeerEvent, NetworkProvider, PeerAddr};
31use crate::transport::websocket::{WebSocketTransport, WsFramedStream};
32use crate::transport::{FramedStream, StreamTransport};
33
34// ---------------------------------------------------------------------------
35// Public types
36// ---------------------------------------------------------------------------
37
38/// A peer's state in the session registry.
39///
40/// Combines Layer 3 network information (discovery, addressing) with
41/// Layer 5 session state (connection status). Peers are added to the
42/// registry when Layer 3 reports them, NOT when transport connections
43/// are established.
44#[derive(Debug, Clone)]
45pub struct PeerState {
46    /// Stable node ID from the network provider.
47    pub id: String,
48    /// Human-readable name (hostname from Layer 3).
49    pub name: String,
50    /// Network IP address.
51    pub ip: IpAddr,
52    /// Whether the peer is currently online (from Layer 3).
53    pub online: bool,
54    /// Whether the peer has an active WebSocket connection.
55    pub connected: bool,
56    /// Connection type description (e.g., "direct" or "relay:ord").
57    pub connection_type: String,
58    /// Operating system of the peer, if known.
59    pub os: Option<String>,
60    /// Last time the peer was seen online (RFC 3339 string).
61    pub last_seen: Option<String>,
62}
63
64/// Events emitted by the session layer when peer state changes.
65///
66/// Subscribers receive these via [`PeerRegistry::on_peer_change`].
67/// Events cover both Layer 3 discovery changes and Layer 5 connection
68/// lifecycle changes.
69#[derive(Debug, Clone)]
70pub enum PeerEvent {
71    /// A new peer appeared on the network (from Layer 3).
72    Joined(PeerState),
73    /// A peer left the network (by stable node ID, from Layer 3).
74    Left(String),
75    /// A peer's metadata changed (IP, relay, online status, from Layer 3).
76    Updated(PeerState),
77    /// A WebSocket connection was established to a peer (Layer 5).
78    Connected(String),
79    /// A WebSocket connection was lost to a peer (Layer 5).
80    Disconnected(String),
81    /// Authentication is required — the URL should be shown to the user.
82    AuthRequired { url: String },
83}
84
85/// An incoming message received from a peer via WebSocket.
86///
87/// Layer 5 does not inspect or interpret the data — it simply delivers
88/// raw bytes along with the sender's identity and a timestamp.
89#[derive(Debug, Clone)]
90pub struct IncomingMessage {
91    /// Stable node ID of the sender.
92    pub from: String,
93    /// Raw bytes received (Layer 6 will interpret this).
94    pub data: Vec<u8>,
95    /// When this message was received.
96    pub received_at: Instant,
97}
98
99// ---------------------------------------------------------------------------
100// Errors
101// ---------------------------------------------------------------------------
102
103/// Errors from Layer 5 session operations.
104#[derive(Debug, thiserror::Error)]
105pub enum SessionError {
106    /// The specified peer is not known to the registry.
107    #[error("unknown peer: {0}")]
108    UnknownPeer(String),
109
110    /// The specified peer is offline (Layer 3 reports not online).
111    #[error("peer offline: {0}")]
112    PeerOffline(String),
113
114    /// Failed to establish a transport connection.
115    #[error("connect failed: {0}")]
116    ConnectFailed(String),
117
118    /// Failed to send data on a transport connection.
119    #[error("send failed: {0}")]
120    SendFailed(String),
121
122    /// Reconnect backoff is active — wait before retrying.
123    #[error("reconnect backoff: retry after {retry_after:?}")]
124    ReconnectBackoff {
125        /// How long the caller must wait before retrying.
126        retry_after: Duration,
127    },
128
129    /// A transport layer error.
130    #[error("transport error: {0}")]
131    Transport(#[from] crate::transport::TransportError),
132}
133
134// ---------------------------------------------------------------------------
135// WsConnectionHandle — channel-based connection control
136// ---------------------------------------------------------------------------
137
138/// A handle to an active WebSocket connection.
139///
140/// Instead of sharing a `Mutex<WsFramedStream>` (which would deadlock
141/// because recv holds the lock across awaits), we use a channel pair:
142/// - `send_tx`: Send data to the connection task, which writes to the WS
143/// - `close_tx`: Signal the connection task to close and exit
144///
145/// The connection task exclusively owns the `WsFramedStream` and uses
146/// `tokio::select!` to multiplex between sending, receiving, and close
147/// signals. This avoids lock contention entirely.
148struct WsConnectionHandle {
149    /// Channel to send outgoing data to the connection task.
150    send_tx: mpsc::Sender<Vec<u8>>,
151    /// One-shot close signal. Dropping this also signals close.
152    close_tx: mpsc::Sender<()>,
153    /// Stable node ID of the connected peer.
154    #[allow(dead_code)]
155    peer_id: String,
156    /// When this connection was established.
157    #[allow(dead_code)]
158    connected_at: Instant,
159}
160
161// ---------------------------------------------------------------------------
162// PeerRegistry
163// ---------------------------------------------------------------------------
164
165/// Manages peer state and WebSocket connections.
166///
167/// The `PeerRegistry` is the heart of Layer 5. It:
168///
169/// 1. **Tracks peers** from Layer 3 discovery events — peers exist in the
170///    registry even with zero transport connections.
171/// 2. **Manages lazy connections** — the first [`send()`](Self::send) to a
172///    peer triggers a WebSocket connection via Layer 4. Subsequent sends
173///    reuse the cached connection.
174/// 3. **Routes messages** — incoming messages from any peer are forwarded
175///    to subscribers via a broadcast channel.
176/// 4. **Emits lifecycle events** — [`PeerEvent`]s for peer discovery changes
177///    and connection state changes.
178///
179/// # Example
180///
181/// ```ignore
182/// use std::sync::Arc;
183/// use truffle_core::session::PeerRegistry;
184///
185/// let registry = PeerRegistry::new(network, ws_transport);
186/// registry.start().await;
187///
188/// // Peers appear from Layer 3 discovery
189/// let peers = registry.peers().await;
190///
191/// // First send lazily connects
192/// registry.send("peer-id", b"hello").await?;
193/// ```
194pub struct PeerRegistry<N: NetworkProvider + 'static> {
195    /// Layer 3 network provider (for peer events and addressing).
196    network: Arc<N>,
197    /// Layer 4 WebSocket transport (for framed connections).
198    ws_transport: Arc<WebSocketTransport<N>>,
199
200    /// All known peers from Layer 3. Peers exist here even with zero connections.
201    peers: Arc<RwLock<HashMap<String, PeerState>>>,
202
203    /// Active WebSocket connection handles indexed by peer_id.
204    ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
205
206    /// Reconnect backoff trackers per peer.
207    peer_backoffs: Arc<RwLock<HashMap<String, ReconnectBackoff>>>,
208
209    /// Set of peer IDs currently being connected to (prevents duplicate dials).
210    connecting: Arc<RwLock<HashSet<String>>>,
211
212    /// Event channel for peer changes (discovery + connection lifecycle).
213    event_tx: broadcast::Sender<PeerEvent>,
214
215    /// Channel for incoming messages from any connected peer.
216    incoming_tx: broadcast::Sender<IncomingMessage>,
217}
218
219impl<N: NetworkProvider + 'static> PeerRegistry<N> {
220    /// Create a new peer registry.
221    ///
222    /// - `network`: The Layer 3 network provider for peer discovery.
223    /// - `ws_transport`: The Layer 4 WebSocket transport for connections.
224    ///
225    /// Call [`start()`](Self::start) after creation to begin processing
226    /// peer events and accepting incoming connections.
227    pub fn new(network: Arc<N>, ws_transport: Arc<WebSocketTransport<N>>) -> Self {
228        let (event_tx, _) = broadcast::channel(256);
229        let (incoming_tx, _) = broadcast::channel(1024);
230
231        Self {
232            network,
233            ws_transport,
234            peers: Arc::new(RwLock::new(HashMap::new())),
235            ws_connections: Arc::new(RwLock::new(HashMap::new())),
236            peer_backoffs: Arc::new(RwLock::new(HashMap::new())),
237            connecting: Arc::new(RwLock::new(HashSet::new())),
238            event_tx,
239            incoming_tx,
240        }
241    }
242
243    /// Start the peer registry.
244    ///
245    /// This spawns two background tasks:
246    /// 1. A task that subscribes to Layer 3 peer events and maintains the
247    ///    peer list (Joined/Left/Updated).
248    /// 2. A task that listens for incoming WebSocket connections from peers
249    ///    and spawns connection tasks for each.
250    ///
251    /// Call this once after constructing the registry.
252    pub async fn start(&self) {
253        // Task 1: Subscribe to Layer 3 peer events
254        self.spawn_peer_event_loop();
255
256        // Task 2: Accept incoming WS connections
257        self.spawn_accept_loop().await;
258    }
259
260    /// Spawn a task that subscribes to Layer 3 peer events and updates the
261    /// internal peer list.
262    fn spawn_peer_event_loop(&self) {
263        let mut events = self.network.peer_events();
264        let peers = self.peers.clone();
265        let ws_connections = self.ws_connections.clone();
266        let event_tx = self.event_tx.clone();
267
268        tokio::spawn(async move {
269            loop {
270                match events.recv().await {
271                    Ok(NetworkPeerEvent::Joined(network_peer)) => {
272                        let state = network_peer_to_state(&network_peer);
273                        let peer_event = PeerEvent::Joined(state.clone());
274
275                        {
276                            let mut map = peers.write().await;
277                            map.insert(network_peer.id.clone(), state);
278                        }
279
280                        let _ = event_tx.send(peer_event);
281                        tracing::info!(
282                            peer_id = %network_peer.id,
283                            peer_name = %network_peer.hostname,
284                            "session: peer joined"
285                        );
286                    }
287                    Ok(NetworkPeerEvent::Left(peer_id)) => {
288                        // Close any active WS connection for this peer
289                        let handle = {
290                            let mut conns = ws_connections.write().await;
291                            conns.remove(&peer_id)
292                        };
293                        if let Some(handle) = handle {
294                            let _ = handle.close_tx.send(()).await;
295                            // Emit Disconnected before Left
296                            let _ = event_tx.send(PeerEvent::Disconnected(peer_id.clone()));
297                            tracing::info!(
298                                peer_id = %peer_id,
299                                "session: closed WS connection for departing peer"
300                            );
301                        }
302
303                        {
304                            let mut map = peers.write().await;
305                            map.remove(&peer_id);
306                        }
307
308                        let _ = event_tx.send(PeerEvent::Left(peer_id.clone()));
309                        tracing::info!(peer_id = %peer_id, "session: peer left");
310                    }
311                    Ok(NetworkPeerEvent::Updated(network_peer)) => {
312                        let mut state = network_peer_to_state(&network_peer);
313
314                        // Preserve the `connected` flag from existing state
315                        {
316                            let mut map = peers.write().await;
317                            if let Some(existing) = map.get(&network_peer.id) {
318                                state.connected = existing.connected;
319                            }
320                            map.insert(network_peer.id.clone(), state.clone());
321                        }
322
323                        let _ = event_tx.send(PeerEvent::Updated(state));
324                        tracing::debug!(
325                            peer_id = %network_peer.id,
326                            "session: peer updated"
327                        );
328                    }
329                    Ok(NetworkPeerEvent::AuthRequired { url }) => {
330                        let _ = event_tx.send(PeerEvent::AuthRequired { url });
331                    }
332                    Err(broadcast::error::RecvError::Lagged(n)) => {
333                        tracing::warn!(
334                            missed = n,
335                            "session: peer event receiver lagged, missed {n} events"
336                        );
337                    }
338                    Err(broadcast::error::RecvError::Closed) => {
339                        tracing::debug!("session: peer event channel closed");
340                        break;
341                    }
342                }
343            }
344        });
345    }
346
347    /// Spawn a task that accepts incoming WebSocket connections from peers.
348    async fn spawn_accept_loop(&self) {
349        let ws_transport = self.ws_transport.clone();
350        let ws_connections = self.ws_connections.clone();
351        let peers = self.peers.clone();
352        let event_tx = self.event_tx.clone();
353        let incoming_tx = self.incoming_tx.clone();
354
355        // Try to start the WS listener. If it fails, log and return.
356        let mut listener = match ws_transport.listen().await {
357            Ok(l) => l,
358            Err(e) => {
359                tracing::error!("session: failed to start WS listener: {e}");
360                return;
361            }
362        };
363
364        tokio::spawn(async move {
365            loop {
366                match listener.accept().await {
367                    Some(stream) => {
368                        let peer_id = stream.remote_peer_id().to_string();
369                        tracing::info!(
370                            peer_id = %peer_id,
371                            "session: accepted incoming WS connection"
372                        );
373
374                        // Create connection handle and spawn connection task
375                        let handle = spawn_connection_task(
376                            stream,
377                            peer_id.clone(),
378                            ws_connections.clone(),
379                            peers.clone(),
380                            event_tx.clone(),
381                            incoming_tx.clone(),
382                        );
383
384                        {
385                            let mut conns = ws_connections.write().await;
386                            conns.insert(peer_id.clone(), handle);
387                        }
388
389                        // Mark peer as connected
390                        {
391                            let mut map = peers.write().await;
392                            if let Some(state) = map.get_mut(&peer_id) {
393                                state.connected = true;
394                            }
395                        }
396
397                        let _ = event_tx.send(PeerEvent::Connected(peer_id));
398                    }
399                    None => {
400                        tracing::debug!("session: WS listener closed");
401                        break;
402                    }
403                }
404            }
405        });
406    }
407
408    /// Return all known peers.
409    ///
410    /// This returns peers discovered by Layer 3, including those with
411    /// no active transport connections (`connected: false`).
412    pub async fn peers(&self) -> Vec<PeerState> {
413        let map = self.peers.read().await;
414        map.values().cloned().collect()
415    }
416
417    /// Subscribe to peer change events.
418    ///
419    /// Returns a broadcast receiver that yields [`PeerEvent`]s for peer
420    /// discovery changes (Joined/Left/Updated) and connection lifecycle
421    /// changes (Connected/Disconnected).
422    pub fn on_peer_change(&self) -> broadcast::Receiver<PeerEvent> {
423        self.event_tx.subscribe()
424    }
425
426    /// Send data to a specific peer.
427    ///
428    /// If no WebSocket connection exists to the peer, one is lazily
429    /// established via Layer 4. The connection is cached for subsequent
430    /// sends. If the peer is unknown or offline, an error is returned.
431    ///
432    /// # Errors
433    ///
434    /// - [`SessionError::UnknownPeer`] if the peer is not in the registry
435    /// - [`SessionError::PeerOffline`] if Layer 3 reports the peer as offline
436    /// - [`SessionError::ConnectFailed`] if the WS connection cannot be established
437    /// - [`SessionError::SendFailed`] if the send operation fails
438    pub async fn send(&self, peer_id: &str, data: &[u8]) -> Result<(), SessionError> {
439        // 0. Resolve peer_id: accept either the Tailscale node ID or the
440        //    peer name (hostname).  This mirrors Node::ping() which also
441        //    searches by both id and name.
442        let peer_id = {
443            let map = self.peers.read().await;
444            if map.contains_key(peer_id) {
445                peer_id.to_string()
446            } else {
447                // Fall back to searching by name
448                map.values()
449                    .find(|p| p.name == peer_id)
450                    .map(|p| p.id.clone())
451                    .ok_or_else(|| SessionError::UnknownPeer(peer_id.to_string()))?
452            }
453        };
454        let peer_id = peer_id.as_str();
455
456        // 1. Look up peer in the registry
457        let peer_addr = {
458            let map = self.peers.read().await;
459            let state = map
460                .get(peer_id)
461                .ok_or_else(|| SessionError::UnknownPeer(peer_id.to_string()))?;
462
463            if !state.online {
464                return Err(SessionError::PeerOffline(peer_id.to_string()));
465            }
466
467            PeerAddr {
468                ip: Some(state.ip),
469                hostname: state.name.clone(),
470                dns_name: None,
471            }
472        };
473
474        // 2. Check for existing WS connection
475        {
476            let conns = self.ws_connections.read().await;
477            if let Some(handle) = conns.get(peer_id) {
478                return handle
479                    .send_tx
480                    .send(data.to_vec())
481                    .await
482                    .map_err(|_| SessionError::SendFailed("connection task closed".to_string()));
483            }
484        }
485
486        // 3. Check reconnect backoff before attempting a new connection
487        {
488            let backoffs = self.peer_backoffs.read().await;
489            if let Some(backoff) = backoffs.get(peer_id) {
490                if backoff.should_retry().is_none() {
491                    let retry_after = backoff.retry_after();
492                    return Err(SessionError::ReconnectBackoff { retry_after });
493                }
494            }
495        }
496
497        // 4. Concurrent send protection — check if another task is already connecting
498        {
499            let mut connecting = self.connecting.write().await;
500            if connecting.contains(peer_id) {
501                // Another send() is already dialing this peer. Fail fast rather
502                // than creating a duplicate connection.
503                return Err(SessionError::ConnectFailed(
504                    "connection already in progress".to_string(),
505                ));
506            }
507            connecting.insert(peer_id.to_string());
508        }
509
510        // 5. No existing connection — lazily connect via Layer 4
511        tracing::info!(peer_id = %peer_id, "session: lazy connecting WS");
512
513        let connect_result = self.ws_transport.connect(&peer_addr).await;
514
515        // Remove from connecting set regardless of outcome
516        {
517            let mut connecting = self.connecting.write().await;
518            connecting.remove(peer_id);
519        }
520
521        let ws_stream = match connect_result {
522            Ok(stream) => {
523                // Successful connect — reset backoff
524                let mut backoffs = self.peer_backoffs.write().await;
525                backoffs
526                    .entry(peer_id.to_string())
527                    .or_insert_with(ReconnectBackoff::new)
528                    .success();
529                stream
530            }
531            Err(e) => {
532                // Failed connect — increase backoff
533                let mut backoffs = self.peer_backoffs.write().await;
534                backoffs
535                    .entry(peer_id.to_string())
536                    .or_insert_with(ReconnectBackoff::new)
537                    .failure();
538                return Err(SessionError::ConnectFailed(e.to_string()));
539            }
540        };
541
542        // 6. Create connection handle and spawn connection task
543        let handle = spawn_connection_task(
544            ws_stream,
545            peer_id.to_string(),
546            self.ws_connections.clone(),
547            self.peers.clone(),
548            self.event_tx.clone(),
549            self.incoming_tx.clone(),
550        );
551
552        // Send data before inserting (so we don't lose the race)
553        let send_result = handle
554            .send_tx
555            .send(data.to_vec())
556            .await
557            .map_err(|_| SessionError::SendFailed("connection task closed".to_string()));
558
559        {
560            let mut conns = self.ws_connections.write().await;
561            conns.insert(peer_id.to_string(), handle);
562        }
563
564        // Mark peer as connected
565        {
566            let mut map = self.peers.write().await;
567            if let Some(state) = map.get_mut(peer_id) {
568                state.connected = true;
569            }
570        }
571
572        let _ = self
573            .event_tx
574            .send(PeerEvent::Connected(peer_id.to_string()));
575
576        send_result
577    }
578
579    /// Broadcast data to all peers with active WebSocket connections.
580    ///
581    /// Sends to all currently connected peers. Peers with no active
582    /// connection are skipped (no lazy connect on broadcast).
583    /// Errors from individual sends are logged but do not fail the broadcast.
584    pub async fn broadcast(&self, data: &[u8]) {
585        let conns = self.ws_connections.read().await;
586
587        for (peer_id, handle) in conns.iter() {
588            if let Err(_) = handle.send_tx.send(data.to_vec()).await {
589                tracing::warn!(
590                    peer_id = %peer_id,
591                    "session: broadcast send failed (connection task closed)"
592                );
593            }
594        }
595    }
596
597    /// Subscribe to incoming messages from any connected peer.
598    ///
599    /// Returns a broadcast receiver that yields [`IncomingMessage`]s.
600    /// Messages include the sender's peer ID and raw bytes — Layer 5
601    /// does not interpret the payload.
602    pub fn subscribe(&self) -> broadcast::Receiver<IncomingMessage> {
603        self.incoming_tx.subscribe()
604    }
605
606    /// Disconnect a specific peer's WebSocket connection.
607    ///
608    /// Removes the cached connection and marks the peer as disconnected.
609    /// Does not remove the peer from the registry (that only happens when
610    /// Layer 3 emits a `Left` event).
611    pub async fn disconnect(&self, peer_id: &str) {
612        let handle = {
613            let mut conns = self.ws_connections.write().await;
614            conns.remove(peer_id)
615        };
616
617        if let Some(handle) = handle {
618            // Signal the connection task to close. If the channel is already
619            // closed (task exited), that's fine.
620            let _ = handle.close_tx.send(()).await;
621        }
622
623        // Mark peer as disconnected
624        {
625            let mut map = self.peers.write().await;
626            if let Some(state) = map.get_mut(peer_id) {
627                state.connected = false;
628            }
629        }
630
631        let _ = self
632            .event_tx
633            .send(PeerEvent::Disconnected(peer_id.to_string()));
634    }
635}
636
637// ---------------------------------------------------------------------------
638// Connection task — exclusively owns the WsFramedStream
639// ---------------------------------------------------------------------------
640
641/// Spawn a background task that exclusively owns a `WsFramedStream`.
642///
643/// The task uses `tokio::select!` to multiplex between:
644/// - Receiving outgoing data from the `send_rx` channel and writing to the WS
645/// - Reading incoming data from the WS and forwarding to `incoming_tx`
646/// - Receiving a close signal from `close_rx`
647///
648/// When the task exits (stream closed, error, or close signal), it cleans up
649/// the connection from the registry and emits a `Disconnected` event.
650///
651/// Returns a [`WsConnectionHandle`] for the caller to send data and close.
652fn spawn_connection_task(
653    stream: WsFramedStream,
654    peer_id: String,
655    ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
656    peers: Arc<RwLock<HashMap<String, PeerState>>>,
657    event_tx: broadcast::Sender<PeerEvent>,
658    incoming_tx: broadcast::Sender<IncomingMessage>,
659) -> WsConnectionHandle {
660    let (send_tx, mut send_rx) = mpsc::channel::<Vec<u8>>(256);
661    let (close_tx, mut close_rx) = mpsc::channel::<()>(1);
662
663    let handle = WsConnectionHandle {
664        send_tx: send_tx.clone(),
665        close_tx: close_tx.clone(),
666        peer_id: peer_id.clone(),
667        connected_at: Instant::now(),
668    };
669
670    tokio::spawn(async move {
671        let mut stream = stream;
672        let mut closed = false;
673
674        loop {
675            tokio::select! {
676                // Outgoing: data from send channel → write to WS
677                Some(data) = send_rx.recv() => {
678                    if let Err(e) = stream.send(&data).await {
679                        tracing::warn!(
680                            peer_id = %peer_id,
681                            error = %e,
682                            "session: WS send error"
683                        );
684                        break;
685                    }
686                }
687
688                // Incoming: data from WS → forward to incoming channel
689                result = stream.recv() => {
690                    match result {
691                        Ok(Some(data)) => {
692                            let msg = IncomingMessage {
693                                from: peer_id.clone(),
694                                data,
695                                received_at: Instant::now(),
696                            };
697                            let _ = incoming_tx.send(msg);
698                        }
699                        Ok(None) => {
700                            tracing::info!(
701                                peer_id = %peer_id,
702                                "session: WS stream closed"
703                            );
704                            break;
705                        }
706                        Err(e) => {
707                            tracing::warn!(
708                                peer_id = %peer_id,
709                                error = %e,
710                                "session: WS recv error"
711                            );
712                            break;
713                        }
714                    }
715                }
716
717                // Close signal
718                _ = close_rx.recv() => {
719                    tracing::info!(
720                        peer_id = %peer_id,
721                        "session: connection close requested"
722                    );
723                    closed = true;
724                    let _ = stream.close().await;
725                    break;
726                }
727            }
728        }
729
730        // Clean up: remove connection from registry, mark peer as disconnected
731        // Only clean up if we weren't explicitly closed (disconnect() handles
732        // its own cleanup to avoid racing).
733        if !closed {
734            {
735                let mut conns = ws_connections.write().await;
736                conns.remove(&peer_id);
737            }
738            {
739                let mut map = peers.write().await;
740                if let Some(state) = map.get_mut(&peer_id) {
741                    state.connected = false;
742                }
743            }
744            let _ = event_tx.send(PeerEvent::Disconnected(peer_id));
745        }
746    });
747
748    handle
749}
750
751// ---------------------------------------------------------------------------
752// Helper: convert NetworkPeer to PeerState
753// ---------------------------------------------------------------------------
754
755/// Convert a Layer 3 `NetworkPeer` to a Layer 5 `PeerState`.
756///
757/// Sets `connected: false` by default — connections are managed by Layer 5,
758/// not by Layer 3 discovery.
759fn network_peer_to_state(peer: &NetworkPeer) -> PeerState {
760    let connection_type = if let Some(ref relay) = peer.relay {
761        format!("relay:{relay}")
762    } else if peer.cur_addr.is_some() {
763        "direct".to_string()
764    } else {
765        "unknown".to_string()
766    };
767
768    PeerState {
769        id: peer.id.clone(),
770        name: peer.hostname.clone(),
771        ip: peer.ip,
772        online: peer.online,
773        connected: false,
774        connection_type,
775        os: peer.os.clone(),
776        last_seen: peer.last_seen.clone(),
777    }
778}