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`](crate::transport::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, Mutex as AsyncMutex, RwLock, Semaphore};
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///
49/// RFC 022: `id` is always the Tailscale stable node id (routing key).
50/// Application-facing durable identity lives in [`Self::identity`] and is
51/// projected as an honest `Option` (never filled with the Tailscale id).
52#[derive(Debug, Clone)]
53pub struct PeerState {
54    /// Tailscale stable node ID from the network provider. Used as the
55    /// primary key for routing inside the session layer.
56    pub id: String,
57    /// Generation counter for this `id` within this process (RFC 022 §7.7).
58    /// Bumped each time the same Tailscale node re-joins after `Left`.
59    /// Combined with `id` to form [`Self::peer_ref`].
60    pub generation: u64,
61    /// Tailscale hostname (as seen by Layer 3). This is the slugged form,
62    /// NOT the user-facing `device_name`.
63    pub name: String,
64    /// Network IP address.
65    pub ip: IpAddr,
66    /// Whether the peer is currently online (from Layer 3).
67    pub online: bool,
68    /// Whether the peer has an active WebSocket connection.
69    pub ws_connected: bool,
70    /// Connection type description (e.g., "direct" or "relay:ord").
71    pub connection_type: String,
72    /// Operating system of the peer, if known (from Layer 3).
73    pub os: Option<String>,
74    /// Last time the peer was seen online (RFC 3339 string).
75    pub last_seen: Option<String>,
76    /// Peer identity advertised in the remote's hello envelope (RFC 017 §8).
77    ///
78    /// `None` until identity is learned (hello / future hostinfo). This is
79    /// the source of truth for the durable ULID — never filled with the
80    /// Tailscale id as a fallback (RFC 022).
81    pub identity: Option<PeerIdentity>,
82    /// When true, `identity` is stored but **not published** as `device_id`
83    /// because another live peer already owns that ULID in `by_device`
84    /// (first-wins, RFC 022 §7.7).
85    pub identity_suppressed: bool,
86}
87
88impl PeerState {
89    /// Process-local peer ref: `{tailscale_id}:{generation}` (RFC 022).
90    pub fn peer_ref(&self) -> String {
91        format_peer_ref(&self.id, self.generation)
92    }
93
94    /// Published durable device id, if any (respects first-wins suppression).
95    pub fn published_device_id(&self) -> Option<&str> {
96        if self.identity_suppressed {
97            return None;
98        }
99        self.identity.as_ref().map(|i| i.device_id.as_str())
100    }
101}
102
103/// Format a [`PeerState::peer_ref`] / user-facing `Peer.peer_ref`.
104pub fn format_peer_ref(tailscale_id: &str, generation: u64) -> String {
105    format!("{tailscale_id}:{generation}")
106}
107
108/// Parse a `{tailscale_id}:{generation}` peer ref (RFC 022).
109///
110/// Returns `None` for anything else: exactly one `:`, non-empty id, all-digit
111/// generation. IPv6 literals (multiple colons) and colon-free identifiers
112/// never qualify, so query strings fall through to normal resolution.
113pub(crate) fn parse_peer_ref(s: &str) -> Option<(&str, u64)> {
114    let (ts, generation) = s.rsplit_once(':')?;
115    if ts.is_empty() || ts.contains(':') || generation.is_empty() {
116        return None;
117    }
118    if !generation.bytes().all(|b| b.is_ascii_digit()) {
119        return None;
120    }
121    Some((ts, generation.parse().ok()?))
122}
123
124/// Events emitted by the session layer when peer state changes.
125///
126/// Subscribers receive these via [`PeerRegistry::on_peer_change`].
127/// Events cover both Layer 3 discovery changes and Layer 5 connection
128/// lifecycle changes.
129#[derive(Debug, Clone)]
130pub enum PeerEvent {
131    /// A new peer appeared on the network (from Layer 3).
132    Joined(PeerState),
133    /// A peer left the network (from Layer 3). Carries the entry's **final
134    /// state** (marked offline, WS down) — the registry entry is already
135    /// gone when this fires, so consumers get a usable last view for
136    /// cleanup (RFC 022 §7.4 / §16.4) instead of a bare id.
137    Left(PeerState),
138    /// A peer's metadata changed (IP, relay, online status, from Layer 3).
139    Updated(PeerState),
140    /// Durable identity was first set or rotated on a peer (RFC 022).
141    /// Carries the full peer snapshot after the change.
142    Identity(PeerState),
143    /// A WebSocket connection was established to a peer (Layer 5 — WS transport).
144    /// Payload is the Tailscale stable id.
145    WsConnected(String),
146    /// A WebSocket connection was lost to a peer (Layer 5 — WS transport).
147    /// Payload is the Tailscale stable id. Does **not** clear learned identity.
148    WsDisconnected(String),
149    /// Authentication is required — the URL should be shown to the user.
150    AuthRequired { url: String },
151}
152
153/// Outcome of a broadcast.
154///
155/// "Queued" means the bytes were handed to a peer's connection task —
156/// delivery is not confirmed at this layer. Broadcasts reach only peers
157/// with an active WebSocket connection; no lazy connections are made.
158#[derive(Debug, Clone, Default)]
159pub struct BroadcastReport {
160    /// Peers with an active WS connection at broadcast time.
161    pub attempted: usize,
162    /// Messages successfully queued to a connection task.
163    pub queued: usize,
164    /// Tailscale ids of peers whose connection task was already closed.
165    pub failed: Vec<String>,
166}
167
168/// An incoming message received from a peer via WebSocket.
169///
170/// Layer 5 does not inspect or interpret the data — it simply delivers
171/// raw bytes along with the sender's identity and a timestamp.
172#[derive(Debug, Clone)]
173pub struct IncomingMessage {
174    /// Sender's **WhoIs-verified Tailscale stable id** (the connection's
175    /// routing key). RFC 022 §7.5: attribution never uses the self-declared
176    /// ULID from the hello envelope.
177    pub from: String,
178    /// Raw bytes received (Layer 6 will interpret this).
179    pub data: Vec<u8>,
180    /// When this message was received.
181    pub received_at: Instant,
182}
183
184// ---------------------------------------------------------------------------
185// Errors
186// ---------------------------------------------------------------------------
187
188/// Errors from Layer 5 session operations.
189#[derive(Debug, thiserror::Error)]
190pub enum SessionError {
191    /// The specified peer is not known to the registry.
192    #[error("unknown peer: {0}")]
193    UnknownPeer(String),
194
195    /// A peer-ref selector referenced a departed or superseded registry
196    /// entry generation (RFC 022 I5) — the handle it came from is stale.
197    #[error("peer gone: {0}")]
198    PeerGone(String),
199
200    /// The specified peer is offline (Layer 3 reports not online).
201    #[error("peer offline: {0}")]
202    PeerOffline(String),
203
204    /// Failed to establish a transport connection.
205    #[error("connect failed: {0}")]
206    ConnectFailed(String),
207
208    /// Failed to send data on a transport connection.
209    #[error("send failed: {0}")]
210    SendFailed(String),
211
212    /// Reconnect backoff is active — wait before retrying.
213    #[error("reconnect backoff: retry after {retry_after:?}")]
214    ReconnectBackoff {
215        /// How long the caller must wait before retrying.
216        retry_after: Duration,
217    },
218
219    /// A transport layer error.
220    #[error("transport error: {0}")]
221    Transport(#[from] crate::transport::TransportError),
222}
223
224// ---------------------------------------------------------------------------
225// WsConnectionHandle — channel-based connection control
226// ---------------------------------------------------------------------------
227
228/// A handle to an active WebSocket connection.
229///
230/// Instead of sharing a `Mutex<WsFramedStream>` (which would deadlock
231/// because recv holds the lock across awaits), we use a channel pair:
232/// - `send_tx`: Send data to the connection task, which writes to the WS
233/// - `close_tx`: Signal the connection task to close and exit
234///
235/// The connection task exclusively owns the `WsFramedStream` and uses
236/// `tokio::select!` to multiplex between sending, receiving, and close
237/// signals. This avoids lock contention entirely.
238struct WsConnectionHandle {
239    /// Channel to send outgoing data to the connection task.
240    send_tx: mpsc::Sender<Vec<u8>>,
241    /// One-shot close signal. Dropping this also signals close.
242    close_tx: mpsc::Sender<()>,
243    /// Stable node ID of the connected peer.
244    #[allow(dead_code)]
245    peer_id: String,
246    /// When this connection was established.
247    #[allow(dead_code)]
248    connected_at: Instant,
249    /// Whether this node dialed or accepted the connection.
250    direction: ConnectionDirection,
251}
252
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254enum ConnectionDirection {
255    Inbound,
256    Outbound,
257}
258
259// ---------------------------------------------------------------------------
260// PeerRegistry
261// ---------------------------------------------------------------------------
262
263/// Manages peer state and WebSocket connections.
264///
265/// The `PeerRegistry` is the heart of Layer 5. It:
266///
267/// 1. **Tracks peers** from Layer 3 discovery events — peers exist in the
268///    registry even with zero transport connections.
269/// 2. **Manages lazy connections** — the first [`send()`](Self::send) to a
270///    peer triggers a WebSocket connection via Layer 4. Subsequent sends
271///    reuse the cached connection.
272/// 3. **Routes messages** — incoming messages from any peer are forwarded
273///    to subscribers via a broadcast channel.
274/// 4. **Emits lifecycle events** — [`PeerEvent`]s for peer discovery changes
275///    and connection state changes.
276///
277/// # Example
278///
279/// ```ignore
280/// use std::sync::Arc;
281/// use truffle_core::session::PeerRegistry;
282///
283/// let registry = PeerRegistry::new(network, ws_transport);
284/// registry.start().await;
285///
286/// // Peers appear from Layer 3 discovery
287/// let peers = registry.peers().await;
288///
289/// // First send lazily connects
290/// registry.send("peer-id", b"hello").await?;
291/// ```
292pub struct PeerRegistry<N: NetworkProvider + 'static> {
293    /// Layer 3 network provider (for peer events and addressing).
294    network: Arc<N>,
295    /// Layer 4 WebSocket transport (for framed connections).
296    ws_transport: Arc<WebSocketTransport<N>>,
297
298    /// All known peers from Layer 3, keyed by Tailscale stable id.
299    /// Peers exist here even with zero connections.
300    peers: Arc<RwLock<HashMap<String, PeerState>>>,
301
302    /// Durable ULID → Tailscale id for at most one **published** live entry
303    /// (RFC 022 §7.7). Used for queries only — never for message attribution.
304    by_device: Arc<RwLock<HashMap<String, String>>>,
305
306    /// Next generation number per Tailscale id (incremented on each join).
307    next_generation: Arc<RwLock<HashMap<String, u64>>>,
308
309    /// Active WebSocket connection handles indexed by peer_id (Tailscale id).
310    ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
311
312    /// Hello identities accepted before Layer 3 has announced the peer.
313    /// Reconciled into `peers` on the subsequent Joined/Updated event.
314    pending_identities: Arc<RwLock<HashMap<String, PeerIdentity>>>,
315
316    /// Reconnect backoff trackers per peer.
317    peer_backoffs: Arc<RwLock<HashMap<String, ReconnectBackoff>>>,
318
319    /// Set of peer IDs currently being connected to (prevents duplicate dials).
320    connecting: Arc<RwLock<HashSet<String>>>,
321
322    /// Event channel for peer changes (discovery + connection lifecycle).
323    event_tx: broadcast::Sender<PeerEvent>,
324
325    /// Channel for incoming messages from any connected peer.
326    incoming_tx: broadcast::Sender<IncomingMessage>,
327
328    /// Handles to the registry's background loops (peer-event + WS accept),
329    /// retained so [`Self::shutdown`] can abort them instead of leaving
330    /// them running until every Arc clone drops.
331    background_tasks: Arc<std::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>>,
332
333    /// RFC 022 Phase C: proactively exchange hello with online peers.
334    eager_identity: bool,
335
336    /// Cap concurrent eager-identity dials (default 4).
337    eager_identity_sem: Arc<Semaphore>,
338
339    /// Per-peer jitter window (ms) applied before an eager dial (RFC 022 §8.1).
340    eager_identity_jitter_ms: u64,
341
342    /// Peer ids with an in-flight ensure_identity task (dedupe).
343    identity_inflight: Arc<AsyncMutex<HashSet<String>>>,
344}
345
346/// Options for [`PeerRegistry::with_options`].
347#[derive(Debug, Clone)]
348pub struct PeerRegistryOptions {
349    /// When true (default), dial the envelope WS once per online peer to
350    /// learn durable identity without waiting for app `send` (RFC 022 §8).
351    pub eager_identity: bool,
352    /// Max concurrent eager-identity dials. Default: 4.
353    pub eager_identity_concurrency: usize,
354    /// Max per-peer delay (ms) applied *before* each EAGER identity dial to
355    /// stagger the first burst of hellos when a node joins a large mesh
356    /// (RFC 022 §8.1). The delay is a deterministic hash of the peer id in
357    /// `0..eager_identity_jitter_ms` (truffle-core has no `rand` dependency).
358    /// `0` disables jitter — tests set it for deterministic timing. Only the
359    /// eager path is delayed; app `send` / `ensure_ws_connected` never wait.
360    /// Default: 250.
361    pub eager_identity_jitter_ms: u64,
362}
363
364impl Default for PeerRegistryOptions {
365    fn default() -> Self {
366        Self {
367            eager_identity: true,
368            eager_identity_concurrency: 4,
369            eager_identity_jitter_ms: 250,
370        }
371    }
372}
373
374impl<N: NetworkProvider + 'static> PeerRegistry<N> {
375    /// Create a new peer registry with default options (eager identity on).
376    ///
377    /// - `network`: The Layer 3 network provider for peer discovery.
378    /// - `ws_transport`: The Layer 4 WebSocket transport for connections.
379    ///
380    /// Call [`start()`](Self::start) after creation to begin processing
381    /// peer events and accepting incoming connections.
382    pub fn new(network: Arc<N>, ws_transport: Arc<WebSocketTransport<N>>) -> Self {
383        Self::with_options(network, ws_transport, PeerRegistryOptions::default())
384    }
385
386    /// Create a peer registry with explicit options (RFC 022 Phase C).
387    pub fn with_options(
388        network: Arc<N>,
389        ws_transport: Arc<WebSocketTransport<N>>,
390        options: PeerRegistryOptions,
391    ) -> Self {
392        let (event_tx, _) = broadcast::channel(256);
393        let (incoming_tx, _) = broadcast::channel(1024);
394        let concurrency = options.eager_identity_concurrency.max(1);
395
396        Self {
397            network,
398            ws_transport,
399            peers: Arc::new(RwLock::new(HashMap::new())),
400            by_device: Arc::new(RwLock::new(HashMap::new())),
401            next_generation: Arc::new(RwLock::new(HashMap::new())),
402            ws_connections: Arc::new(RwLock::new(HashMap::new())),
403            pending_identities: Arc::new(RwLock::new(HashMap::new())),
404            peer_backoffs: Arc::new(RwLock::new(HashMap::new())),
405            connecting: Arc::new(RwLock::new(HashSet::new())),
406            event_tx,
407            incoming_tx,
408            background_tasks: Arc::new(std::sync::Mutex::new(Vec::new())),
409            eager_identity: options.eager_identity,
410            eager_identity_sem: Arc::new(Semaphore::new(concurrency)),
411            eager_identity_jitter_ms: options.eager_identity_jitter_ms,
412            identity_inflight: Arc::new(AsyncMutex::new(HashSet::new())),
413        }
414    }
415
416    /// Start the peer registry.
417    ///
418    /// This spawns two background tasks:
419    /// 1. A task that subscribes to Layer 3 peer events and maintains the
420    ///    peer list (Joined/Left/Updated).
421    /// 2. A task that listens for incoming WebSocket connections from peers
422    ///    and spawns connection tasks for each.
423    ///
424    /// Call this once after constructing the registry.
425    pub async fn start(&self) {
426        // Task 1: Subscribe to Layer 3 peer events
427        self.spawn_peer_event_loop();
428
429        // Task 2: Accept incoming WS connections
430        self.spawn_accept_loop().await;
431    }
432
433    /// Spawn a task that subscribes to Layer 3 peer events and updates the
434    /// internal peer list.
435    fn spawn_peer_event_loop(&self) {
436        let mut events = self.network.peer_events();
437        let peers = self.peers.clone();
438        let by_device = self.by_device.clone();
439        let next_generation = self.next_generation.clone();
440        let ws_connections = self.ws_connections.clone();
441        let pending_identities = self.pending_identities.clone();
442        let event_tx = self.event_tx.clone();
443        // Clones for scheduling eager identity from the event loop.
444        let schedule_ctx = EagerScheduleCtx {
445            eager_identity: self.eager_identity,
446            peers: self.peers.clone(),
447            by_device: self.by_device.clone(),
448            ws_connections: self.ws_connections.clone(),
449            peer_backoffs: self.peer_backoffs.clone(),
450            connecting: self.connecting.clone(),
451            event_tx: self.event_tx.clone(),
452            incoming_tx: self.incoming_tx.clone(),
453            ws_transport: self.ws_transport.clone(),
454            network: self.network.clone(),
455            eager_identity_sem: self.eager_identity_sem.clone(),
456            eager_identity_jitter_ms: self.eager_identity_jitter_ms,
457            identity_inflight: self.identity_inflight.clone(),
458            background_tasks: self.background_tasks.clone(),
459        };
460
461        let handle = tokio::spawn(async move {
462            loop {
463                match events.recv().await {
464                    Ok(NetworkPeerEvent::Joined(network_peer)) => {
465                        let generation = {
466                            let mut gens = next_generation.write().await;
467                            let e = gens.entry(network_peer.id.clone()).or_insert(0);
468                            *e += 1;
469                            *e
470                        };
471                        let mut state = network_peer_to_state(&network_peer, generation);
472                        state.ws_connected =
473                            ws_connections.read().await.contains_key(&network_peer.id);
474                        let peer_id = network_peer.id.clone();
475                        let peer_event = PeerEvent::Joined(state.clone());
476                        let pending_identity = {
477                            let mut pending = pending_identities.write().await;
478                            pending.remove(&peer_id)
479                        };
480
481                        let identity_outcomes = {
482                            let mut map = peers.write().await;
483                            map.insert(network_peer.id.clone(), state);
484                            if let Some(identity) = pending_identity {
485                                let mut by_dev = by_device.write().await;
486                                apply_identity(&mut map, &mut by_dev, &peer_id, identity)
487                            } else {
488                                Vec::new()
489                            }
490                        };
491
492                        let _ = event_tx.send(peer_event);
493                        emit_identity_outcomes(&event_tx, identity_outcomes);
494                        tracing::info!(
495                            peer_id = %network_peer.id,
496                            generation,
497                            peer_name = %network_peer.hostname,
498                            "session: peer joined"
499                        );
500
501                        let needs_identity = {
502                            let map = peers.read().await;
503                            map.get(&peer_id)
504                                .map(|state| state.online && state.published_device_id().is_none())
505                                .unwrap_or(false)
506                        };
507                        if needs_identity {
508                            schedule_ctx.schedule(peer_id);
509                        }
510                    }
511                    Ok(NetworkPeerEvent::Left(peer_id)) => {
512                        pending_identities.write().await.remove(&peer_id);
513                        // Close any active WS connection for this peer
514                        let handle = {
515                            let mut conns = ws_connections.write().await;
516                            conns.remove(&peer_id)
517                        };
518                        if let Some(handle) = handle {
519                            let _ = handle.close_tx.send(()).await;
520                            // Emit Disconnected before Left
521                            let _ = event_tx.send(PeerEvent::WsDisconnected(peer_id.clone()));
522                            tracing::info!(
523                                peer_id = %peer_id,
524                                "session: closed WS connection for departing peer"
525                            );
526                        }
527
528                        let removed = {
529                            let mut map = peers.write().await;
530                            map.remove(&peer_id)
531                        };
532
533                        // Drop by_device mapping if it pointed at this peer; promote
534                        // a suppressed claimant if one exists (RFC 022 §7.7).
535                        if let Some(mut removed) = removed {
536                            let promote = {
537                                let mut by_dev = by_device.write().await;
538                                if let Some(uid) = removed.published_device_id() {
539                                    if by_dev.get(uid).map(|s| s.as_str()) == Some(peer_id.as_str())
540                                    {
541                                        by_dev.remove(uid);
542                                        Some(uid.to_string())
543                                    } else {
544                                        None
545                                    }
546                                } else if let Some(ref ident) = removed.identity {
547                                    // Suppressed holder leaving — nothing published
548                                    let _ = ident;
549                                    None
550                                } else {
551                                    None
552                                }
553                            };
554
555                            if let Some(uid) = promote {
556                                let mut map = peers.write().await;
557                                let mut by_dev = by_device.write().await;
558                                if let Some(promoted) = map.values_mut().find(|p| {
559                                    p.id != peer_id
560                                        && p.online
561                                        && p.identity
562                                            .as_ref()
563                                            .map(|i| i.device_id == uid)
564                                            .unwrap_or(false)
565                                        && p.identity_suppressed
566                                }) {
567                                    promoted.identity_suppressed = false;
568                                    by_dev.insert(uid.clone(), promoted.id.clone());
569                                    let snap = promoted.clone();
570                                    drop(map);
571                                    drop(by_dev);
572                                    let _ = event_tx.send(PeerEvent::Identity(snap));
573                                    tracing::info!(
574                                        device_id = %uid,
575                                        "session: promoted suppressed ULID claimant after holder left"
576                                    );
577                                }
578                            }
579
580                            // Emit `Left` with the entry's final view: the map
581                            // entry is already gone, and consumers need a
582                            // usable last state for cleanup (RFC 022 §16.4).
583                            removed.online = false;
584                            removed.ws_connected = false;
585                            let _ = event_tx.send(PeerEvent::Left(removed));
586                            tracing::info!(peer_id = %peer_id, "session: peer left");
587                        } else {
588                            // Never announced via `joined` — nothing to retire,
589                            // so no `left` is emitted either.
590                            tracing::debug!(
591                                peer_id = %peer_id,
592                                "session: left for unknown peer; no event"
593                            );
594                        }
595                    }
596                    Ok(NetworkPeerEvent::Updated(network_peer)) => {
597                        let mut state = network_peer_to_state(&network_peer, 0);
598                        // Both branches below assign this before it is read;
599                        // no initializer keeps the unused-assignment lint quiet.
600                        let mut became_online_without_identity;
601
602                        // Preserve Layer 5 state (ws_connected, identity,
603                        // generation, suppression) from the existing entry —
604                        // Layer 3 Updated events only carry discovery metadata.
605                        {
606                            let pending_identity = {
607                                let mut pending = pending_identities.write().await;
608                                pending.remove(&network_peer.id)
609                            };
610                            let mut map = peers.write().await;
611                            if let Some(existing) = map.get(&network_peer.id) {
612                                state.generation = existing.generation;
613                                state.ws_connected = existing.ws_connected;
614                                state.identity = existing.identity.clone();
615                                state.identity_suppressed = existing.identity_suppressed;
616                                became_online_without_identity = !existing.online
617                                    && state.online
618                                    && existing.published_device_id().is_none();
619                            } else {
620                                // Unknown peer Updated without Joined — treat as gen 1.
621                                let mut gens = next_generation.write().await;
622                                let e = gens.entry(network_peer.id.clone()).or_insert(0);
623                                *e += 1;
624                                state.generation = *e;
625                                became_online_without_identity =
626                                    state.online && state.published_device_id().is_none();
627                            }
628                            state.ws_connected =
629                                ws_connections.read().await.contains_key(&network_peer.id);
630                            map.insert(network_peer.id.clone(), state.clone());
631                            if let Some(identity) = pending_identity {
632                                let mut by_dev = by_device.write().await;
633                                let outcomes = apply_identity(
634                                    &mut map,
635                                    &mut by_dev,
636                                    &network_peer.id,
637                                    identity,
638                                );
639                                emit_identity_outcomes(&event_tx, outcomes);
640                                became_online_without_identity = map
641                                    .get(&network_peer.id)
642                                    .map(|state| {
643                                        state.online && state.published_device_id().is_none()
644                                    })
645                                    .unwrap_or(false);
646                                state = map
647                                    .get(&network_peer.id)
648                                    .cloned()
649                                    .expect("peer inserted immediately above");
650                            }
651                        }
652
653                        let peer_id = network_peer.id.clone();
654                        let _ = event_tx.send(PeerEvent::Updated(state));
655                        tracing::debug!(
656                            peer_id = %network_peer.id,
657                            "session: peer updated"
658                        );
659
660                        if became_online_without_identity {
661                            schedule_ctx.schedule(peer_id);
662                        }
663                    }
664                    Ok(NetworkPeerEvent::AuthRequired { url }) => {
665                        let _ = event_tx.send(PeerEvent::AuthRequired { url });
666                    }
667                    Err(broadcast::error::RecvError::Lagged(n)) => {
668                        tracing::warn!(
669                            missed = n,
670                            "session: peer event receiver lagged, missed {n} events"
671                        );
672                    }
673                    Err(broadcast::error::RecvError::Closed) => {
674                        tracing::debug!("session: peer event channel closed");
675                        break;
676                    }
677                }
678            }
679        });
680        self.background_tasks.lock().unwrap().push(handle);
681    }
682
683    /// Spawn a task that accepts incoming WebSocket connections from peers.
684    async fn spawn_accept_loop(&self) {
685        let ws_transport = self.ws_transport.clone();
686        let ws_connections = self.ws_connections.clone();
687        let peers = self.peers.clone();
688        let by_device = self.by_device.clone();
689        let pending_identities = self.pending_identities.clone();
690        let event_tx = self.event_tx.clone();
691        let incoming_tx = self.incoming_tx.clone();
692        let local_tailscale_id = self.network.local_identity().tailscale_id;
693
694        // Try to start the WS listener. If it fails, log and return.
695        let mut listener = match ws_transport.listen().await {
696            Ok(l) => l,
697            Err(e) => {
698                tracing::error!("session: failed to start WS listener: {e}");
699                return;
700            }
701        };
702
703        let handle = tokio::spawn(async move {
704            loop {
705                match listener.accept().await {
706                    Some(stream) => {
707                        let peer_id = stream.remote_peer_id().to_string();
708                        let remote_identity = stream.remote_identity().cloned();
709                        tracing::info!(
710                            peer_id = %peer_id,
711                            device_id = remote_identity.as_ref().map(|i| i.device_id.as_str()),
712                            "session: accepted incoming WS connection"
713                        );
714
715                        // Create connection handle and spawn connection task.
716                        // Attribution uses WhoIs-verified Tailscale id (peer_id).
717                        let handle = spawn_connection_task(
718                            stream,
719                            peer_id.clone(),
720                            ConnectionDirection::Inbound,
721                            ws_connections.clone(),
722                            peers.clone(),
723                            event_tx.clone(),
724                            incoming_tx.clone(),
725                        );
726
727                        if !install_connection(
728                            &ws_connections,
729                            &local_tailscale_id,
730                            &peer_id,
731                            handle,
732                        )
733                        .await
734                        {
735                            tracing::debug!(
736                                peer_id = %peer_id,
737                                "session: rejected duplicate incoming WS connection"
738                            );
739                            continue;
740                        }
741
742                        // Mark peer as connected and apply identity from hello.
743                        // An inbound hello can win the race with Layer 3
744                        // discovery; retain it until Joined/Updated creates the
745                        // peer entry instead of silently discarding identity.
746                        {
747                            let mut pending = pending_identities.write().await;
748                            let mut map = peers.write().await;
749                            let mut by_dev = by_device.write().await;
750                            if let Some(state) = map.get_mut(&peer_id) {
751                                state.ws_connected = true;
752                                if let Some(identity) = remote_identity {
753                                    let outcomes =
754                                        apply_identity(&mut map, &mut by_dev, &peer_id, identity);
755                                    emit_identity_outcomes(&event_tx, outcomes);
756                                }
757                            } else if let Some(identity) = remote_identity {
758                                pending.insert(peer_id.clone(), identity);
759                            }
760                        }
761
762                        let _ = event_tx.send(PeerEvent::WsConnected(peer_id));
763                    }
764                    None => {
765                        tracing::debug!("session: WS listener closed");
766                        break;
767                    }
768                }
769            }
770        });
771        self.background_tasks.lock().unwrap().push(handle);
772    }
773
774    /// Return all known peers.
775    ///
776    /// This returns peers discovered by Layer 3, including those with
777    /// no active transport connections (`ws_connected: false`).
778    pub async fn peers(&self) -> Vec<PeerState> {
779        let map = self.peers.read().await;
780        map.values().cloned().collect()
781    }
782
783    /// Subscribe to peer change events.
784    ///
785    /// Returns a broadcast receiver that yields [`PeerEvent`]s for peer
786    /// discovery changes (Joined/Left/Updated) and connection lifecycle
787    /// changes (Connected/Disconnected).
788    pub fn on_peer_change(&self) -> broadcast::Receiver<PeerEvent> {
789        self.event_tx.subscribe()
790    }
791
792    /// Send data to a specific peer.
793    ///
794    /// If no WebSocket connection exists to the peer, one is lazily
795    /// established via Layer 4. The connection is cached for subsequent
796    /// sends. If the peer is unknown or offline, an error is returned.
797    ///
798    /// # Errors
799    ///
800    /// - [`SessionError::UnknownPeer`] if the peer is not in the registry
801    /// - [`SessionError::PeerOffline`] if Layer 3 reports the peer as offline
802    /// - [`SessionError::ConnectFailed`] if the WS connection cannot be established
803    /// - [`SessionError::SendFailed`] if the send operation fails
804    pub async fn send(&self, peer_id: &str, data: &[u8]) -> Result<(), SessionError> {
805        let peer_id = self.resolve_routing_key(peer_id).await?;
806        self.ensure_ws_connected(&peer_id).await?;
807
808        let conns = self.ws_connections.read().await;
809        let handle = conns
810            .get(&peer_id)
811            .ok_or_else(|| SessionError::SendFailed("connection missing after connect".into()))?;
812        handle
813            .send_tx
814            .send(data.to_vec())
815            .await
816            .map_err(|_| SessionError::SendFailed("connection task closed".to_string()))
817    }
818
819    /// Ensure a WS session exists to `peer_id` (Tailscale routing key).
820    /// Completes the RFC 017 hello and applies identity (RFC 022).
821    ///
822    /// Used by app `send` and by eager-identity (Phase C). Idempotent when
823    /// already connected.
824    pub async fn ensure_ws_connected(&self, peer_id: &str) -> Result<(), SessionError> {
825        // Already connected?
826        {
827            let conns = self.ws_connections.read().await;
828            if conns.contains_key(peer_id) {
829                return Ok(());
830            }
831        }
832
833        let peer_addr = {
834            let map = self.peers.read().await;
835            let state = map
836                .get(peer_id)
837                .ok_or_else(|| SessionError::UnknownPeer(peer_id.to_string()))?;
838            if !state.online {
839                return Err(SessionError::PeerOffline(peer_id.to_string()));
840            }
841            PeerAddr {
842                ip: Some(state.ip),
843                hostname: state.name.clone(),
844                dns_name: None,
845            }
846        };
847
848        // Backoff
849        {
850            let backoffs = self.peer_backoffs.read().await;
851            if let Some(backoff) = backoffs.get(peer_id) {
852                if backoff.should_retry().is_none() {
853                    let retry_after = backoff.retry_after();
854                    return Err(SessionError::ReconnectBackoff { retry_after });
855                }
856            }
857        }
858
859        // Dedupe concurrent dials
860        {
861            let already = {
862                let connecting = self.connecting.read().await;
863                connecting.contains(peer_id)
864            };
865            if already {
866                // Wait briefly for the other dial to finish, then re-check.
867                for _ in 0..50 {
868                    tokio::time::sleep(Duration::from_millis(20)).await;
869                    let conns = self.ws_connections.read().await;
870                    if conns.contains_key(peer_id) {
871                        return Ok(());
872                    }
873                    let connecting = self.connecting.read().await;
874                    if !connecting.contains(peer_id) {
875                        break;
876                    }
877                }
878                let conns = self.ws_connections.read().await;
879                if conns.contains_key(peer_id) {
880                    return Ok(());
881                }
882            }
883            let mut connecting = self.connecting.write().await;
884            if connecting.contains(peer_id) {
885                return Err(SessionError::ConnectFailed(
886                    "connection already in progress".to_string(),
887                ));
888            }
889            connecting.insert(peer_id.to_string());
890        }
891
892        tracing::info!(peer_id = %peer_id, "session: connecting WS");
893
894        let connect_result = self.ws_transport.connect(&peer_addr).await;
895
896        {
897            let mut connecting = self.connecting.write().await;
898            connecting.remove(peer_id);
899        }
900
901        let ws_stream = match connect_result {
902            Ok(stream) => {
903                let mut backoffs = self.peer_backoffs.write().await;
904                backoffs
905                    .entry(peer_id.to_string())
906                    .or_insert_with(ReconnectBackoff::new)
907                    .success();
908                stream
909            }
910            Err(e) => {
911                let mut backoffs = self.peer_backoffs.write().await;
912                backoffs
913                    .entry(peer_id.to_string())
914                    .or_insert_with(ReconnectBackoff::new)
915                    .failure();
916                return Err(SessionError::ConnectFailed(e.to_string()));
917            }
918        };
919
920        // RFC 022 §7.5, dial side: the answerer's claimed tailscale_id must
921        // match the peer this connection was dialed for. A mismatch means we
922        // reached something other than `peer_id` (port collision, loopback
923        // test rig, or a lying hello) — registering it would poison the
924        // entry's identity and route this peer's traffic to the answerer.
925        if let Some(claimed) = ws_stream.remote_identity() {
926            if claimed.tailscale_id != peer_id {
927                tracing::warn!(
928                    peer_id = %peer_id,
929                    claimed = %claimed.tailscale_id,
930                    "session: dialed peer answered as a different tailscale_id; dropping connection"
931                );
932                return Err(SessionError::ConnectFailed(format!(
933                    "hello identity mismatch: dialed {peer_id}, answerer claims {}",
934                    claimed.tailscale_id
935                )));
936            }
937        }
938
939        let remote_identity = ws_stream.remote_identity().cloned();
940
941        let handle = spawn_connection_task(
942            ws_stream,
943            peer_id.to_string(),
944            ConnectionDirection::Outbound,
945            self.ws_connections.clone(),
946            self.peers.clone(),
947            self.event_tx.clone(),
948            self.incoming_tx.clone(),
949        );
950
951        if !install_connection(
952            &self.ws_connections,
953            &self.network.local_identity().tailscale_id,
954            peer_id,
955            handle,
956        )
957        .await
958        {
959            // A simultaneous inbound connection won the deterministic
960            // tie-break. Its hello already established the same peer session.
961            return Ok(());
962        }
963
964        {
965            let mut map = self.peers.write().await;
966            let mut by_dev = self.by_device.write().await;
967            if let Some(state) = map.get_mut(peer_id) {
968                state.ws_connected = true;
969            }
970            if let Some(identity) = remote_identity {
971                let outcomes = apply_identity(&mut map, &mut by_dev, peer_id, identity);
972                emit_identity_outcomes(&self.event_tx, outcomes);
973            }
974        }
975
976        let _ = self
977            .event_tx
978            .send(PeerEvent::WsConnected(peer_id.to_string()));
979
980        Ok(())
981    }
982
983    /// One-shot identity exchange for an online peer (RFC 022 Phase C).
984    ///
985    /// No-ops if identity is already published or the peer is offline.
986    pub async fn ensure_identity(&self, peer_id: &str) -> Result<(), SessionError> {
987        {
988            let map = self.peers.read().await;
989            match map.get(peer_id) {
990                Some(s) if s.published_device_id().is_some() => return Ok(()),
991                Some(s) if !s.online => {
992                    return Err(SessionError::PeerOffline(peer_id.to_string()));
993                }
994                None => return Err(SessionError::UnknownPeer(peer_id.to_string())),
995                _ => {}
996            }
997        }
998        self.ensure_ws_connected(peer_id).await
999    }
1000
1001    async fn resolve_routing_key(&self, peer_id: &str) -> Result<String, SessionError> {
1002        let map = self.peers.read().await;
1003        if map.contains_key(peer_id) {
1004            return Ok(peer_id.to_string());
1005        }
1006
1007        // Published-ULID lookup goes through `by_device` — the first-wins
1008        // authoritative index — so a suppressed duplicate claimant can never
1009        // capture ULID-addressed traffic (RFC 022 §7.7). Never match a raw
1010        // `identity.device_id` here: suppressed identities are unpublished.
1011        {
1012            let by_dev = self.by_device.read().await;
1013            if let Some(ts) = by_dev.get(peer_id) {
1014                return Ok(ts.clone());
1015            }
1016        }
1017
1018        if let Some(found) = map.values().find(|p| {
1019            p.name == peer_id
1020                || (!p.identity_suppressed
1021                    && p.identity
1022                        .as_ref()
1023                        .map(|i| i.device_name == peer_id)
1024                        .unwrap_or(false))
1025        }) {
1026            return Ok(found.id.clone());
1027        }
1028
1029        // Peer-ref selector `{tailscale_id}:{generation}` — generation-checked
1030        // handle routing (RFC 022 I5). Checked last so identifiers that merely
1031        // look ref-shaped can still resolve above; a real ref that reaches
1032        // here is live (generation match), superseded, or departed — the
1033        // latter two must fail with PeerGone, never silently reach a
1034        // rejoined peer.
1035        if let Some((ts, generation)) = parse_peer_ref(peer_id) {
1036            return match map.get(ts) {
1037                Some(p) if p.generation == generation => Ok(ts.to_string()),
1038                _ => Err(SessionError::PeerGone(peer_id.to_string())),
1039            };
1040        }
1041
1042        Err(SessionError::UnknownPeer(peer_id.to_string()))
1043    }
1044
1045    /// Broadcast data to all peers with active WebSocket connections.
1046    ///
1047    /// Sends to all currently connected peers. Peers with no active
1048    /// connection are skipped (no lazy connect on broadcast).
1049    /// Errors from individual sends are logged but do not fail the broadcast.
1050    pub async fn broadcast(&self, data: &[u8]) -> BroadcastReport {
1051        let conns = self.ws_connections.read().await;
1052        let mut report = BroadcastReport {
1053            attempted: conns.len(),
1054            ..Default::default()
1055        };
1056
1057        for (peer_id, handle) in conns.iter() {
1058            if handle.send_tx.send(data.to_vec()).await.is_err() {
1059                tracing::warn!(
1060                    peer_id = %peer_id,
1061                    "session: broadcast send failed (connection task closed)"
1062                );
1063                report.failed.push(peer_id.clone());
1064            } else {
1065                report.queued += 1;
1066            }
1067        }
1068        report
1069    }
1070
1071    /// Subscribe to incoming messages from any connected peer.
1072    ///
1073    /// Returns a broadcast receiver that yields [`IncomingMessage`]s.
1074    /// Messages include the sender's peer ID and raw bytes — Layer 5
1075    /// does not interpret the payload.
1076    pub fn subscribe(&self) -> broadcast::Receiver<IncomingMessage> {
1077        self.incoming_tx.subscribe()
1078    }
1079
1080    /// Test-only: inject an incoming message as if received from a peer,
1081    /// so tests can drive the envelope router without a real transport.
1082    #[cfg(test)]
1083    pub(crate) fn test_inject_incoming(&self, from: &str, data: Vec<u8>) {
1084        let _ = self.incoming_tx.send(IncomingMessage {
1085            from: from.to_string(),
1086            data,
1087            received_at: Instant::now(),
1088        });
1089    }
1090
1091    /// Test-only: stamp a synthetic [`PeerIdentity`] onto an existing
1092    /// peer in the registry, simulating the effect of a completed hello
1093    /// exchange without running a real WebSocket handshake. Returns
1094    /// `true` if the peer was found and updated (including suppressed).
1095    #[doc(hidden)]
1096    pub async fn test_stamp_identity(&self, peer_id: &str, identity: PeerIdentity) -> bool {
1097        let mut map = self.peers.write().await;
1098        if !map.contains_key(peer_id) {
1099            return false;
1100        }
1101        let mut by_dev = self.by_device.write().await;
1102        let outcomes = apply_identity(&mut map, &mut by_dev, peer_id, identity);
1103        emit_identity_outcomes(&self.event_tx, outcomes);
1104        true
1105    }
1106
1107    /// Look up the published Tailscale id for a durable device ULID, if any.
1108    #[doc(hidden)]
1109    pub async fn test_by_device(&self, device_id: &str) -> Option<String> {
1110        self.by_device.read().await.get(device_id).cloned()
1111    }
1112
1113    /// Disconnect a specific peer's WebSocket connection.
1114    ///
1115    /// Removes the cached connection and marks the peer as disconnected.
1116    /// Does not remove the peer from the registry (that only happens when
1117    /// Layer 3 emits a `Left` event).
1118    pub async fn disconnect(&self, peer_id: &str) {
1119        let handle = {
1120            let mut conns = self.ws_connections.write().await;
1121            conns.remove(peer_id)
1122        };
1123
1124        if let Some(handle) = handle {
1125            // Signal the connection task to close. If the channel is already
1126            // closed (task exited), that's fine.
1127            let _ = handle.close_tx.send(()).await;
1128        }
1129
1130        // Mark peer as disconnected
1131        {
1132            let mut map = self.peers.write().await;
1133            if let Some(state) = map.get_mut(peer_id) {
1134                state.ws_connected = false;
1135            }
1136        }
1137
1138        let _ = self
1139            .event_tx
1140            .send(PeerEvent::WsDisconnected(peer_id.to_string()));
1141    }
1142
1143    /// Close all active WebSocket connections and mark every peer as
1144    /// disconnected. Called by `Node::stop()` during teardown. Safe to call
1145    /// multiple times — with no active connections it is a no-op.
1146    pub async fn shutdown(&self) {
1147        // Stop the background loops first (peer-event + WS accept) so no
1148        // new connections or peer updates arrive mid-teardown. Abort is
1149        // safe: both loops only await channel/accept operations.
1150        for handle in self.background_tasks.lock().unwrap().drain(..) {
1151            handle.abort();
1152        }
1153
1154        let handles: Vec<(String, WsConnectionHandle)> = {
1155            let mut conns = self.ws_connections.write().await;
1156            conns.drain().collect()
1157        };
1158        for (peer_id, handle) in handles {
1159            let _ = handle.close_tx.send(()).await;
1160            let _ = self.event_tx.send(PeerEvent::WsDisconnected(peer_id));
1161        }
1162        let mut map = self.peers.write().await;
1163        for state in map.values_mut() {
1164            state.ws_connected = false;
1165        }
1166    }
1167}
1168
1169// ---------------------------------------------------------------------------
1170// Connection task — exclusively owns the WsFramedStream
1171// ---------------------------------------------------------------------------
1172
1173/// Install one connection per peer. During simultaneous cross-dials, both
1174/// nodes deterministically keep the same physical connection: the
1175/// lexicographically smaller Tailscale id keeps its outbound side and the
1176/// larger id keeps the matching inbound side. A one-sided/non-preferred
1177/// connection is still accepted when no connection exists.
1178async fn install_connection(
1179    ws_connections: &Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
1180    local_tailscale_id: &str,
1181    peer_id: &str,
1182    handle: WsConnectionHandle,
1183) -> bool {
1184    let preferred = preferred_connection_direction(local_tailscale_id, peer_id);
1185
1186    let (installed, to_close) = {
1187        let mut conns = ws_connections.write().await;
1188        let replace = match conns.get(peer_id) {
1189            None => true,
1190            Some(existing) => {
1191                should_replace_connection(existing.direction, handle.direction, preferred)
1192            }
1193        };
1194        if replace {
1195            (true, conns.insert(peer_id.to_string(), handle))
1196        } else {
1197            (false, Some(handle))
1198        }
1199    };
1200
1201    if let Some(old) = to_close {
1202        let _ = old.close_tx.try_send(());
1203    }
1204    installed
1205}
1206
1207fn preferred_connection_direction(
1208    local_tailscale_id: &str,
1209    peer_id: &str,
1210) -> Option<ConnectionDirection> {
1211    if local_tailscale_id.is_empty() || local_tailscale_id == peer_id {
1212        None
1213    } else if local_tailscale_id < peer_id {
1214        Some(ConnectionDirection::Outbound)
1215    } else {
1216        Some(ConnectionDirection::Inbound)
1217    }
1218}
1219
1220fn should_replace_connection(
1221    existing: ConnectionDirection,
1222    candidate: ConnectionDirection,
1223    preferred: Option<ConnectionDirection>,
1224) -> bool {
1225    preferred == Some(candidate) && preferred != Some(existing)
1226}
1227
1228/// Spawn a background task that exclusively owns a `WsFramedStream`.
1229///
1230/// The task uses `tokio::select!` to multiplex between:
1231/// - Receiving outgoing data from the `send_rx` channel and writing to the WS
1232/// - Reading incoming data from the WS and forwarding to `incoming_tx`
1233/// - Receiving a close signal from `close_rx`
1234///
1235/// When the task exits (stream closed, error, or close signal), it cleans up
1236/// the connection from the registry and emits a `Disconnected` event.
1237///
1238/// Returns a [`WsConnectionHandle`] for the caller to send data and close.
1239fn spawn_connection_task(
1240    stream: WsFramedStream,
1241    peer_id: String,
1242    direction: ConnectionDirection,
1243    ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
1244    peers: Arc<RwLock<HashMap<String, PeerState>>>,
1245    event_tx: broadcast::Sender<PeerEvent>,
1246    incoming_tx: broadcast::Sender<IncomingMessage>,
1247) -> WsConnectionHandle {
1248    let (send_tx, mut send_rx) = mpsc::channel::<Vec<u8>>(256);
1249    let (close_tx, mut close_rx) = mpsc::channel::<()>(1);
1250
1251    let handle = WsConnectionHandle {
1252        send_tx: send_tx.clone(),
1253        close_tx: close_tx.clone(),
1254        peer_id: peer_id.clone(),
1255        connected_at: Instant::now(),
1256        direction,
1257    };
1258    let task_send_tx = send_tx.clone();
1259
1260    // RFC 022 §7.5: attribute inbound traffic by the WhoIs-verified
1261    // Tailscale stable id of this connection — never the self-declared ULID.
1262    let from_tailscale_id = peer_id.clone();
1263
1264    tokio::spawn(async move {
1265        let mut stream = stream;
1266        let mut closed = false;
1267
1268        loop {
1269            tokio::select! {
1270                // Outgoing: data from send channel → write to WS
1271                Some(data) = send_rx.recv() => {
1272                    if let Err(e) = stream.send(&data).await {
1273                        tracing::warn!(
1274                            peer_id = %peer_id,
1275                            error = %e,
1276                            "session: WS send error"
1277                        );
1278                        break;
1279                    }
1280                }
1281
1282                // Incoming: data from WS → forward to incoming channel
1283                result = stream.recv() => {
1284                    match result {
1285                        Ok(Some(data)) => {
1286                            let msg = IncomingMessage {
1287                                from: from_tailscale_id.clone(),
1288                                data,
1289                                received_at: Instant::now(),
1290                            };
1291                            let _ = incoming_tx.send(msg);
1292                        }
1293                        Ok(None) => {
1294                            tracing::info!(
1295                                peer_id = %peer_id,
1296                                "session: WS stream closed"
1297                            );
1298                            break;
1299                        }
1300                        Err(e) => {
1301                            tracing::warn!(
1302                                peer_id = %peer_id,
1303                                error = %e,
1304                                "session: WS recv error"
1305                            );
1306                            break;
1307                        }
1308                    }
1309                }
1310
1311                // Close signal
1312                _ = close_rx.recv() => {
1313                    tracing::info!(
1314                        peer_id = %peer_id,
1315                        "session: connection close requested"
1316                    );
1317                    closed = true;
1318                    let _ = stream.close().await;
1319                    break;
1320                }
1321            }
1322        }
1323
1324        // Clean up: remove connection from registry, mark peer as disconnected
1325        // Only clean up if we weren't explicitly closed (disconnect() handles
1326        // its own cleanup to avoid racing).
1327        if !closed {
1328            let owned_slot = {
1329                let mut conns = ws_connections.write().await;
1330                let owns_slot = conns
1331                    .get(&peer_id)
1332                    .map(|handle| handle.send_tx.same_channel(&task_send_tx))
1333                    .unwrap_or(false);
1334                if owns_slot {
1335                    conns.remove(&peer_id);
1336                }
1337                owns_slot
1338            };
1339            if owned_slot {
1340                let mut map = peers.write().await;
1341                if let Some(state) = map.get_mut(&peer_id) {
1342                    state.ws_connected = false;
1343                }
1344                let _ = event_tx.send(PeerEvent::WsDisconnected(peer_id));
1345            }
1346        }
1347    });
1348
1349    handle
1350}
1351
1352// ---------------------------------------------------------------------------
1353// Eager identity scheduling (RFC 022 Phase C)
1354// ---------------------------------------------------------------------------
1355
1356/// Deterministic per-peer delay in `0..window_ms` for staggering eager dials
1357/// (RFC 022 §8.1).
1358///
1359/// truffle-core carries no `rand` dependency, so the stagger is a hash of the
1360/// peer id (`DefaultHasher`, fixed-seed SipHash) folded into the window rather
1361/// than a random draw. The properties the eager scheduler relies on:
1362///
1363/// - **Bounded:** always `< window_ms`, and exactly `Duration::ZERO` when
1364///   `window_ms == 0` — which both disables jitter (tests use it to keep eager
1365///   timing deterministic) and avoids a `% 0` panic.
1366/// - **Stable per peer:** one peer id always maps to the same delay for the
1367///   life of the process, so a re-scheduled peer does not thrash.
1368/// - **Spread across peers:** distinct ids land on different offsets, which is
1369///   the whole point — it breaks up the synchronized first-dial burst when a
1370///   node joins a large mesh. Decorrelating the *same* peer across different
1371///   local nodes is out of scope (the herd this targets is one node's own
1372///   outbound burst); the semaphore bounds concurrency regardless.
1373fn eager_jitter_delay(peer_id: &str, window_ms: u64) -> Duration {
1374    if window_ms == 0 {
1375        return Duration::ZERO;
1376    }
1377    use std::collections::hash_map::DefaultHasher;
1378    use std::hash::{Hash, Hasher};
1379    let mut h = DefaultHasher::new();
1380    peer_id.hash(&mut h);
1381    Duration::from_millis(h.finish() % window_ms)
1382}
1383
1384/// Arcs needed to dial for identity without holding `&PeerRegistry`.
1385struct EagerScheduleCtx<N: NetworkProvider + 'static> {
1386    eager_identity: bool,
1387    peers: Arc<RwLock<HashMap<String, PeerState>>>,
1388    by_device: Arc<RwLock<HashMap<String, String>>>,
1389    ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
1390    peer_backoffs: Arc<RwLock<HashMap<String, ReconnectBackoff>>>,
1391    connecting: Arc<RwLock<HashSet<String>>>,
1392    event_tx: broadcast::Sender<PeerEvent>,
1393    incoming_tx: broadcast::Sender<IncomingMessage>,
1394    ws_transport: Arc<WebSocketTransport<N>>,
1395    network: Arc<N>,
1396    eager_identity_sem: Arc<Semaphore>,
1397    eager_identity_jitter_ms: u64,
1398    identity_inflight: Arc<AsyncMutex<HashSet<String>>>,
1399    background_tasks: Arc<std::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>>,
1400}
1401
1402impl<N: NetworkProvider + 'static> EagerScheduleCtx<N> {
1403    fn schedule(&self, peer_id: String) {
1404        if !self.eager_identity {
1405            return;
1406        }
1407
1408        let peers = self.peers.clone();
1409        let by_device = self.by_device.clone();
1410        let ws_connections = self.ws_connections.clone();
1411        let peer_backoffs = self.peer_backoffs.clone();
1412        let connecting = self.connecting.clone();
1413        let event_tx = self.event_tx.clone();
1414        let incoming_tx = self.incoming_tx.clone();
1415        let ws_transport = self.ws_transport.clone();
1416        let network = self.network.clone();
1417        let sem = self.eager_identity_sem.clone();
1418        let jitter_ms = self.eager_identity_jitter_ms;
1419        let inflight = self.identity_inflight.clone();
1420        let connect_ctx = EagerConnectCtx {
1421            peers: peers.clone(),
1422            by_device,
1423            ws_connections,
1424            peer_backoffs: peer_backoffs.clone(),
1425            connecting,
1426            event_tx,
1427            incoming_tx,
1428            ws_transport,
1429            network,
1430        };
1431
1432        let handle = tokio::spawn(async move {
1433            // Dedupe inside the task so a contended try-lock can never create
1434            // two active retry loops for the same peer.
1435            {
1436                let mut set = inflight.lock().await;
1437                if !set.insert(peer_id.clone()) {
1438                    return;
1439                }
1440            }
1441
1442            // RFC 022 §8.1: stagger the first burst of eager hellos with a
1443            // bounded per-peer delay applied *before* acquiring the dial
1444            // permit, so a node joining a large mesh does not fire its first
1445            // `eager_identity_concurrency` hellos simultaneously. Waiting
1446            // before the semaphore (not after) means we never hold a dial slot
1447            // just to idle, and it spreads arrival at the semaphore so even the
1448            // very first dials are staggered. The delay is derived from the
1449            // peer id (no `rand` dependency); only this eager path waits — app
1450            // `send` never does.
1451            let delay = eager_jitter_delay(&peer_id, jitter_ms);
1452            if !delay.is_zero() {
1453                tokio::time::sleep(delay).await;
1454            }
1455
1456            loop {
1457                // Stop retrying once identity is learned or discovery says the
1458                // peer is gone/offline.
1459                let needs = {
1460                    let map = peers.read().await;
1461                    match map.get(&peer_id) {
1462                        Some(s) => s.online && s.published_device_id().is_none(),
1463                        None => false,
1464                    }
1465                };
1466                if !needs {
1467                    break;
1468                }
1469
1470                let permit = match sem.acquire().await {
1471                    Ok(p) => p,
1472                    Err(_) => break,
1473                };
1474
1475                // Build a temporary view with the same connection logic as
1476                // PeerRegistry::ensure_ws_connected (duplicated fields).
1477                let result = eager_connect_ws(&peer_id, &connect_ctx).await;
1478                drop(permit);
1479
1480                match result {
1481                    Ok(()) => break,
1482                    Err(SessionError::UnknownPeer(_) | SessionError::PeerOffline(_)) => break,
1483                    Err(e) => {
1484                        tracing::debug!(
1485                        peer_id = %peer_id,
1486                        error = %e,
1487                        "session: eager identity dial failed; retrying"
1488                        );
1489                    }
1490                }
1491
1492                let retry_after = {
1493                    let backoffs = peer_backoffs.read().await;
1494                    backoffs
1495                        .get(&peer_id)
1496                        .map(ReconnectBackoff::retry_after)
1497                        .unwrap_or_else(|| Duration::from_millis(100))
1498                        .max(Duration::from_millis(25))
1499                };
1500                tokio::time::sleep(retry_after).await;
1501            }
1502
1503            let mut set = inflight.lock().await;
1504            set.remove(&peer_id);
1505        });
1506        let mut tasks = self.background_tasks.lock().unwrap();
1507        tasks.retain(|task| !task.is_finished());
1508        tasks.push(handle);
1509    }
1510}
1511
1512struct EagerConnectCtx<N: NetworkProvider + 'static> {
1513    peers: Arc<RwLock<HashMap<String, PeerState>>>,
1514    by_device: Arc<RwLock<HashMap<String, String>>>,
1515    ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
1516    peer_backoffs: Arc<RwLock<HashMap<String, ReconnectBackoff>>>,
1517    connecting: Arc<RwLock<HashSet<String>>>,
1518    event_tx: broadcast::Sender<PeerEvent>,
1519    incoming_tx: broadcast::Sender<IncomingMessage>,
1520    ws_transport: Arc<WebSocketTransport<N>>,
1521    network: Arc<N>,
1522}
1523
1524/// Connection path shared by eager identity (no send payload).
1525async fn eager_connect_ws<N: NetworkProvider + 'static>(
1526    peer_id: &str,
1527    ctx: &EagerConnectCtx<N>,
1528) -> Result<(), SessionError> {
1529    {
1530        let conns = ctx.ws_connections.read().await;
1531        if conns.contains_key(peer_id) {
1532            return Ok(());
1533        }
1534    }
1535
1536    let peer_addr = {
1537        let map = ctx.peers.read().await;
1538        let state = map
1539            .get(peer_id)
1540            .ok_or_else(|| SessionError::UnknownPeer(peer_id.to_string()))?;
1541        if !state.online {
1542            return Err(SessionError::PeerOffline(peer_id.to_string()));
1543        }
1544        if state.published_device_id().is_some() {
1545            return Ok(());
1546        }
1547        PeerAddr {
1548            ip: Some(state.ip),
1549            hostname: state.name.clone(),
1550            dns_name: None,
1551        }
1552    };
1553
1554    {
1555        let backoffs = ctx.peer_backoffs.read().await;
1556        if let Some(backoff) = backoffs.get(peer_id) {
1557            if backoff.should_retry().is_none() {
1558                return Err(SessionError::ReconnectBackoff {
1559                    retry_after: backoff.retry_after(),
1560                });
1561            }
1562        }
1563    }
1564
1565    {
1566        let mut connecting_g = ctx.connecting.write().await;
1567        if connecting_g.contains(peer_id) {
1568            return Err(SessionError::ConnectFailed(
1569                "connection already in progress".to_string(),
1570            ));
1571        }
1572        connecting_g.insert(peer_id.to_string());
1573    }
1574
1575    tracing::info!(peer_id = %peer_id, "session: eager identity connecting WS");
1576
1577    let connect_result = ctx.ws_transport.connect(&peer_addr).await;
1578
1579    {
1580        let mut connecting_g = ctx.connecting.write().await;
1581        connecting_g.remove(peer_id);
1582    }
1583
1584    let ws_stream = match connect_result {
1585        Ok(stream) => {
1586            let mut backoffs = ctx.peer_backoffs.write().await;
1587            backoffs
1588                .entry(peer_id.to_string())
1589                .or_insert_with(ReconnectBackoff::new)
1590                .success();
1591            stream
1592        }
1593        Err(e) => {
1594            let mut backoffs = ctx.peer_backoffs.write().await;
1595            backoffs
1596                .entry(peer_id.to_string())
1597                .or_insert_with(ReconnectBackoff::new)
1598                .failure();
1599            return Err(SessionError::ConnectFailed(e.to_string()));
1600        }
1601    };
1602
1603    // RFC 022 §7.5, dial side: same claimed-vs-dialed check as
1604    // `ensure_ws_connected` — an eager hello must never adopt an identity
1605    // from an answerer that is not the peer it dialed.
1606    if let Some(claimed) = ws_stream.remote_identity() {
1607        if claimed.tailscale_id != peer_id {
1608            tracing::warn!(
1609                peer_id = %peer_id,
1610                claimed = %claimed.tailscale_id,
1611                "session: eager dial answered as a different tailscale_id; dropping connection"
1612            );
1613            return Err(SessionError::ConnectFailed(format!(
1614                "hello identity mismatch: dialed {peer_id}, answerer claims {}",
1615                claimed.tailscale_id
1616            )));
1617        }
1618    }
1619
1620    let remote_identity = ws_stream.remote_identity().cloned();
1621
1622    let handle = spawn_connection_task(
1623        ws_stream,
1624        peer_id.to_string(),
1625        ConnectionDirection::Outbound,
1626        ctx.ws_connections.clone(),
1627        ctx.peers.clone(),
1628        ctx.event_tx.clone(),
1629        ctx.incoming_tx.clone(),
1630    );
1631
1632    if !install_connection(
1633        &ctx.ws_connections,
1634        &ctx.network.local_identity().tailscale_id,
1635        peer_id,
1636        handle,
1637    )
1638    .await
1639    {
1640        // The matching inbound connection won the cross-dial tie-break.
1641        return Ok(());
1642    }
1643
1644    {
1645        let mut map = ctx.peers.write().await;
1646        let mut by_dev = ctx.by_device.write().await;
1647        if let Some(state) = map.get_mut(peer_id) {
1648            state.ws_connected = true;
1649        }
1650        if let Some(identity) = remote_identity {
1651            let outcomes = apply_identity(&mut map, &mut by_dev, peer_id, identity);
1652            emit_identity_outcomes(&ctx.event_tx, outcomes);
1653        }
1654    }
1655
1656    let _ = ctx
1657        .event_tx
1658        .send(PeerEvent::WsConnected(peer_id.to_string()));
1659    Ok(())
1660}
1661
1662// ---------------------------------------------------------------------------
1663// Helper: convert NetworkPeer to PeerState
1664// ---------------------------------------------------------------------------
1665
1666/// Convert a Layer 3 `NetworkPeer` to a Layer 5 `PeerState`.
1667///
1668/// Sets `ws_connected: false` by default — connections are managed by Layer 5,
1669/// not by Layer 3 discovery. `generation` must be supplied by the registry
1670/// (bumped per re-join of the same Tailscale id).
1671fn network_peer_to_state(peer: &NetworkPeer, generation: u64) -> PeerState {
1672    let connection_type = if let Some(ref relay) = peer.relay {
1673        format!("relay:{relay}")
1674    } else if peer.cur_addr.is_some() {
1675        "direct".to_string()
1676    } else {
1677        "unknown".to_string()
1678    };
1679
1680    PeerState {
1681        id: peer.id.clone(),
1682        generation,
1683        name: peer.hostname.clone(),
1684        ip: peer.ip,
1685        online: peer.online,
1686        ws_connected: false,
1687        connection_type,
1688        os: peer.os.clone(),
1689        last_seen: peer.last_seen.clone(),
1690        identity: None,
1691        identity_suppressed: false,
1692    }
1693}
1694
1695// ---------------------------------------------------------------------------
1696// Identity application (RFC 022 §7.7)
1697// ---------------------------------------------------------------------------
1698
1699/// Side effects from applying a hello identity to a registry entry.
1700#[derive(Debug)]
1701enum IdentityOutcome {
1702    /// Emit `PeerEvent::Identity` for this snapshot.
1703    Identity(PeerState),
1704    /// Retire a ghost entry (same ULID, offline holder) before the new claim.
1705    /// Carries the ghost's final state for the synthesized `Left` event.
1706    GhostLeft(PeerState),
1707    /// Emit `PeerEvent::Updated` — identity metadata (name/os) changed on a
1708    /// re-hello without the ULID changing (no second `identity`, RFC 022 §8).
1709    Updated(PeerState),
1710}
1711
1712/// Apply `identity` to the peer at `ts_id`, updating `by_device` under
1713/// first-wins / ghost-retire / rotation rules.
1714fn apply_identity(
1715    peers: &mut HashMap<String, PeerState>,
1716    by_device: &mut HashMap<String, String>,
1717    ts_id: &str,
1718    identity: PeerIdentity,
1719) -> Vec<IdentityOutcome> {
1720    let mut outcomes = Vec::new();
1721    let uid = identity.device_id.clone();
1722
1723    let Some(state) = peers.get(ts_id) else {
1724        return outcomes;
1725    };
1726
1727    // Rotation: same entry, different ULID already published.
1728    if let Some(prev) = state.published_device_id() {
1729        if prev != uid.as_str() {
1730            by_device.remove(prev);
1731        } else if !state.identity_suppressed {
1732            // Same ULID already published — refresh metadata silently:
1733            // RFC 022 §8 says later confirmations emit no second `identity`
1734            // (every WS reconnect re-hellos). A changed name/os surfaces as
1735            // `updated` instead.
1736            if let Some(s) = peers.get_mut(ts_id) {
1737                let changed = s.identity.as_ref() != Some(&identity);
1738                s.identity = Some(identity);
1739                s.identity_suppressed = false;
1740                if changed {
1741                    outcomes.push(IdentityOutcome::Updated(s.clone()));
1742                }
1743            }
1744            return outcomes;
1745        }
1746    }
1747
1748    match by_device.get(&uid).cloned() {
1749        Some(holder) if holder == ts_id => {
1750            // Already the published owner — update identity block.
1751            if let Some(s) = peers.get_mut(ts_id) {
1752                s.identity = Some(identity);
1753                s.identity_suppressed = false;
1754                outcomes.push(IdentityOutcome::Identity(s.clone()));
1755            }
1756        }
1757        Some(holder) => {
1758            let holder_online = peers.get(&holder).map(|p| p.online).unwrap_or(false);
1759            if holder_online {
1760                // First-wins: store identity but suppress publication.
1761                tracing::warn!(
1762                    device_id = %uid,
1763                    holder = %holder,
1764                    claimant = %ts_id,
1765                    "session: duplicate-device-id — first-wins, suppressing claimant"
1766                );
1767                if let Some(s) = peers.get_mut(ts_id) {
1768                    s.identity = Some(identity);
1769                    s.identity_suppressed = true;
1770                    // No Identity event for suppressed claim (deviceId stays null).
1771                }
1772            } else {
1773                // Ghost retire: offline holder loses the ULID.
1774                if let Some(mut ghost) = peers.remove(&holder) {
1775                    ghost.online = false;
1776                    ghost.ws_connected = false;
1777                    outcomes.push(IdentityOutcome::GhostLeft(ghost));
1778                }
1779                by_device.insert(uid.clone(), ts_id.to_string());
1780                if let Some(s) = peers.get_mut(ts_id) {
1781                    s.identity = Some(identity);
1782                    s.identity_suppressed = false;
1783                    outcomes.push(IdentityOutcome::Identity(s.clone()));
1784                }
1785            }
1786        }
1787        None => {
1788            by_device.insert(uid, ts_id.to_string());
1789            if let Some(s) = peers.get_mut(ts_id) {
1790                s.identity = Some(identity);
1791                s.identity_suppressed = false;
1792                outcomes.push(IdentityOutcome::Identity(s.clone()));
1793            }
1794        }
1795    }
1796
1797    outcomes
1798}
1799
1800fn emit_identity_outcomes(event_tx: &broadcast::Sender<PeerEvent>, outcomes: Vec<IdentityOutcome>) {
1801    for o in outcomes {
1802        match o {
1803            IdentityOutcome::GhostLeft(state) => {
1804                let _ = event_tx.send(PeerEvent::Left(state));
1805            }
1806            IdentityOutcome::Identity(state) => {
1807                let _ = event_tx.send(PeerEvent::Identity(state));
1808            }
1809            IdentityOutcome::Updated(state) => {
1810                let _ = event_tx.send(PeerEvent::Updated(state));
1811            }
1812        }
1813    }
1814}