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