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}
250
251// ---------------------------------------------------------------------------
252// PeerRegistry
253// ---------------------------------------------------------------------------
254
255/// Manages peer state and WebSocket connections.
256///
257/// The `PeerRegistry` is the heart of Layer 5. It:
258///
259/// 1. **Tracks peers** from Layer 3 discovery events — peers exist in the
260///    registry even with zero transport connections.
261/// 2. **Manages lazy connections** — the first [`send()`](Self::send) to a
262///    peer triggers a WebSocket connection via Layer 4. Subsequent sends
263///    reuse the cached connection.
264/// 3. **Routes messages** — incoming messages from any peer are forwarded
265///    to subscribers via a broadcast channel.
266/// 4. **Emits lifecycle events** — [`PeerEvent`]s for peer discovery changes
267///    and connection state changes.
268///
269/// # Example
270///
271/// ```ignore
272/// use std::sync::Arc;
273/// use truffle_core::session::PeerRegistry;
274///
275/// let registry = PeerRegistry::new(network, ws_transport);
276/// registry.start().await;
277///
278/// // Peers appear from Layer 3 discovery
279/// let peers = registry.peers().await;
280///
281/// // First send lazily connects
282/// registry.send("peer-id", b"hello").await?;
283/// ```
284pub struct PeerRegistry<N: NetworkProvider + 'static> {
285    /// Layer 3 network provider (for peer events and addressing).
286    network: Arc<N>,
287    /// Layer 4 WebSocket transport (for framed connections).
288    ws_transport: Arc<WebSocketTransport<N>>,
289
290    /// All known peers from Layer 3, keyed by Tailscale stable id.
291    /// Peers exist here even with zero connections.
292    peers: Arc<RwLock<HashMap<String, PeerState>>>,
293
294    /// Durable ULID → Tailscale id for at most one **published** live entry
295    /// (RFC 022 §7.7). Used for queries only — never for message attribution.
296    by_device: Arc<RwLock<HashMap<String, String>>>,
297
298    /// Next generation number per Tailscale id (incremented on each join).
299    next_generation: Arc<RwLock<HashMap<String, u64>>>,
300
301    /// Active WebSocket connection handles indexed by peer_id (Tailscale id).
302    ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
303
304    /// Reconnect backoff trackers per peer.
305    peer_backoffs: Arc<RwLock<HashMap<String, ReconnectBackoff>>>,
306
307    /// Set of peer IDs currently being connected to (prevents duplicate dials).
308    connecting: Arc<RwLock<HashSet<String>>>,
309
310    /// Event channel for peer changes (discovery + connection lifecycle).
311    event_tx: broadcast::Sender<PeerEvent>,
312
313    /// Channel for incoming messages from any connected peer.
314    incoming_tx: broadcast::Sender<IncomingMessage>,
315
316    /// Handles to the registry's background loops (peer-event + WS accept),
317    /// retained so [`Self::shutdown`] can abort them instead of leaving
318    /// them running until every Arc clone drops.
319    background_tasks: std::sync::Mutex<Vec<tokio::task::JoinHandle<()>>>,
320
321    /// RFC 022 Phase C: proactively exchange hello with online peers.
322    eager_identity: bool,
323
324    /// Cap concurrent eager-identity dials (default 4).
325    eager_identity_sem: Arc<Semaphore>,
326
327    /// Per-peer jitter window (ms) applied before an eager dial (RFC 022 §8.1).
328    eager_identity_jitter_ms: u64,
329
330    /// Peer ids with an in-flight ensure_identity task (dedupe).
331    identity_inflight: Arc<AsyncMutex<HashSet<String>>>,
332}
333
334/// Options for [`PeerRegistry::with_options`].
335#[derive(Debug, Clone)]
336pub struct PeerRegistryOptions {
337    /// When true (default), dial the envelope WS once per online peer to
338    /// learn durable identity without waiting for app `send` (RFC 022 §8).
339    pub eager_identity: bool,
340    /// Max concurrent eager-identity dials. Default: 4.
341    pub eager_identity_concurrency: usize,
342    /// Max per-peer delay (ms) applied *before* each EAGER identity dial to
343    /// stagger the first burst of hellos when a node joins a large mesh
344    /// (RFC 022 §8.1). The delay is a deterministic hash of the peer id in
345    /// `0..eager_identity_jitter_ms` (truffle-core has no `rand` dependency).
346    /// `0` disables jitter — tests set it for deterministic timing. Only the
347    /// eager path is delayed; app `send` / `ensure_ws_connected` never wait.
348    /// Default: 250.
349    pub eager_identity_jitter_ms: u64,
350}
351
352impl Default for PeerRegistryOptions {
353    fn default() -> Self {
354        Self {
355            eager_identity: true,
356            eager_identity_concurrency: 4,
357            eager_identity_jitter_ms: 250,
358        }
359    }
360}
361
362impl<N: NetworkProvider + 'static> PeerRegistry<N> {
363    /// Create a new peer registry with default options (eager identity on).
364    ///
365    /// - `network`: The Layer 3 network provider for peer discovery.
366    /// - `ws_transport`: The Layer 4 WebSocket transport for connections.
367    ///
368    /// Call [`start()`](Self::start) after creation to begin processing
369    /// peer events and accepting incoming connections.
370    pub fn new(network: Arc<N>, ws_transport: Arc<WebSocketTransport<N>>) -> Self {
371        Self::with_options(network, ws_transport, PeerRegistryOptions::default())
372    }
373
374    /// Create a peer registry with explicit options (RFC 022 Phase C).
375    pub fn with_options(
376        network: Arc<N>,
377        ws_transport: Arc<WebSocketTransport<N>>,
378        options: PeerRegistryOptions,
379    ) -> Self {
380        let (event_tx, _) = broadcast::channel(256);
381        let (incoming_tx, _) = broadcast::channel(1024);
382        let concurrency = options.eager_identity_concurrency.max(1);
383
384        Self {
385            network,
386            ws_transport,
387            peers: Arc::new(RwLock::new(HashMap::new())),
388            by_device: Arc::new(RwLock::new(HashMap::new())),
389            next_generation: Arc::new(RwLock::new(HashMap::new())),
390            ws_connections: Arc::new(RwLock::new(HashMap::new())),
391            peer_backoffs: Arc::new(RwLock::new(HashMap::new())),
392            connecting: Arc::new(RwLock::new(HashSet::new())),
393            event_tx,
394            incoming_tx,
395            background_tasks: std::sync::Mutex::new(Vec::new()),
396            eager_identity: options.eager_identity,
397            eager_identity_sem: Arc::new(Semaphore::new(concurrency)),
398            eager_identity_jitter_ms: options.eager_identity_jitter_ms,
399            identity_inflight: Arc::new(AsyncMutex::new(HashSet::new())),
400        }
401    }
402
403    /// Start the peer registry.
404    ///
405    /// This spawns two background tasks:
406    /// 1. A task that subscribes to Layer 3 peer events and maintains the
407    ///    peer list (Joined/Left/Updated).
408    /// 2. A task that listens for incoming WebSocket connections from peers
409    ///    and spawns connection tasks for each.
410    ///
411    /// Call this once after constructing the registry.
412    pub async fn start(&self) {
413        // Task 1: Subscribe to Layer 3 peer events
414        self.spawn_peer_event_loop();
415
416        // Task 2: Accept incoming WS connections
417        self.spawn_accept_loop().await;
418    }
419
420    /// Spawn a task that subscribes to Layer 3 peer events and updates the
421    /// internal peer list.
422    fn spawn_peer_event_loop(&self) {
423        let mut events = self.network.peer_events();
424        let peers = self.peers.clone();
425        let by_device = self.by_device.clone();
426        let next_generation = self.next_generation.clone();
427        let ws_connections = self.ws_connections.clone();
428        let event_tx = self.event_tx.clone();
429        // Clones for scheduling eager identity from the event loop.
430        let schedule_ctx = EagerScheduleCtx {
431            eager_identity: self.eager_identity,
432            peers: self.peers.clone(),
433            by_device: self.by_device.clone(),
434            ws_connections: self.ws_connections.clone(),
435            peer_backoffs: self.peer_backoffs.clone(),
436            connecting: self.connecting.clone(),
437            event_tx: self.event_tx.clone(),
438            incoming_tx: self.incoming_tx.clone(),
439            ws_transport: self.ws_transport.clone(),
440            network: self.network.clone(),
441            eager_identity_sem: self.eager_identity_sem.clone(),
442            eager_identity_jitter_ms: self.eager_identity_jitter_ms,
443            identity_inflight: self.identity_inflight.clone(),
444        };
445
446        let handle = tokio::spawn(async move {
447            loop {
448                match events.recv().await {
449                    Ok(NetworkPeerEvent::Joined(network_peer)) => {
450                        let generation = {
451                            let mut gens = next_generation.write().await;
452                            let e = gens.entry(network_peer.id.clone()).or_insert(0);
453                            *e += 1;
454                            *e
455                        };
456                        let state = network_peer_to_state(&network_peer, generation);
457                        let online = state.online;
458                        let peer_id = network_peer.id.clone();
459                        let peer_event = PeerEvent::Joined(state.clone());
460
461                        {
462                            let mut map = peers.write().await;
463                            map.insert(network_peer.id.clone(), state);
464                        }
465
466                        let _ = event_tx.send(peer_event);
467                        tracing::info!(
468                            peer_id = %network_peer.id,
469                            generation,
470                            peer_name = %network_peer.hostname,
471                            "session: peer joined"
472                        );
473
474                        if online {
475                            schedule_ctx.schedule(peer_id);
476                        }
477                    }
478                    Ok(NetworkPeerEvent::Left(peer_id)) => {
479                        // Close any active WS connection for this peer
480                        let handle = {
481                            let mut conns = ws_connections.write().await;
482                            conns.remove(&peer_id)
483                        };
484                        if let Some(handle) = handle {
485                            let _ = handle.close_tx.send(()).await;
486                            // Emit Disconnected before Left
487                            let _ = event_tx.send(PeerEvent::WsDisconnected(peer_id.clone()));
488                            tracing::info!(
489                                peer_id = %peer_id,
490                                "session: closed WS connection for departing peer"
491                            );
492                        }
493
494                        let removed = {
495                            let mut map = peers.write().await;
496                            map.remove(&peer_id)
497                        };
498
499                        // Drop by_device mapping if it pointed at this peer; promote
500                        // a suppressed claimant if one exists (RFC 022 §7.7).
501                        if let Some(mut removed) = removed {
502                            let promote = {
503                                let mut by_dev = by_device.write().await;
504                                if let Some(uid) = removed.published_device_id() {
505                                    if by_dev.get(uid).map(|s| s.as_str()) == Some(peer_id.as_str())
506                                    {
507                                        by_dev.remove(uid);
508                                        Some(uid.to_string())
509                                    } else {
510                                        None
511                                    }
512                                } else if let Some(ref ident) = removed.identity {
513                                    // Suppressed holder leaving — nothing published
514                                    let _ = ident;
515                                    None
516                                } else {
517                                    None
518                                }
519                            };
520
521                            if let Some(uid) = promote {
522                                let mut map = peers.write().await;
523                                let mut by_dev = by_device.write().await;
524                                if let Some(promoted) = map.values_mut().find(|p| {
525                                    p.id != peer_id
526                                        && p.online
527                                        && p.identity
528                                            .as_ref()
529                                            .map(|i| i.device_id == uid)
530                                            .unwrap_or(false)
531                                        && p.identity_suppressed
532                                }) {
533                                    promoted.identity_suppressed = false;
534                                    by_dev.insert(uid.clone(), promoted.id.clone());
535                                    let snap = promoted.clone();
536                                    drop(map);
537                                    drop(by_dev);
538                                    let _ = event_tx.send(PeerEvent::Identity(snap));
539                                    tracing::info!(
540                                        device_id = %uid,
541                                        "session: promoted suppressed ULID claimant after holder left"
542                                    );
543                                }
544                            }
545
546                            // Emit `Left` with the entry's final view: the map
547                            // entry is already gone, and consumers need a
548                            // usable last state for cleanup (RFC 022 §16.4).
549                            removed.online = false;
550                            removed.ws_connected = false;
551                            let _ = event_tx.send(PeerEvent::Left(removed));
552                            tracing::info!(peer_id = %peer_id, "session: peer left");
553                        } else {
554                            // Never announced via `joined` — nothing to retire,
555                            // so no `left` is emitted either.
556                            tracing::debug!(
557                                peer_id = %peer_id,
558                                "session: left for unknown peer; no event"
559                            );
560                        }
561                    }
562                    Ok(NetworkPeerEvent::Updated(network_peer)) => {
563                        let mut state = network_peer_to_state(&network_peer, 0);
564                        // Both branches below assign this before it is read;
565                        // no initializer keeps the unused-assignment lint quiet.
566                        let became_online_without_identity;
567
568                        // Preserve Layer 5 state (ws_connected, identity,
569                        // generation, suppression) from the existing entry —
570                        // Layer 3 Updated events only carry discovery metadata.
571                        {
572                            let mut map = peers.write().await;
573                            if let Some(existing) = map.get(&network_peer.id) {
574                                state.generation = existing.generation;
575                                state.ws_connected = existing.ws_connected;
576                                state.identity = existing.identity.clone();
577                                state.identity_suppressed = existing.identity_suppressed;
578                                became_online_without_identity = !existing.online
579                                    && state.online
580                                    && existing.published_device_id().is_none();
581                            } else {
582                                // Unknown peer Updated without Joined — treat as gen 1.
583                                let mut gens = next_generation.write().await;
584                                let e = gens.entry(network_peer.id.clone()).or_insert(0);
585                                *e += 1;
586                                state.generation = *e;
587                                became_online_without_identity =
588                                    state.online && state.published_device_id().is_none();
589                            }
590                            map.insert(network_peer.id.clone(), state.clone());
591                        }
592
593                        let peer_id = network_peer.id.clone();
594                        let _ = event_tx.send(PeerEvent::Updated(state));
595                        tracing::debug!(
596                            peer_id = %network_peer.id,
597                            "session: peer updated"
598                        );
599
600                        if became_online_without_identity {
601                            schedule_ctx.schedule(peer_id);
602                        }
603                    }
604                    Ok(NetworkPeerEvent::AuthRequired { url }) => {
605                        let _ = event_tx.send(PeerEvent::AuthRequired { url });
606                    }
607                    Err(broadcast::error::RecvError::Lagged(n)) => {
608                        tracing::warn!(
609                            missed = n,
610                            "session: peer event receiver lagged, missed {n} events"
611                        );
612                    }
613                    Err(broadcast::error::RecvError::Closed) => {
614                        tracing::debug!("session: peer event channel closed");
615                        break;
616                    }
617                }
618            }
619        });
620        self.background_tasks.lock().unwrap().push(handle);
621    }
622
623    /// Spawn a task that accepts incoming WebSocket connections from peers.
624    async fn spawn_accept_loop(&self) {
625        let ws_transport = self.ws_transport.clone();
626        let ws_connections = self.ws_connections.clone();
627        let peers = self.peers.clone();
628        let by_device = self.by_device.clone();
629        let event_tx = self.event_tx.clone();
630        let incoming_tx = self.incoming_tx.clone();
631
632        // Try to start the WS listener. If it fails, log and return.
633        let mut listener = match ws_transport.listen().await {
634            Ok(l) => l,
635            Err(e) => {
636                tracing::error!("session: failed to start WS listener: {e}");
637                return;
638            }
639        };
640
641        let handle = tokio::spawn(async move {
642            loop {
643                match listener.accept().await {
644                    Some(stream) => {
645                        let peer_id = stream.remote_peer_id().to_string();
646                        let remote_identity = stream.remote_identity().cloned();
647                        tracing::info!(
648                            peer_id = %peer_id,
649                            device_id = remote_identity.as_ref().map(|i| i.device_id.as_str()),
650                            "session: accepted incoming WS connection"
651                        );
652
653                        // Create connection handle and spawn connection task.
654                        // Attribution uses WhoIs-verified Tailscale id (peer_id).
655                        let handle = spawn_connection_task(
656                            stream,
657                            peer_id.clone(),
658                            ws_connections.clone(),
659                            peers.clone(),
660                            event_tx.clone(),
661                            incoming_tx.clone(),
662                        );
663
664                        {
665                            let mut conns = ws_connections.write().await;
666                            conns.insert(peer_id.clone(), handle);
667                        }
668
669                        // Mark peer as connected and apply identity from hello.
670                        {
671                            let mut map = peers.write().await;
672                            let mut by_dev = by_device.write().await;
673                            if let Some(state) = map.get_mut(&peer_id) {
674                                state.ws_connected = true;
675                            }
676                            if let Some(identity) = remote_identity {
677                                let outcomes =
678                                    apply_identity(&mut map, &mut by_dev, &peer_id, identity);
679                                emit_identity_outcomes(&event_tx, outcomes);
680                            }
681                        }
682
683                        let _ = event_tx.send(PeerEvent::WsConnected(peer_id));
684                    }
685                    None => {
686                        tracing::debug!("session: WS listener closed");
687                        break;
688                    }
689                }
690            }
691        });
692        self.background_tasks.lock().unwrap().push(handle);
693    }
694
695    /// Return all known peers.
696    ///
697    /// This returns peers discovered by Layer 3, including those with
698    /// no active transport connections (`ws_connected: false`).
699    pub async fn peers(&self) -> Vec<PeerState> {
700        let map = self.peers.read().await;
701        map.values().cloned().collect()
702    }
703
704    /// Subscribe to peer change events.
705    ///
706    /// Returns a broadcast receiver that yields [`PeerEvent`]s for peer
707    /// discovery changes (Joined/Left/Updated) and connection lifecycle
708    /// changes (Connected/Disconnected).
709    pub fn on_peer_change(&self) -> broadcast::Receiver<PeerEvent> {
710        self.event_tx.subscribe()
711    }
712
713    /// Send data to a specific peer.
714    ///
715    /// If no WebSocket connection exists to the peer, one is lazily
716    /// established via Layer 4. The connection is cached for subsequent
717    /// sends. If the peer is unknown or offline, an error is returned.
718    ///
719    /// # Errors
720    ///
721    /// - [`SessionError::UnknownPeer`] if the peer is not in the registry
722    /// - [`SessionError::PeerOffline`] if Layer 3 reports the peer as offline
723    /// - [`SessionError::ConnectFailed`] if the WS connection cannot be established
724    /// - [`SessionError::SendFailed`] if the send operation fails
725    pub async fn send(&self, peer_id: &str, data: &[u8]) -> Result<(), SessionError> {
726        let peer_id = self.resolve_routing_key(peer_id).await?;
727        self.ensure_ws_connected(&peer_id).await?;
728
729        let conns = self.ws_connections.read().await;
730        let handle = conns
731            .get(&peer_id)
732            .ok_or_else(|| SessionError::SendFailed("connection missing after connect".into()))?;
733        handle
734            .send_tx
735            .send(data.to_vec())
736            .await
737            .map_err(|_| SessionError::SendFailed("connection task closed".to_string()))
738    }
739
740    /// Ensure a WS session exists to `peer_id` (Tailscale routing key).
741    /// Completes the RFC 017 hello and applies identity (RFC 022).
742    ///
743    /// Used by app `send` and by eager-identity (Phase C). Idempotent when
744    /// already connected.
745    pub async fn ensure_ws_connected(&self, peer_id: &str) -> Result<(), SessionError> {
746        // Already connected?
747        {
748            let conns = self.ws_connections.read().await;
749            if conns.contains_key(peer_id) {
750                return Ok(());
751            }
752        }
753
754        let peer_addr = {
755            let map = self.peers.read().await;
756            let state = map
757                .get(peer_id)
758                .ok_or_else(|| SessionError::UnknownPeer(peer_id.to_string()))?;
759            if !state.online {
760                return Err(SessionError::PeerOffline(peer_id.to_string()));
761            }
762            PeerAddr {
763                ip: Some(state.ip),
764                hostname: state.name.clone(),
765                dns_name: None,
766            }
767        };
768
769        // Backoff
770        {
771            let backoffs = self.peer_backoffs.read().await;
772            if let Some(backoff) = backoffs.get(peer_id) {
773                if backoff.should_retry().is_none() {
774                    let retry_after = backoff.retry_after();
775                    return Err(SessionError::ReconnectBackoff { retry_after });
776                }
777            }
778        }
779
780        // Dedupe concurrent dials
781        {
782            let already = {
783                let connecting = self.connecting.read().await;
784                connecting.contains(peer_id)
785            };
786            if already {
787                // Wait briefly for the other dial to finish, then re-check.
788                for _ in 0..50 {
789                    tokio::time::sleep(Duration::from_millis(20)).await;
790                    let conns = self.ws_connections.read().await;
791                    if conns.contains_key(peer_id) {
792                        return Ok(());
793                    }
794                    let connecting = self.connecting.read().await;
795                    if !connecting.contains(peer_id) {
796                        break;
797                    }
798                }
799                let conns = self.ws_connections.read().await;
800                if conns.contains_key(peer_id) {
801                    return Ok(());
802                }
803            }
804            let mut connecting = self.connecting.write().await;
805            if connecting.contains(peer_id) {
806                return Err(SessionError::ConnectFailed(
807                    "connection already in progress".to_string(),
808                ));
809            }
810            connecting.insert(peer_id.to_string());
811        }
812
813        tracing::info!(peer_id = %peer_id, "session: connecting WS");
814
815        let connect_result = self.ws_transport.connect(&peer_addr).await;
816
817        {
818            let mut connecting = self.connecting.write().await;
819            connecting.remove(peer_id);
820        }
821
822        let ws_stream = match connect_result {
823            Ok(stream) => {
824                let mut backoffs = self.peer_backoffs.write().await;
825                backoffs
826                    .entry(peer_id.to_string())
827                    .or_insert_with(ReconnectBackoff::new)
828                    .success();
829                stream
830            }
831            Err(e) => {
832                let mut backoffs = self.peer_backoffs.write().await;
833                backoffs
834                    .entry(peer_id.to_string())
835                    .or_insert_with(ReconnectBackoff::new)
836                    .failure();
837                return Err(SessionError::ConnectFailed(e.to_string()));
838            }
839        };
840
841        // RFC 022 §7.5, dial side: the answerer's claimed tailscale_id must
842        // match the peer this connection was dialed for. A mismatch means we
843        // reached something other than `peer_id` (port collision, loopback
844        // test rig, or a lying hello) — registering it would poison the
845        // entry's identity and route this peer's traffic to the answerer.
846        if let Some(claimed) = ws_stream.remote_identity() {
847            if claimed.tailscale_id != peer_id {
848                tracing::warn!(
849                    peer_id = %peer_id,
850                    claimed = %claimed.tailscale_id,
851                    "session: dialed peer answered as a different tailscale_id; dropping connection"
852                );
853                return Err(SessionError::ConnectFailed(format!(
854                    "hello identity mismatch: dialed {peer_id}, answerer claims {}",
855                    claimed.tailscale_id
856                )));
857            }
858        }
859
860        let remote_identity = ws_stream.remote_identity().cloned();
861
862        let handle = spawn_connection_task(
863            ws_stream,
864            peer_id.to_string(),
865            self.ws_connections.clone(),
866            self.peers.clone(),
867            self.event_tx.clone(),
868            self.incoming_tx.clone(),
869        );
870
871        {
872            let mut conns = self.ws_connections.write().await;
873            conns.insert(peer_id.to_string(), handle);
874        }
875
876        {
877            let mut map = self.peers.write().await;
878            let mut by_dev = self.by_device.write().await;
879            if let Some(state) = map.get_mut(peer_id) {
880                state.ws_connected = true;
881            }
882            if let Some(identity) = remote_identity {
883                let outcomes = apply_identity(&mut map, &mut by_dev, peer_id, identity);
884                emit_identity_outcomes(&self.event_tx, outcomes);
885            }
886        }
887
888        let _ = self
889            .event_tx
890            .send(PeerEvent::WsConnected(peer_id.to_string()));
891
892        Ok(())
893    }
894
895    /// One-shot identity exchange for an online peer (RFC 022 Phase C).
896    ///
897    /// No-ops if identity is already published or the peer is offline.
898    pub async fn ensure_identity(&self, peer_id: &str) -> Result<(), SessionError> {
899        {
900            let map = self.peers.read().await;
901            match map.get(peer_id) {
902                Some(s) if s.published_device_id().is_some() => return Ok(()),
903                Some(s) if !s.online => {
904                    return Err(SessionError::PeerOffline(peer_id.to_string()));
905                }
906                None => return Err(SessionError::UnknownPeer(peer_id.to_string())),
907                _ => {}
908            }
909        }
910        self.ensure_ws_connected(peer_id).await
911    }
912
913    async fn resolve_routing_key(&self, peer_id: &str) -> Result<String, SessionError> {
914        let map = self.peers.read().await;
915        if map.contains_key(peer_id) {
916            return Ok(peer_id.to_string());
917        }
918
919        // Published-ULID lookup goes through `by_device` — the first-wins
920        // authoritative index — so a suppressed duplicate claimant can never
921        // capture ULID-addressed traffic (RFC 022 §7.7). Never match a raw
922        // `identity.device_id` here: suppressed identities are unpublished.
923        {
924            let by_dev = self.by_device.read().await;
925            if let Some(ts) = by_dev.get(peer_id) {
926                return Ok(ts.clone());
927            }
928        }
929
930        if let Some(found) = map.values().find(|p| {
931            p.name == peer_id
932                || (!p.identity_suppressed
933                    && p.identity
934                        .as_ref()
935                        .map(|i| i.device_name == peer_id)
936                        .unwrap_or(false))
937        }) {
938            return Ok(found.id.clone());
939        }
940
941        // Peer-ref selector `{tailscale_id}:{generation}` — generation-checked
942        // handle routing (RFC 022 I5). Checked last so identifiers that merely
943        // look ref-shaped can still resolve above; a real ref that reaches
944        // here is live (generation match), superseded, or departed — the
945        // latter two must fail with PeerGone, never silently reach a
946        // rejoined peer.
947        if let Some((ts, generation)) = parse_peer_ref(peer_id) {
948            return match map.get(ts) {
949                Some(p) if p.generation == generation => Ok(ts.to_string()),
950                _ => Err(SessionError::PeerGone(peer_id.to_string())),
951            };
952        }
953
954        Err(SessionError::UnknownPeer(peer_id.to_string()))
955    }
956
957    /// Broadcast data to all peers with active WebSocket connections.
958    ///
959    /// Sends to all currently connected peers. Peers with no active
960    /// connection are skipped (no lazy connect on broadcast).
961    /// Errors from individual sends are logged but do not fail the broadcast.
962    pub async fn broadcast(&self, data: &[u8]) -> BroadcastReport {
963        let conns = self.ws_connections.read().await;
964        let mut report = BroadcastReport {
965            attempted: conns.len(),
966            ..Default::default()
967        };
968
969        for (peer_id, handle) in conns.iter() {
970            if handle.send_tx.send(data.to_vec()).await.is_err() {
971                tracing::warn!(
972                    peer_id = %peer_id,
973                    "session: broadcast send failed (connection task closed)"
974                );
975                report.failed.push(peer_id.clone());
976            } else {
977                report.queued += 1;
978            }
979        }
980        report
981    }
982
983    /// Subscribe to incoming messages from any connected peer.
984    ///
985    /// Returns a broadcast receiver that yields [`IncomingMessage`]s.
986    /// Messages include the sender's peer ID and raw bytes — Layer 5
987    /// does not interpret the payload.
988    pub fn subscribe(&self) -> broadcast::Receiver<IncomingMessage> {
989        self.incoming_tx.subscribe()
990    }
991
992    /// Test-only: inject an incoming message as if received from a peer,
993    /// so tests can drive the envelope router without a real transport.
994    #[cfg(test)]
995    pub(crate) fn test_inject_incoming(&self, from: &str, data: Vec<u8>) {
996        let _ = self.incoming_tx.send(IncomingMessage {
997            from: from.to_string(),
998            data,
999            received_at: Instant::now(),
1000        });
1001    }
1002
1003    /// Test-only: stamp a synthetic [`PeerIdentity`] onto an existing
1004    /// peer in the registry, simulating the effect of a completed hello
1005    /// exchange without running a real WebSocket handshake. Returns
1006    /// `true` if the peer was found and updated (including suppressed).
1007    #[doc(hidden)]
1008    pub async fn test_stamp_identity(&self, peer_id: &str, identity: PeerIdentity) -> bool {
1009        let mut map = self.peers.write().await;
1010        if !map.contains_key(peer_id) {
1011            return false;
1012        }
1013        let mut by_dev = self.by_device.write().await;
1014        let outcomes = apply_identity(&mut map, &mut by_dev, peer_id, identity);
1015        emit_identity_outcomes(&self.event_tx, outcomes);
1016        true
1017    }
1018
1019    /// Look up the published Tailscale id for a durable device ULID, if any.
1020    #[doc(hidden)]
1021    pub async fn test_by_device(&self, device_id: &str) -> Option<String> {
1022        self.by_device.read().await.get(device_id).cloned()
1023    }
1024
1025    /// Disconnect a specific peer's WebSocket connection.
1026    ///
1027    /// Removes the cached connection and marks the peer as disconnected.
1028    /// Does not remove the peer from the registry (that only happens when
1029    /// Layer 3 emits a `Left` event).
1030    pub async fn disconnect(&self, peer_id: &str) {
1031        let handle = {
1032            let mut conns = self.ws_connections.write().await;
1033            conns.remove(peer_id)
1034        };
1035
1036        if let Some(handle) = handle {
1037            // Signal the connection task to close. If the channel is already
1038            // closed (task exited), that's fine.
1039            let _ = handle.close_tx.send(()).await;
1040        }
1041
1042        // Mark peer as disconnected
1043        {
1044            let mut map = self.peers.write().await;
1045            if let Some(state) = map.get_mut(peer_id) {
1046                state.ws_connected = false;
1047            }
1048        }
1049
1050        let _ = self
1051            .event_tx
1052            .send(PeerEvent::WsDisconnected(peer_id.to_string()));
1053    }
1054
1055    /// Close all active WebSocket connections and mark every peer as
1056    /// disconnected. Called by `Node::stop()` during teardown. Safe to call
1057    /// multiple times — with no active connections it is a no-op.
1058    pub async fn shutdown(&self) {
1059        // Stop the background loops first (peer-event + WS accept) so no
1060        // new connections or peer updates arrive mid-teardown. Abort is
1061        // safe: both loops only await channel/accept operations.
1062        for handle in self.background_tasks.lock().unwrap().drain(..) {
1063            handle.abort();
1064        }
1065
1066        let handles: Vec<(String, WsConnectionHandle)> = {
1067            let mut conns = self.ws_connections.write().await;
1068            conns.drain().collect()
1069        };
1070        for (peer_id, handle) in handles {
1071            let _ = handle.close_tx.send(()).await;
1072            let _ = self.event_tx.send(PeerEvent::WsDisconnected(peer_id));
1073        }
1074        let mut map = self.peers.write().await;
1075        for state in map.values_mut() {
1076            state.ws_connected = false;
1077        }
1078    }
1079}
1080
1081// ---------------------------------------------------------------------------
1082// Connection task — exclusively owns the WsFramedStream
1083// ---------------------------------------------------------------------------
1084
1085/// Spawn a background task that exclusively owns a `WsFramedStream`.
1086///
1087/// The task uses `tokio::select!` to multiplex between:
1088/// - Receiving outgoing data from the `send_rx` channel and writing to the WS
1089/// - Reading incoming data from the WS and forwarding to `incoming_tx`
1090/// - Receiving a close signal from `close_rx`
1091///
1092/// When the task exits (stream closed, error, or close signal), it cleans up
1093/// the connection from the registry and emits a `Disconnected` event.
1094///
1095/// Returns a [`WsConnectionHandle`] for the caller to send data and close.
1096fn spawn_connection_task(
1097    stream: WsFramedStream,
1098    peer_id: String,
1099    ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
1100    peers: Arc<RwLock<HashMap<String, PeerState>>>,
1101    event_tx: broadcast::Sender<PeerEvent>,
1102    incoming_tx: broadcast::Sender<IncomingMessage>,
1103) -> WsConnectionHandle {
1104    let (send_tx, mut send_rx) = mpsc::channel::<Vec<u8>>(256);
1105    let (close_tx, mut close_rx) = mpsc::channel::<()>(1);
1106
1107    let handle = WsConnectionHandle {
1108        send_tx: send_tx.clone(),
1109        close_tx: close_tx.clone(),
1110        peer_id: peer_id.clone(),
1111        connected_at: Instant::now(),
1112    };
1113
1114    // RFC 022 §7.5: attribute inbound traffic by the WhoIs-verified
1115    // Tailscale stable id of this connection — never the self-declared ULID.
1116    let from_tailscale_id = peer_id.clone();
1117
1118    tokio::spawn(async move {
1119        let mut stream = stream;
1120        let mut closed = false;
1121
1122        loop {
1123            tokio::select! {
1124                // Outgoing: data from send channel → write to WS
1125                Some(data) = send_rx.recv() => {
1126                    if let Err(e) = stream.send(&data).await {
1127                        tracing::warn!(
1128                            peer_id = %peer_id,
1129                            error = %e,
1130                            "session: WS send error"
1131                        );
1132                        break;
1133                    }
1134                }
1135
1136                // Incoming: data from WS → forward to incoming channel
1137                result = stream.recv() => {
1138                    match result {
1139                        Ok(Some(data)) => {
1140                            let msg = IncomingMessage {
1141                                from: from_tailscale_id.clone(),
1142                                data,
1143                                received_at: Instant::now(),
1144                            };
1145                            let _ = incoming_tx.send(msg);
1146                        }
1147                        Ok(None) => {
1148                            tracing::info!(
1149                                peer_id = %peer_id,
1150                                "session: WS stream closed"
1151                            );
1152                            break;
1153                        }
1154                        Err(e) => {
1155                            tracing::warn!(
1156                                peer_id = %peer_id,
1157                                error = %e,
1158                                "session: WS recv error"
1159                            );
1160                            break;
1161                        }
1162                    }
1163                }
1164
1165                // Close signal
1166                _ = close_rx.recv() => {
1167                    tracing::info!(
1168                        peer_id = %peer_id,
1169                        "session: connection close requested"
1170                    );
1171                    closed = true;
1172                    let _ = stream.close().await;
1173                    break;
1174                }
1175            }
1176        }
1177
1178        // Clean up: remove connection from registry, mark peer as disconnected
1179        // Only clean up if we weren't explicitly closed (disconnect() handles
1180        // its own cleanup to avoid racing).
1181        if !closed {
1182            {
1183                let mut conns = ws_connections.write().await;
1184                conns.remove(&peer_id);
1185            }
1186            {
1187                let mut map = peers.write().await;
1188                if let Some(state) = map.get_mut(&peer_id) {
1189                    state.ws_connected = false;
1190                }
1191            }
1192            let _ = event_tx.send(PeerEvent::WsDisconnected(peer_id));
1193        }
1194    });
1195
1196    handle
1197}
1198
1199// ---------------------------------------------------------------------------
1200// Eager identity scheduling (RFC 022 Phase C)
1201// ---------------------------------------------------------------------------
1202
1203/// Deterministic per-peer delay in `0..window_ms` for staggering eager dials
1204/// (RFC 022 §8.1).
1205///
1206/// truffle-core carries no `rand` dependency, so the stagger is a hash of the
1207/// peer id (`DefaultHasher`, fixed-seed SipHash) folded into the window rather
1208/// than a random draw. The properties the eager scheduler relies on:
1209///
1210/// - **Bounded:** always `< window_ms`, and exactly `Duration::ZERO` when
1211///   `window_ms == 0` — which both disables jitter (tests use it to keep eager
1212///   timing deterministic) and avoids a `% 0` panic.
1213/// - **Stable per peer:** one peer id always maps to the same delay for the
1214///   life of the process, so a re-scheduled peer does not thrash.
1215/// - **Spread across peers:** distinct ids land on different offsets, which is
1216///   the whole point — it breaks up the synchronized first-dial burst when a
1217///   node joins a large mesh. Decorrelating the *same* peer across different
1218///   local nodes is out of scope (the herd this targets is one node's own
1219///   outbound burst); the semaphore bounds concurrency regardless.
1220fn eager_jitter_delay(peer_id: &str, window_ms: u64) -> Duration {
1221    if window_ms == 0 {
1222        return Duration::ZERO;
1223    }
1224    use std::collections::hash_map::DefaultHasher;
1225    use std::hash::{Hash, Hasher};
1226    let mut h = DefaultHasher::new();
1227    peer_id.hash(&mut h);
1228    Duration::from_millis(h.finish() % window_ms)
1229}
1230
1231/// Arcs needed to dial for identity without holding `&PeerRegistry`.
1232struct EagerScheduleCtx<N: NetworkProvider + 'static> {
1233    eager_identity: bool,
1234    peers: Arc<RwLock<HashMap<String, PeerState>>>,
1235    by_device: Arc<RwLock<HashMap<String, String>>>,
1236    ws_connections: Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
1237    peer_backoffs: Arc<RwLock<HashMap<String, ReconnectBackoff>>>,
1238    connecting: Arc<RwLock<HashSet<String>>>,
1239    event_tx: broadcast::Sender<PeerEvent>,
1240    incoming_tx: broadcast::Sender<IncomingMessage>,
1241    ws_transport: Arc<WebSocketTransport<N>>,
1242    network: Arc<N>,
1243    eager_identity_sem: Arc<Semaphore>,
1244    eager_identity_jitter_ms: u64,
1245    identity_inflight: Arc<AsyncMutex<HashSet<String>>>,
1246}
1247
1248impl<N: NetworkProvider + 'static> EagerScheduleCtx<N> {
1249    fn schedule(&self, peer_id: String) {
1250        if !self.eager_identity {
1251            return;
1252        }
1253
1254        // Dedupe in-flight ensures.
1255        {
1256            // try_lock: if contended, still spawn (double-check inside task).
1257            if let Ok(mut inflight) = self.identity_inflight.try_lock() {
1258                if !inflight.insert(peer_id.clone()) {
1259                    return;
1260                }
1261            }
1262        }
1263
1264        let peers = self.peers.clone();
1265        let by_device = self.by_device.clone();
1266        let ws_connections = self.ws_connections.clone();
1267        let peer_backoffs = self.peer_backoffs.clone();
1268        let connecting = self.connecting.clone();
1269        let event_tx = self.event_tx.clone();
1270        let incoming_tx = self.incoming_tx.clone();
1271        let ws_transport = self.ws_transport.clone();
1272        let _network = self.network.clone();
1273        let sem = self.eager_identity_sem.clone();
1274        let jitter_ms = self.eager_identity_jitter_ms;
1275        let inflight = self.identity_inflight.clone();
1276
1277        tokio::spawn(async move {
1278            // Mark inflight (if try_lock missed earlier).
1279            {
1280                let mut set = inflight.lock().await;
1281                set.insert(peer_id.clone());
1282            }
1283
1284            // RFC 022 §8.1: stagger the first burst of eager hellos with a
1285            // bounded per-peer delay applied *before* acquiring the dial
1286            // permit, so a node joining a large mesh does not fire its first
1287            // `eager_identity_concurrency` hellos simultaneously. Waiting
1288            // before the semaphore (not after) means we never hold a dial slot
1289            // just to idle, and it spreads arrival at the semaphore so even the
1290            // very first dials are staggered. The delay is derived from the
1291            // peer id (no `rand` dependency); only this eager path waits — app
1292            // `send` never does.
1293            let delay = eager_jitter_delay(&peer_id, jitter_ms);
1294            if !delay.is_zero() {
1295                tokio::time::sleep(delay).await;
1296            }
1297
1298            let permit = match sem.acquire().await {
1299                Ok(p) => p,
1300                Err(_) => {
1301                    let mut set = inflight.lock().await;
1302                    set.remove(&peer_id);
1303                    return;
1304                }
1305            };
1306
1307            // Skip if identity already known or peer gone/offline.
1308            let needs = {
1309                let map = peers.read().await;
1310                match map.get(&peer_id) {
1311                    Some(s) => s.online && s.published_device_id().is_none(),
1312                    None => false,
1313                }
1314            };
1315
1316            if needs {
1317                // Build a temporary view with the same connection logic as
1318                // PeerRegistry::ensure_ws_connected (duplicated fields).
1319                if let Err(e) = eager_connect_ws(
1320                    &peer_id,
1321                    &peers,
1322                    &by_device,
1323                    &ws_connections,
1324                    &peer_backoffs,
1325                    &connecting,
1326                    &event_tx,
1327                    &incoming_tx,
1328                    &ws_transport,
1329                )
1330                .await
1331                {
1332                    tracing::debug!(
1333                        peer_id = %peer_id,
1334                        error = %e,
1335                        "session: eager identity dial failed"
1336                    );
1337                }
1338            }
1339
1340            drop(permit);
1341            let mut set = inflight.lock().await;
1342            set.remove(&peer_id);
1343        });
1344    }
1345}
1346
1347/// Connection path shared by eager identity (no send payload).
1348async fn eager_connect_ws<N: NetworkProvider + 'static>(
1349    peer_id: &str,
1350    peers: &Arc<RwLock<HashMap<String, PeerState>>>,
1351    by_device: &Arc<RwLock<HashMap<String, String>>>,
1352    ws_connections: &Arc<RwLock<HashMap<String, WsConnectionHandle>>>,
1353    peer_backoffs: &Arc<RwLock<HashMap<String, ReconnectBackoff>>>,
1354    connecting: &Arc<RwLock<HashSet<String>>>,
1355    event_tx: &broadcast::Sender<PeerEvent>,
1356    incoming_tx: &broadcast::Sender<IncomingMessage>,
1357    ws_transport: &Arc<WebSocketTransport<N>>,
1358) -> Result<(), SessionError> {
1359    {
1360        let conns = ws_connections.read().await;
1361        if conns.contains_key(peer_id) {
1362            return Ok(());
1363        }
1364    }
1365
1366    let peer_addr = {
1367        let map = peers.read().await;
1368        let state = map
1369            .get(peer_id)
1370            .ok_or_else(|| SessionError::UnknownPeer(peer_id.to_string()))?;
1371        if !state.online {
1372            return Err(SessionError::PeerOffline(peer_id.to_string()));
1373        }
1374        if state.published_device_id().is_some() {
1375            return Ok(());
1376        }
1377        PeerAddr {
1378            ip: Some(state.ip),
1379            hostname: state.name.clone(),
1380            dns_name: None,
1381        }
1382    };
1383
1384    {
1385        let backoffs = peer_backoffs.read().await;
1386        if let Some(backoff) = backoffs.get(peer_id) {
1387            if backoff.should_retry().is_none() {
1388                return Err(SessionError::ReconnectBackoff {
1389                    retry_after: backoff.retry_after(),
1390                });
1391            }
1392        }
1393    }
1394
1395    {
1396        let mut connecting_g = connecting.write().await;
1397        if connecting_g.contains(peer_id) {
1398            return Err(SessionError::ConnectFailed(
1399                "connection already in progress".to_string(),
1400            ));
1401        }
1402        connecting_g.insert(peer_id.to_string());
1403    }
1404
1405    tracing::info!(peer_id = %peer_id, "session: eager identity connecting WS");
1406
1407    let connect_result = ws_transport.connect(&peer_addr).await;
1408
1409    {
1410        let mut connecting_g = connecting.write().await;
1411        connecting_g.remove(peer_id);
1412    }
1413
1414    let ws_stream = match connect_result {
1415        Ok(stream) => {
1416            let mut backoffs = peer_backoffs.write().await;
1417            backoffs
1418                .entry(peer_id.to_string())
1419                .or_insert_with(ReconnectBackoff::new)
1420                .success();
1421            stream
1422        }
1423        Err(e) => {
1424            let mut backoffs = peer_backoffs.write().await;
1425            backoffs
1426                .entry(peer_id.to_string())
1427                .or_insert_with(ReconnectBackoff::new)
1428                .failure();
1429            return Err(SessionError::ConnectFailed(e.to_string()));
1430        }
1431    };
1432
1433    // RFC 022 §7.5, dial side: same claimed-vs-dialed check as
1434    // `ensure_ws_connected` — an eager hello must never adopt an identity
1435    // from an answerer that is not the peer it dialed.
1436    if let Some(claimed) = ws_stream.remote_identity() {
1437        if claimed.tailscale_id != peer_id {
1438            tracing::warn!(
1439                peer_id = %peer_id,
1440                claimed = %claimed.tailscale_id,
1441                "session: eager dial answered as a different tailscale_id; dropping connection"
1442            );
1443            return Err(SessionError::ConnectFailed(format!(
1444                "hello identity mismatch: dialed {peer_id}, answerer claims {}",
1445                claimed.tailscale_id
1446            )));
1447        }
1448    }
1449
1450    let remote_identity = ws_stream.remote_identity().cloned();
1451
1452    let handle = spawn_connection_task(
1453        ws_stream,
1454        peer_id.to_string(),
1455        ws_connections.clone(),
1456        peers.clone(),
1457        event_tx.clone(),
1458        incoming_tx.clone(),
1459    );
1460
1461    {
1462        let mut conns = ws_connections.write().await;
1463        conns.insert(peer_id.to_string(), handle);
1464    }
1465
1466    {
1467        let mut map = peers.write().await;
1468        let mut by_dev = by_device.write().await;
1469        if let Some(state) = map.get_mut(peer_id) {
1470            state.ws_connected = true;
1471        }
1472        if let Some(identity) = remote_identity {
1473            let outcomes = apply_identity(&mut map, &mut by_dev, peer_id, identity);
1474            emit_identity_outcomes(event_tx, outcomes);
1475        }
1476    }
1477
1478    let _ = event_tx.send(PeerEvent::WsConnected(peer_id.to_string()));
1479    Ok(())
1480}
1481
1482// ---------------------------------------------------------------------------
1483// Helper: convert NetworkPeer to PeerState
1484// ---------------------------------------------------------------------------
1485
1486/// Convert a Layer 3 `NetworkPeer` to a Layer 5 `PeerState`.
1487///
1488/// Sets `ws_connected: false` by default — connections are managed by Layer 5,
1489/// not by Layer 3 discovery. `generation` must be supplied by the registry
1490/// (bumped per re-join of the same Tailscale id).
1491fn network_peer_to_state(peer: &NetworkPeer, generation: u64) -> PeerState {
1492    let connection_type = if let Some(ref relay) = peer.relay {
1493        format!("relay:{relay}")
1494    } else if peer.cur_addr.is_some() {
1495        "direct".to_string()
1496    } else {
1497        "unknown".to_string()
1498    };
1499
1500    PeerState {
1501        id: peer.id.clone(),
1502        generation,
1503        name: peer.hostname.clone(),
1504        ip: peer.ip,
1505        online: peer.online,
1506        ws_connected: false,
1507        connection_type,
1508        os: peer.os.clone(),
1509        last_seen: peer.last_seen.clone(),
1510        identity: None,
1511        identity_suppressed: false,
1512    }
1513}
1514
1515// ---------------------------------------------------------------------------
1516// Identity application (RFC 022 §7.7)
1517// ---------------------------------------------------------------------------
1518
1519/// Side effects from applying a hello identity to a registry entry.
1520#[derive(Debug)]
1521enum IdentityOutcome {
1522    /// Emit `PeerEvent::Identity` for this snapshot.
1523    Identity(PeerState),
1524    /// Retire a ghost entry (same ULID, offline holder) before the new claim.
1525    /// Carries the ghost's final state for the synthesized `Left` event.
1526    GhostLeft(PeerState),
1527    /// Emit `PeerEvent::Updated` — identity metadata (name/os) changed on a
1528    /// re-hello without the ULID changing (no second `identity`, RFC 022 §8).
1529    Updated(PeerState),
1530}
1531
1532/// Apply `identity` to the peer at `ts_id`, updating `by_device` under
1533/// first-wins / ghost-retire / rotation rules.
1534fn apply_identity(
1535    peers: &mut HashMap<String, PeerState>,
1536    by_device: &mut HashMap<String, String>,
1537    ts_id: &str,
1538    identity: PeerIdentity,
1539) -> Vec<IdentityOutcome> {
1540    let mut outcomes = Vec::new();
1541    let uid = identity.device_id.clone();
1542
1543    let Some(state) = peers.get(ts_id) else {
1544        return outcomes;
1545    };
1546
1547    // Rotation: same entry, different ULID already published.
1548    if let Some(prev) = state.published_device_id() {
1549        if prev != uid.as_str() {
1550            by_device.remove(prev);
1551        } else if !state.identity_suppressed {
1552            // Same ULID already published — refresh metadata silently:
1553            // RFC 022 §8 says later confirmations emit no second `identity`
1554            // (every WS reconnect re-hellos). A changed name/os surfaces as
1555            // `updated` instead.
1556            if let Some(s) = peers.get_mut(ts_id) {
1557                let changed = s.identity.as_ref() != Some(&identity);
1558                s.identity = Some(identity);
1559                s.identity_suppressed = false;
1560                if changed {
1561                    outcomes.push(IdentityOutcome::Updated(s.clone()));
1562                }
1563            }
1564            return outcomes;
1565        }
1566    }
1567
1568    match by_device.get(&uid).cloned() {
1569        Some(holder) if holder == ts_id => {
1570            // Already the published owner — update identity block.
1571            if let Some(s) = peers.get_mut(ts_id) {
1572                s.identity = Some(identity);
1573                s.identity_suppressed = false;
1574                outcomes.push(IdentityOutcome::Identity(s.clone()));
1575            }
1576        }
1577        Some(holder) => {
1578            let holder_online = peers.get(&holder).map(|p| p.online).unwrap_or(false);
1579            if holder_online {
1580                // First-wins: store identity but suppress publication.
1581                tracing::warn!(
1582                    device_id = %uid,
1583                    holder = %holder,
1584                    claimant = %ts_id,
1585                    "session: duplicate-device-id — first-wins, suppressing claimant"
1586                );
1587                if let Some(s) = peers.get_mut(ts_id) {
1588                    s.identity = Some(identity);
1589                    s.identity_suppressed = true;
1590                    // No Identity event for suppressed claim (deviceId stays null).
1591                }
1592            } else {
1593                // Ghost retire: offline holder loses the ULID.
1594                if let Some(mut ghost) = peers.remove(&holder) {
1595                    ghost.online = false;
1596                    ghost.ws_connected = false;
1597                    outcomes.push(IdentityOutcome::GhostLeft(ghost));
1598                }
1599                by_device.insert(uid.clone(), ts_id.to_string());
1600                if let Some(s) = peers.get_mut(ts_id) {
1601                    s.identity = Some(identity);
1602                    s.identity_suppressed = false;
1603                    outcomes.push(IdentityOutcome::Identity(s.clone()));
1604                }
1605            }
1606        }
1607        None => {
1608            by_device.insert(uid, ts_id.to_string());
1609            if let Some(s) = peers.get_mut(ts_id) {
1610                s.identity = Some(identity);
1611                s.identity_suppressed = false;
1612                outcomes.push(IdentityOutcome::Identity(s.clone()));
1613            }
1614        }
1615    }
1616
1617    outcomes
1618}
1619
1620fn emit_identity_outcomes(event_tx: &broadcast::Sender<PeerEvent>, outcomes: Vec<IdentityOutcome>) {
1621    for o in outcomes {
1622        match o {
1623            IdentityOutcome::GhostLeft(state) => {
1624                let _ = event_tx.send(PeerEvent::Left(state));
1625            }
1626            IdentityOutcome::Identity(state) => {
1627                let _ = event_tx.send(PeerEvent::Identity(state));
1628            }
1629            IdentityOutcome::Updated(state) => {
1630                let _ = event_tx.send(PeerEvent::Updated(state));
1631            }
1632        }
1633    }
1634}