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