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}