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