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 ws_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 — WS transport).
78 WsConnected(String),
79 /// A WebSocket connection was lost to a peer (Layer 5 — WS transport).
80 WsDisconnected(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::WsDisconnected(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 `ws_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.ws_connected = existing.ws_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.ws_connected = true;
394 }
395 }
396
397 let _ = event_tx.send(PeerEvent::WsConnected(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 (`ws_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.ws_connected = true;
569 }
570 }
571
572 let _ = self
573 .event_tx
574 .send(PeerEvent::WsConnected(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.ws_connected = false;
628 }
629 }
630
631 let _ = self
632 .event_tx
633 .send(PeerEvent::WsDisconnected(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.ws_connected = false;
742 }
743 }
744 let _ = event_tx.send(PeerEvent::WsDisconnected(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 `ws_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 ws_connected: false,
774 connection_type,
775 os: peer.os.clone(),
776 last_seen: peer.last_seen.clone(),
777 }
778}