Skip to main content

dig_nat/
fast_connect.rs

1//! Fast-connect — start on the fastest usable transport, then live-promote to a better one.
2//!
3//! [`connect`](crate::connect) races the traversal ladder and returns ONE connection over the first
4//! tier that lands. For a NAT'd peer that tier is often the relay (TURN) — usable immediately, but
5//! the relay carries every byte. [`connect_fast`] instead returns the first-usable transport AND keeps
6//! racing a better (direct) path in the background; when a direct path lands AND proves itself, it is
7//! promoted SEAMLESSLY, with no interruption to in-flight work.
8//!
9//! ## Why the handoff is safe (the crux)
10//!
11//! [`yamux`](crate::mux) runs over ONE mTLS byte stream and is transport-bound — you cannot swap the
12//! byte transport under a live `yamux::Connection`. So the handoff happens at the STREAM-ROUTING layer
13//! ABOVE the session, never at the byte layer: **a live logical stream NEVER migrates; only NEW
14//! streams route to the promoted transport, and old streams drain on the old one.** DIG's peer API is
15//! already a factory of short-lived, request-scoped streams ([`open_range_stream`](FastPeerConnection::open_range_stream),
16//! [`query_availability`](FastPeerConnection::query_availability) — a fresh yamux stream each, with no
17//! cross-stream ordering contract), so route-new + drain-old is correct by construction: no loss, no
18//! reorder, no duplication, and no read-quiesce/flush is needed because the byte path is never swapped.
19//!
20//! The swap itself is a single [`ArcSwap`] pointer store: [`open_stream`](FastPeerConnection::open_stream)
21//! loads the CURRENT transport slot and opens its stream; promotion stores the new slot, so only
22//! subsequent `open_stream` calls see it. The swapped-out relayed slot is held in a draining state
23//! until its in-flight streams finish (or a short grace cap elapses), then dropped — which releases
24//! ONLY the per-peer relay tunnel, never the node's persistent relay reservation.
25//!
26//! ## Promotion gate (conservative — SECURITY-CRITICAL)
27//!
28//! A direct path is promoted ONLY when ALL hold:
29//! 1. the direct-tier mTLS handshake completed with the `peer_id` pin verified (the dialer guarantees
30//!    this or errors);
31//! 2. the direct connection's identity EQUALS the relayed one — same `peer_id` AND same #1204 BLS
32//!    pubkey (the identity-equality invariant that makes swapping transports to "the same peer" safe);
33//! 3. ONE successful application round-trip over the direct session (an empty-availability probe) —
34//!    proving real bidirectional mux traffic, because a NAT mapping can complete TLS then blackhole.
35//!
36//! Never promote on handshake-completion alone. A failed gate REFUSES promotion and stays relayed.
37//!
38//! ## mTLS + NC-1
39//!
40//! The session does not survive the swap and need not: `peer_id = SHA-256(TLS SPKI DER)` is
41//! transport-bound, and the direct path runs its OWN mTLS to the SAME `peer_id`. The identity-equality
42//! gate (2) is the invariant that makes the swap safe. NC-1 payload sealing sits ABOVE dig-nat, keyed
43//! to the peer's BLS pubkey (identical across transports), so it is unaffected by a transport swap.
44
45use std::future::Future;
46use std::net::SocketAddr;
47use std::pin::Pin;
48use std::sync::atomic::{AtomicUsize, Ordering};
49use std::sync::Arc;
50use std::task::{Context, Poll};
51use std::time::Duration;
52
53use arc_swap::ArcSwap;
54use futures::future::{select, Either};
55use tokio::io::{AsyncRead, AsyncWrite, AsyncWriteExt, ReadBuf};
56use tokio::sync::{watch, Mutex, Notify};
57
58use crate::dialer::MtlsDialer;
59use crate::error::NatError;
60use crate::method::{MethodOutcome, TraversalKind};
61use crate::mux::{
62    AvailabilityRequest, AvailabilityResponse, ClosedHandle, PeerSession, PeerStream, RangeRequest,
63};
64use crate::peer::{PeerConnection, PeerTarget};
65use crate::strategy::{self, Dialer};
66use crate::{NatConfig, NatRuntime, NodeCert, PeerId};
67
68/// A fresh single-tier dial attempt, produced on demand by an [`Establisher`]. `'static` + `Send` so
69/// it can be moved into the background promotion guard.
70type DialFuture = Pin<Box<dyn Future<Output = Result<PeerConnection, NatError>> + Send>>;
71
72/// A reusable factory that starts ONE fresh dial over a specific transport tier (direct ladder, or
73/// relayed). Reusable so the promotion guard can RE-establish (the relayed reconnect / direct-death
74/// fallback) without re-plumbing the runtime handles.
75type Establisher = Arc<dyn Fn() -> DialFuture + Send + Sync>;
76
77/// One transport under a [`FastPeerConnection`]: its multiplexed session plus the metadata the
78/// promotion gate + drain accounting need. Slots are swapped whole via [`ArcSwap`]; an in-flight
79/// stream holds an `Arc<TransportSlot>` so its session is never dropped from under it.
80struct TransportSlot {
81    /// The multiplexed mTLS session. A `tokio` mutex because [`PeerSession::open_stream`] is `&mut`
82    /// async; the lock is held only for the brief open (a channel send + oneshot await), never for
83    /// stream IO.
84    session: Mutex<PeerSession>,
85    /// Which traversal tier established this slot (observability + [`FastPeerConnection::current_method`]).
86    method: TraversalKind,
87    /// The remote address this slot's session runs over (the peer's endpoint, or the relay).
88    remote_addr: SocketAddr,
89    /// The peer's verified #1204 BLS pubkey on this slot — the identity-equality invariant compares
90    /// the direct slot's against the relayed one's before promoting.
91    peer_bls_pub: Option<[u8; 48]>,
92    /// Observer of this slot's session closing (transport death) — the guard awaits it to fall back.
93    closed: ClosedHandle,
94    /// Live streams opened on THIS slot not yet dropped — drain accounting for a swapped-out slot.
95    outstanding: AtomicUsize,
96    /// Notified when a stream on this slot drops, so the drain can complete early (before the cap).
97    drained: Notify,
98}
99
100impl TransportSlot {
101    /// Wrap an established [`PeerConnection`] as a swappable transport slot.
102    fn from_conn(conn: PeerConnection) -> Arc<TransportSlot> {
103        let closed = conn.session.closed_handle();
104        Arc::new(TransportSlot {
105            session: Mutex::new(conn.session),
106            method: conn.method,
107            remote_addr: conn.remote_addr,
108            peer_bls_pub: conn.peer_bls_pub,
109            closed,
110            outstanding: AtomicUsize::new(0),
111            drained: Notify::new(),
112        })
113    }
114}
115
116/// A peer connection that starts on the fastest usable transport and LIVE-PROMOTES to a better one
117/// (relayed → direct) transparently, with no interruption to in-flight streams.
118///
119/// The public API mirrors [`PeerConnection`] but is `&self` (interior mutability): the current
120/// transport is swapped atomically underneath, so a caller keeps ONE handle across a promotion. Which
121/// tier is currently active is observable via [`current_method`](Self::current_method) /
122/// [`subscribe`](Self::subscribe) — observability only; the caller opens streams identically
123/// regardless of the tier.
124pub struct FastPeerConnection {
125    /// The verified remote identity — stable across every transport swap (the invariant that makes a
126    /// swap safe).
127    peer_id: PeerId,
128    /// The current transport, swapped atomically on promotion/fallback. `open_stream` loads this.
129    active: Arc<ArcSwap<TransportSlot>>,
130    /// The last-published active method, for [`subscribe`](Self::subscribe) notifications.
131    events: watch::Sender<TraversalKind>,
132    /// Owns the background promote/fallback task; aborts it on drop so no work outlives the handle.
133    _guard: PromotionGuard,
134}
135
136/// A logical stream over a [`FastPeerConnection`]'s CURRENT transport. Holds an `Arc<TransportSlot>`
137/// so the transport it was opened on is never dropped from under it (an in-flight stream always
138/// completes on the transport it started on — the route-new/drain-old contract), and decrements the
139/// slot's drain counter on drop so a post-promotion drain can finish as soon as its streams do.
140pub struct FastPeerStream {
141    inner: PeerStream,
142    slot: Arc<TransportSlot>,
143}
144
145impl Drop for FastPeerStream {
146    fn drop(&mut self) {
147        // Reaching zero wakes a waiting drain immediately (before the grace cap).
148        if self.slot.outstanding.fetch_sub(1, Ordering::AcqRel) == 1 {
149            self.slot.drained.notify_waiters();
150        }
151    }
152}
153
154impl AsyncRead for FastPeerStream {
155    fn poll_read(
156        mut self: Pin<&mut Self>,
157        cx: &mut Context<'_>,
158        buf: &mut ReadBuf<'_>,
159    ) -> Poll<std::io::Result<()>> {
160        Pin::new(&mut self.inner).poll_read(cx, buf)
161    }
162}
163
164impl AsyncWrite for FastPeerStream {
165    fn poll_write(
166        mut self: Pin<&mut Self>,
167        cx: &mut Context<'_>,
168        buf: &[u8],
169    ) -> Poll<std::io::Result<usize>> {
170        Pin::new(&mut self.inner).poll_write(cx, buf)
171    }
172
173    fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
174        Pin::new(&mut self.inner).poll_flush(cx)
175    }
176
177    fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> {
178        Pin::new(&mut self.inner).poll_shutdown(cx)
179    }
180}
181
182impl FastPeerConnection {
183    /// The verified remote identity — stable across every transport swap.
184    pub fn peer_id(&self) -> PeerId {
185        self.peer_id
186    }
187
188    /// The traversal tier currently carrying this connection (authoritative — read from the live
189    /// active slot). Flips on a live promotion (relayed→direct) or a fallback (direct→relayed).
190    pub fn current_method(&self) -> TraversalKind {
191        self.active.load().method
192    }
193
194    /// The remote address the current transport runs over (the peer's endpoint, or the relay).
195    pub fn remote_addr(&self) -> SocketAddr {
196        self.active.load().remote_addr
197    }
198
199    /// Subscribe to active-transport changes (each promotion/fallback sends the new
200    /// [`TraversalKind`]). Observability only.
201    pub fn subscribe(&self) -> watch::Receiver<TraversalKind> {
202        self.events.subscribe()
203    }
204
205    /// Open a new concurrent logical stream over the CURRENT transport (cheap — open as many as you
206    /// need). The stream completes on whichever transport was active when it opened, even if a
207    /// promotion swaps the active transport meanwhile.
208    pub async fn open_stream(&self) -> std::io::Result<FastPeerStream> {
209        let slot = self.active.load_full();
210        let stream = {
211            let mut session = slot.session.lock().await;
212            session.open_stream().await?
213        };
214        slot.outstanding.fetch_add(1, Ordering::AcqRel);
215        Ok(FastPeerStream {
216            inner: stream,
217            slot,
218        })
219    }
220
221    /// Open a `dig.fetchRange` stream for `req` over the current transport (writes the range preamble,
222    /// then the caller reads [`RangeFrame`](crate::mux::RangeFrame)s).
223    pub async fn open_range_stream(&self, req: &RangeRequest) -> std::io::Result<FastPeerStream> {
224        let mut stream = self.open_stream().await?;
225        stream.write_all(&req.encode()).await?;
226        stream.flush().await?;
227        Ok(stream)
228    }
229
230    /// Availability pre-check (`dig.getAvailability`) over the current transport — a short-lived
231    /// control round-trip on a fresh stream.
232    pub async fn query_availability(
233        &self,
234        items: Vec<crate::mux::AvailabilityItem>,
235    ) -> std::io::Result<AvailabilityResponse> {
236        let mut stream = self.open_stream().await?;
237        stream
238            .write_all(&AvailabilityRequest { items }.encode())
239            .await?;
240        stream.flush().await?;
241        AvailabilityResponse::decode(&mut stream).await
242    }
243}
244
245impl std::fmt::Debug for FastPeerConnection {
246    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
247        f.debug_struct("FastPeerConnection")
248            .field("peer_id", &self.peer_id)
249            .field("method", &self.current_method())
250            .field("remote_addr", &self.remote_addr())
251            .finish_non_exhaustive()
252    }
253}
254
255/// Owns the background promote/fallback task and aborts it when the [`FastPeerConnection`] drops, so
256/// no promotion work outlives the handle it serves.
257struct PromotionGuard {
258    handle: tokio::task::JoinHandle<()>,
259}
260
261impl Drop for PromotionGuard {
262    fn drop(&mut self) {
263        self.handle.abort();
264    }
265}
266
267/// Establish a mutually-authenticated connection to `peer`, returning the FIRST usable transport and
268/// LIVE-PROMOTING to a better one in the background (see the module docs).
269///
270/// It concurrently launches (a) a relayed dial over the held relay reservation (if `runtime` wired a
271/// relay data-plane) and (b) the DIRECT traversal ladder race (Direct → UPnP → NAT-PMP → PCP →
272/// hole-punch — the full ladder MINUS the relayed tier), and returns a [`FastPeerConnection`] as soon
273/// as EITHER lands:
274/// - a NAT'd peer whose relay lands first is returned relayed-active while the direct ladder keeps
275///   racing; when it lands + passes the promotion gate, the connection is promoted to direct;
276/// - a public peer whose direct dial wins outright is returned direct-active and the relay is never
277///   used.
278///
279/// `connect`/`connect_with_runtime`/[`PeerConnection`] are unchanged; this is an additive alternate
280/// entry point.
281///
282/// # Errors
283/// [`NatError::AllMethodsFailed`] if BOTH the relayed and direct attempts fail (or
284/// [`NatError::NoMethodsEnabled`] if neither tier could even be composed).
285pub async fn connect_fast(
286    peer: &PeerTarget,
287    node: &Arc<NodeCert>,
288    config: &NatConfig,
289    runtime: &NatRuntime,
290) -> Result<FastPeerConnection, NatError> {
291    // The direct ladder = the full ladder MINUS the relayed tier, composed from the current runtime.
292    let mut direct_config = config.clone();
293    direct_config
294        .enabled_methods
295        .retain(|k| *k != TraversalKind::Relayed);
296    let direct_methods = crate::compose_ladder(&direct_config, runtime);
297    let direct_dialer =
298        Arc::new(MtlsDialer::new(Arc::clone(node)).with_binding_policy(config.binding_policy));
299    let direct: Establisher = {
300        let peer = peer.clone();
301        let timeout = config.per_method_timeout;
302        Arc::new(move || {
303            let peer = peer.clone();
304            let dialer = Arc::clone(&direct_dialer);
305            let methods = direct_methods.clone();
306            Box::pin(async move {
307                strategy::connect_with_strategy(&peer, methods, dialer.as_ref(), timeout).await
308            })
309        })
310    };
311
312    // The relayed tier, if a relay data-plane is wired (a NAT'd node with a held reservation).
313    let relayed: Option<Establisher> = runtime.relayed.as_ref().map(|relayed_dialer| {
314        let relayed_dialer = Arc::clone(relayed_dialer);
315        let node = Arc::clone(node);
316        let peer = peer.clone();
317        let binding = config.binding_policy;
318        let endpoint = relayed_dialer.relay_endpoint();
319        let est: Establisher = Arc::new(move || {
320            let dialer = MtlsDialer::new(Arc::clone(&node))
321                .with_binding_policy(binding)
322                .with_relayed_dialer(Arc::clone(&relayed_dialer));
323            let peer = peer.clone();
324            Box::pin(async move {
325                let outcome = MethodOutcome::single(TraversalKind::Relayed, endpoint);
326                dialer
327                    .dial(&peer, &outcome)
328                    .await
329                    .map_err(|e| NatError::AllMethodsFailed(vec![e]))
330            })
331        });
332        est
333    });
334
335    connect_fast_with(
336        peer.peer_id,
337        direct,
338        relayed,
339        config.fast_connect_grace,
340        config.per_method_timeout,
341    )
342    .await
343}
344
345/// The transport-agnostic core: race the direct + relayed establishers, return the first-usable
346/// [`FastPeerConnection`], and spawn the background promote/fallback guard. Split out so the promotion
347/// state machine is unit-tested with fake establishers (no real network) — see the tests below.
348async fn connect_fast_with(
349    expected_peer_id: PeerId,
350    direct: Establisher,
351    relayed: Option<Establisher>,
352    grace_cap: Duration,
353    probe_timeout: Duration,
354) -> Result<FastPeerConnection, NatError> {
355    let direct_fut = direct();
356    let Some(relayed) = relayed else {
357        // No relay tier: the direct ladder is the only path. First-usable == direct.
358        let conn = direct_fut.await?;
359        return Ok(build(
360            expected_peer_id,
361            conn,
362            GuardPlan::none(),
363            grace_cap,
364            probe_timeout,
365        ));
366    };
367    let relayed_fut = relayed();
368
369    match select(relayed_fut, direct_fut).await {
370        // Relayed landed first.
371        Either::Left((relayed_res, direct_fut)) => match relayed_res {
372            Ok(relayed_conn) => {
373                // Relayed-active; keep the in-flight direct attempt racing for promotion, and wire
374                // the relayed establisher as the direct-death fallback / relay-reconnect path.
375                let plan = GuardPlan {
376                    promote_from: Some(direct_fut),
377                    fallback: Some(relayed),
378                };
379                Ok(build(
380                    expected_peer_id,
381                    relayed_conn,
382                    plan,
383                    grace_cap,
384                    probe_timeout,
385                ))
386            }
387            // Relayed failed — fall back to whatever the direct ladder produces.
388            Err(relayed_err) => match direct_fut.await {
389                Ok(direct_conn) => Ok(build(
390                    expected_peer_id,
391                    direct_conn,
392                    GuardPlan::fallback_only(Some(relayed)),
393                    grace_cap,
394                    probe_timeout,
395                )),
396                Err(direct_err) => Err(merge_errors(relayed_err, direct_err)),
397            },
398        },
399        // Direct landed first.
400        Either::Right((direct_res, relayed_fut)) => match direct_res {
401            // Direct won outright — return direct-active; the relay is never used (cancel it). The
402            // relayed establisher stays wired as the direct-death fallback.
403            Ok(direct_conn) => {
404                drop(relayed_fut);
405                Ok(build(
406                    expected_peer_id,
407                    direct_conn,
408                    GuardPlan::fallback_only(Some(relayed)),
409                    grace_cap,
410                    probe_timeout,
411                ))
412            }
413            // Direct failed — await the relayed attempt.
414            Err(direct_err) => match relayed_fut.await {
415                Ok(relayed_conn) => {
416                    // Relayed-active; retry a promotion once via the direct establisher.
417                    let plan = GuardPlan {
418                        promote_from: Some(direct()),
419                        fallback: Some(relayed),
420                    };
421                    Ok(build(
422                        expected_peer_id,
423                        relayed_conn,
424                        plan,
425                        grace_cap,
426                        probe_timeout,
427                    ))
428                }
429                Err(relayed_err) => Err(merge_errors(relayed_err, direct_err)),
430            },
431        },
432    }
433}
434
435/// What the background guard should do for a given initial connection.
436struct GuardPlan {
437    /// A pending direct attempt to await + (on success) promote the connection to. `None` when the
438    /// initial connection is already direct.
439    promote_from: Option<DialFuture>,
440    /// A reusable establisher to re-dial when the active transport dies (direct→relayed fallback, or
441    /// relayed reconnect). `None` when there is nothing to fall back to (a public peer with no relay).
442    fallback: Option<Establisher>,
443}
444
445impl GuardPlan {
446    fn none() -> Self {
447        GuardPlan {
448            promote_from: None,
449            fallback: None,
450        }
451    }
452    fn fallback_only(fallback: Option<Establisher>) -> Self {
453        GuardPlan {
454            promote_from: None,
455            fallback,
456        }
457    }
458}
459
460/// Assemble a [`FastPeerConnection`] from its initial slot + spawn the background guard for `plan`.
461fn build(
462    peer_id: PeerId,
463    initial: PeerConnection,
464    plan: GuardPlan,
465    grace_cap: Duration,
466    probe_timeout: Duration,
467) -> FastPeerConnection {
468    let slot = TransportSlot::from_conn(initial);
469    let (events, _rx) = watch::channel(slot.method);
470    let active = Arc::new(ArcSwap::from(slot));
471    let handle = tokio::spawn(run_guard(
472        peer_id,
473        Arc::clone(&active),
474        events.clone(),
475        plan,
476        grace_cap,
477        probe_timeout,
478    ));
479    FastPeerConnection {
480        peer_id,
481        active,
482        events,
483        _guard: PromotionGuard { handle },
484    }
485}
486
487/// The background promote/fallback state machine (see the module docs). Runs until the guard is
488/// aborted (the [`FastPeerConnection`] dropped) or no fallback remains after a transport death.
489async fn run_guard(
490    peer_id: PeerId,
491    active: Arc<ArcSwap<TransportSlot>>,
492    events: watch::Sender<TraversalKind>,
493    plan: GuardPlan,
494    grace_cap: Duration,
495    probe_timeout: Duration,
496) {
497    // Phase 1 — promotion: if a direct attempt is pending, await it and (on a passed gate) promote
498    // the relayed connection to it, draining the swapped-out relayed slot.
499    if let Some(promote_from) = plan.promote_from {
500        if let Ok(direct_conn) = promote_from.await {
501            try_promote(
502                peer_id,
503                &active,
504                &events,
505                direct_conn,
506                grace_cap,
507                probe_timeout,
508            )
509            .await;
510        }
511        // A failed direct attempt or a refused gate simply stays on the current (relayed) transport.
512    }
513
514    // Phase 2 — fallback: while the active transport can be re-established, watch it for death and
515    // re-dial on close. On a direct death this re-dials relayed; on a relayed death this reconnects
516    // relayed. With no fallback (a public peer with no relay), the guard exits — a lost direct
517    // connection then simply surfaces as stream errors to the caller.
518    //
519    // A flapping transport (dial-succeeds-then-instantly-dies) would otherwise drive an unbounded
520    // re-dial busy-loop; a capped-exponential backoff paces RAPID successive deaths. A session that
521    // was held STABLY (lived past [`FALLBACK_STABILITY`]) resets the backoff, so an ordinary lone
522    // death still re-dials immediately (the single-re-dial-per-death contract is unchanged).
523    let Some(fallback) = plan.fallback else {
524        return;
525    };
526    let mut rapid_deaths: u32 = 0;
527    loop {
528        let closed = active.load().closed.clone();
529        let established_at = tokio::time::Instant::now();
530        closed.closed().await;
531
532        // Pace only RAPID re-deaths; a stably-held session resets the counter.
533        if established_at.elapsed() >= FALLBACK_STABILITY {
534            rapid_deaths = 0;
535        } else {
536            rapid_deaths = rapid_deaths.saturating_add(1);
537        }
538        let backoff = fallback_backoff(rapid_deaths, FALLBACK_BACKOFF_BASE, FALLBACK_BACKOFF_CAP);
539        if !backoff.is_zero() {
540            tokio::time::sleep(backoff).await;
541        }
542
543        match fallback().await {
544            Ok(conn) => {
545                let slot = TransportSlot::from_conn(conn);
546                let method = slot.method;
547                active.store(slot);
548                let _ = events.send(method);
549            }
550            // Re-establish failed — give up; the caller's next `open_stream` errors.
551            Err(_) => return,
552        }
553    }
554}
555
556/// First delay for the fallback re-dial backoff (the shortest paced wait after a rapid death).
557const FALLBACK_BACKOFF_BASE: Duration = Duration::from_millis(50);
558/// Upper bound on the fallback re-dial backoff — no single wait exceeds this.
559const FALLBACK_BACKOFF_CAP: Duration = Duration::from_secs(5);
560/// A re-established session that lives at least this long is "stable" and resets the backoff, so an
561/// ordinary lone transport death always re-dials immediately.
562const FALLBACK_STABILITY: Duration = Duration::from_secs(10);
563
564/// Capped-exponential fallback re-dial backoff (mirrors the relay reservation loop's
565/// [`crate::relay::backoff_secs`]). `rapid_deaths == 0` → no wait (a lone death re-dials at once);
566/// each additional rapid death doubles the wait, clamped to `cap`. Pure → unit-tested.
567fn fallback_backoff(rapid_deaths: u32, base: Duration, cap: Duration) -> Duration {
568    if rapid_deaths == 0 {
569        return Duration::ZERO;
570    }
571    let base_ms = base.as_millis() as u64;
572    let shifted = base_ms.checked_shl(rapid_deaths - 1).unwrap_or(u64::MAX);
573    Duration::from_millis(shifted).clamp(base, cap)
574}
575
576/// Run the promotion gate over a landed direct connection and, iff it passes, swap the active
577/// transport to it and drain the swapped-out relayed slot. A refused gate leaves the connection
578/// relayed (the `direct_conn` is dropped).
579async fn try_promote(
580    peer_id: PeerId,
581    active: &Arc<ArcSwap<TransportSlot>>,
582    events: &watch::Sender<TraversalKind>,
583    mut direct_conn: PeerConnection,
584    grace_cap: Duration,
585    probe_timeout: Duration,
586) {
587    let relayed_slot = active.load_full();
588
589    // Gate (2) — identity equality: SAME peer_id AND SAME #1204 BLS pubkey as the relayed transport.
590    // This is what makes swapping "to the same peer" safe (SECURITY-CRITICAL); a mismatch is refused.
591    if direct_conn.peer_id != peer_id || direct_conn.peer_bls_pub != relayed_slot.peer_bls_pub {
592        tracing::warn!(
593            "fast-connect: direct path identity mismatch — promotion refused, staying relayed"
594        );
595        return;
596    }
597
598    // Gate (3) — one successful application round-trip (empty availability). Proves real bidirectional
599    // mux traffic; a NAT mapping that completes TLS then blackholes fails here. Never promote on
600    // handshake-completion alone. The probe is BOUNDED by `probe_timeout` (the per-method timeout): a
601    // post-TLS blackhole (TLS completes, mux never answers) would hang the probe forever, so a timeout
602    // is treated as a probe FAILURE and fails closed — no promotion, stay relayed.
603    match tokio::time::timeout(probe_timeout, direct_conn.query_availability(vec![])).await {
604        Ok(Ok(_)) => {}
605        Ok(Err(_)) => {
606            tracing::warn!(
607                "fast-connect: direct path failed the availability probe — staying relayed"
608            );
609            return;
610        }
611        Err(_) => {
612            tracing::warn!(
613                "fast-connect: direct path availability probe timed out — staying relayed"
614            );
615            return;
616        }
617    }
618
619    // Gate passed — swap NEW streams onto the direct transport atomically. In-flight relayed streams
620    // keep running on the relayed slot (they hold its Arc); no live stream migrates.
621    let direct_slot = TransportSlot::from_conn(direct_conn);
622    let method = direct_slot.method;
623    active.store(direct_slot);
624    let _ = events.send(method);
625    tracing::info!(?method, "fast-connect: promoted to a direct transport");
626
627    // Drain + drop the swapped-out relayed slot in the background: hold it until its in-flight streams
628    // finish (or the grace cap elapses), then release it — dropping the relayed session closes ONLY
629    // the per-peer relay tunnel (the persistent reservation is untouched).
630    tokio::spawn(drain_then_drop(relayed_slot, grace_cap));
631}
632
633/// Await the slot's in-flight streams draining to zero, bounded by `grace_cap`, then drop this task's
634/// reference. A still-live stream past the cap keeps the slot alive via its own `Arc` (no truncation);
635/// the cap only bounds how long THIS task waits before releasing its own hold.
636async fn drain_then_drop(slot: Arc<TransportSlot>, grace_cap: Duration) {
637    let deadline = tokio::time::sleep(grace_cap);
638    tokio::pin!(deadline);
639    loop {
640        if slot.outstanding.load(Ordering::Acquire) == 0 {
641            break;
642        }
643        let drained = slot.drained.notified();
644        if slot.outstanding.load(Ordering::Acquire) == 0 {
645            break;
646        }
647        tokio::select! {
648            _ = drained => {}
649            _ = &mut deadline => break,
650        }
651    }
652    drop(slot);
653}
654
655/// Combine the two tiers' failures into one [`NatError::AllMethodsFailed`] preserving each tier's
656/// per-method reasons.
657fn merge_errors(relayed: NatError, direct: NatError) -> NatError {
658    let mut failures = Vec::new();
659    for e in [relayed, direct] {
660        if let NatError::AllMethodsFailed(mut fs) = e {
661            failures.append(&mut fs);
662        }
663    }
664    NatError::AllMethodsFailed(failures)
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670    use std::time::Duration;
671
672    use sha2::{Digest, Sha256};
673    use tokio::io::AsyncWriteExt;
674    use tokio_rustls::TlsAcceptor;
675
676    use crate::method::relayed::ReservationRelayedTransport;
677    use crate::mux::{
678        AvailabilityAnswer, AvailabilityItem, AvailabilityRequest, AvailabilityResponse,
679    };
680    use crate::relay::{loopback_reservation_pair, RelayStatus};
681    use crate::tunnel::RelayTunnelStream;
682    use crate::{BindingPolicy, MethodError};
683    use dig_tls::bls::SecretKey;
684
685    const NET: &str = "DIG_MAINNET";
686    const RELAY_ENDPOINT: &str = "127.0.0.1:3478";
687
688    fn test_bls_sk(label: &str) -> SecretKey {
689        let seed: [u8; 32] = Sha256::digest(label.as_bytes()).into();
690        SecretKey::from_seed(&seed)
691    }
692    fn test_node(label: &str) -> Arc<NodeCert> {
693        Arc::new(NodeCert::generate_signed(&test_bls_sk(label)).expect("generate node cert"))
694    }
695
696    /// Spawn a serving node over an accepted mTLS byte stream: answers every inbound stream as an
697    /// availability query (so both the empty-availability promotion probe AND a real query succeed),
698    /// tagging `total_length` with `tag` so a test can tell WHICH transport served a stream. If
699    /// `kill` is set, the server tears its session down when notified — simulating a transport dying
700    /// (post-promotion direct death), so the client's [`ClosedHandle`] fires and the guard falls back.
701    fn serve_availability<S>(acceptor: TlsAcceptor, stream: S, tag: u64, kill: Option<Arc<Notify>>)
702    where
703        S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
704    {
705        tokio::spawn(async move {
706            let Ok(tls) = acceptor.accept(stream).await else {
707                return;
708            };
709            let mut session = PeerSession::server(tls);
710            loop {
711                let accepted = match &kill {
712                    Some(kill) => tokio::select! {
713                        s = session.accept_stream() => s,
714                        _ = kill.notified() => return, // drop the session → client transport dies
715                    },
716                    None => session.accept_stream().await,
717                };
718                let Some(mut s) = accepted else { return };
719                tokio::spawn(async move {
720                    if let Ok(req) = AvailabilityRequest::decode(&mut s).await {
721                        let resp = AvailabilityResponse {
722                            items: req
723                                .items
724                                .iter()
725                                .map(|_| AvailabilityAnswer {
726                                    available: true,
727                                    roots: None,
728                                    total_length: Some(tag),
729                                    chunk_count: Some(1),
730                                    complete: Some(true),
731                                })
732                                .collect(),
733                        };
734                        let _ = s.write_all(&resp.encode()).await;
735                        let _ = s.shutdown().await;
736                    }
737                });
738            }
739        });
740    }
741
742    /// Spawn a server that completes the mTLS handshake + yamux session but then BLACKHOLES: it never
743    /// accepts an inbound stream, so a client's `query_availability` probe writes its request and then
744    /// hangs forever awaiting a response. The session is held alive (not dropped), so the client's
745    /// [`ClosedHandle`] does NOT fire — this models a NAT mapping that completes TLS then silently
746    /// stops answering at the mux layer (the exact case gate 3 + its timeout must catch).
747    fn serve_blackhole<S>(acceptor: TlsAcceptor, stream: S)
748    where
749        S: AsyncRead + AsyncWrite + Send + Unpin + 'static,
750    {
751        tokio::spawn(async move {
752            let Ok(tls) = acceptor.accept(stream).await else {
753                return;
754            };
755            let _session = PeerSession::server(tls);
756            // Hold the session alive but never accept/answer a stream — blackhole at the mux layer.
757            std::future::pending::<()>().await;
758        });
759    }
760
761    /// A direct [`Establisher`] to `server` (identity matches — same `peer_id` + BLS as a relayed
762    /// establisher to the same node) whose mTLS + yamux come up, but which BLACKHOLES the mux layer
763    /// (never answers the availability probe). Used to prove gate 3 refuses a post-TLS blackhole and
764    /// that the probe is bounded by a timeout rather than hanging forever.
765    fn blackhole_direct_establisher(
766        client: &Arc<NodeCert>,
767        server: &Arc<NodeCert>,
768        delay: Duration,
769    ) -> Establisher {
770        let node = Arc::clone(client);
771        let server = Arc::clone(server);
772        let server_id = server.peer_id();
773        Arc::new(move || {
774            let node = Arc::clone(&node);
775            let server = Arc::clone(&server);
776            Box::pin(async move {
777                if !delay.is_zero() {
778                    tokio::time::sleep(delay).await;
779                }
780                let (client_io, server_io) = tokio::io::duplex(64 * 1024);
781                let server_tls = dig_tls::server_config(&server, BindingPolicy::Opportunistic)
782                    .expect("server config")
783                    .config;
784                serve_blackhole(TlsAcceptor::from(server_tls), server_io);
785
786                let client_cfg =
787                    dig_tls::client_config(&node, Some(server_id), BindingPolicy::Opportunistic)
788                        .expect("client config");
789                let captured = client_cfg.captured_peer_id;
790                let captured_bls = client_cfg.captured_bls;
791                let connector = tokio_rustls::TlsConnector::from(client_cfg.config);
792                let sni = rustls_pki_types::ServerName::try_from("peer.dig.invalid").unwrap();
793                let tls = connector.connect(sni, client_io).await.map_err(|e| {
794                    NatError::AllMethodsFailed(vec![MethodError::failed(
795                        TraversalKind::Direct,
796                        format!("mtls handshake: {e}"),
797                    )])
798                })?;
799                let verified = captured.get().expect("peer presented a cert");
800                Ok(PeerConnection {
801                    peer_id: verified,
802                    method: TraversalKind::Direct,
803                    remote_addr: "203.0.113.9:4444".parse().unwrap(),
804                    peer_bls_pub: captured_bls.get(),
805                    session: PeerSession::client(tls),
806                })
807            })
808        })
809    }
810
811    /// A direct [`Establisher`] to `server` whose `peer_id` MATCHES the expected peer but whose
812    /// `peer_bls_pub` is OVERWRITTEN to a value that differs from the real (relayed) slot's BLS. This
813    /// isolates the BLS-equality leg of gate 2 (existing test 6 differs in BOTH peer_id and BLS, so it
814    /// cannot catch a dropped BLS clause).
815    fn mismatched_bls_establisher(
816        client: &Arc<NodeCert>,
817        server: &Arc<NodeCert>,
818        tag: u64,
819        delay: Duration,
820    ) -> Establisher {
821        let inner = direct_establisher(client, server, tag, delay);
822        Arc::new(move || {
823            let inner = Arc::clone(&inner);
824            Box::pin(async move {
825                let mut conn = inner().await?;
826                // Same peer_id as the relayed slot, but a deliberately different BLS pubkey.
827                conn.peer_bls_pub = Some([0xAB; 48]);
828                Ok(conn)
829            })
830        })
831    }
832
833    /// A relayed [`Establisher`] over a loopback relay reservation to a server serving with `tag`.
834    /// Returns the client's reservation handle too (tests read its tunnel registry).
835    fn relayed_establisher(
836        client: &Arc<NodeCert>,
837        server: &Arc<NodeCert>,
838        tag: u64,
839    ) -> (Establisher, Arc<RelayStatus>) {
840        let client_hex = client.peer_id().to_hex();
841        let server_hex = server.peer_id().to_hex();
842        let (client_status, server_status) = loopback_reservation_pair(&client_hex, &server_hex);
843
844        // Server side: accept the tunnel + serve availability.
845        let server_tunnel = server_status
846            .open_tunnel(&client_hex, NET)
847            .expect("server opens relay tunnel");
848        let server_tls = dig_tls::server_config(server, BindingPolicy::Opportunistic)
849            .expect("server config")
850            .config;
851        serve_availability(
852            TlsAcceptor::from(server_tls),
853            RelayTunnelStream::new(server_tunnel),
854            tag,
855            None,
856        );
857
858        let server_id = server.peer_id();
859        let node = Arc::clone(client);
860        let transport = Arc::new(ReservationRelayedTransport::new(
861            Arc::clone(&client_status),
862            RELAY_ENDPOINT.parse().unwrap(),
863        ));
864        let est: Establisher = Arc::new(move || {
865            let dialer = MtlsDialer::new(Arc::clone(&node))
866                .with_binding_policy(BindingPolicy::Opportunistic)
867                .with_relayed_dialer(Arc::clone(&transport) as Arc<_>);
868            Box::pin(async move {
869                let peer = PeerTarget::relay_only(server_id, NET);
870                let outcome =
871                    MethodOutcome::single(TraversalKind::Relayed, RELAY_ENDPOINT.parse().unwrap());
872                dialer
873                    .dial(&peer, &outcome)
874                    .await
875                    .map_err(|e| NatError::AllMethodsFailed(vec![e]))
876            })
877        });
878        (est, client_status)
879    }
880
881    /// An [`Establisher`] over an in-memory duplex byte stream (mTLS + yamux, no network) reporting
882    /// `method`, serving with `tag`. `delay` lets a test make one path land AFTER another so the
883    /// promotion race is deterministic; `kill` (if set) lets a test tear the server down to simulate
884    /// a transport death. Reusable — each call spins a fresh duplex + server (so it can serve as the
885    /// fallback path re-dialed after a death).
886    fn duplex_establisher(
887        client: &Arc<NodeCert>,
888        server: &Arc<NodeCert>,
889        method: TraversalKind,
890        tag: u64,
891        delay: Duration,
892        kill: Option<Arc<Notify>>,
893    ) -> Establisher {
894        let node = Arc::clone(client);
895        let server = Arc::clone(server);
896        let server_id = server.peer_id();
897        Arc::new(move || {
898            let node = Arc::clone(&node);
899            let server = Arc::clone(&server);
900            let kill = kill.clone();
901            Box::pin(async move {
902                if !delay.is_zero() {
903                    tokio::time::sleep(delay).await;
904                }
905                let (client_io, server_io) = tokio::io::duplex(64 * 1024);
906                // Server side: accept mTLS over the duplex + serve availability.
907                let server_tls = dig_tls::server_config(&server, BindingPolicy::Opportunistic)
908                    .expect("server config")
909                    .config;
910                serve_availability(TlsAcceptor::from(server_tls), server_io, tag, kill);
911
912                // Client side: run the client mTLS handshake over the duplex, pinning server_id.
913                let client_cfg =
914                    dig_tls::client_config(&node, Some(server_id), BindingPolicy::Opportunistic)
915                        .expect("client config");
916                let captured = client_cfg.captured_peer_id;
917                let captured_bls = client_cfg.captured_bls;
918                let connector = tokio_rustls::TlsConnector::from(client_cfg.config);
919                let sni = rustls_pki_types::ServerName::try_from("peer.dig.invalid").unwrap();
920                let tls = connector.connect(sni, client_io).await.map_err(|e| {
921                    NatError::AllMethodsFailed(vec![MethodError::failed(
922                        method,
923                        format!("mtls handshake: {e}"),
924                    )])
925                })?;
926                let verified = captured.get().expect("peer presented a cert");
927                Ok(PeerConnection {
928                    peer_id: verified,
929                    method,
930                    remote_addr: "203.0.113.9:4444".parse().unwrap(),
931                    peer_bls_pub: captured_bls.get(),
932                    session: PeerSession::client(tls),
933                })
934            })
935        })
936    }
937
938    /// A direct duplex [`Establisher`] (the common case): reports [`TraversalKind::Direct`].
939    fn direct_establisher(
940        client: &Arc<NodeCert>,
941        server: &Arc<NodeCert>,
942        tag: u64,
943        delay: Duration,
944    ) -> Establisher {
945        duplex_establisher(client, server, TraversalKind::Direct, tag, delay, None)
946    }
947
948    /// (1) First-usable latency: a slow direct fake means `connect_fast` returns BEFORE direct
949    /// completes, relayed-active.
950    #[tokio::test]
951    async fn returns_first_usable_relayed_before_slow_direct() {
952        let client = test_node("fc/1/client");
953        let server = test_node("fc/1/server");
954        let (relayed, _status) = relayed_establisher(&client, &server, 11);
955        let direct = direct_establisher(&client, &server, 22, Duration::from_secs(30));
956
957        let conn = tokio::time::timeout(
958            Duration::from_secs(5),
959            connect_fast_with(
960                server.peer_id(),
961                direct,
962                Some(relayed),
963                Duration::from_millis(200),
964                Duration::from_secs(5),
965            ),
966        )
967        .await
968        .expect("connect_fast returns before the slow direct completes")
969        .expect("relayed lands first");
970
971        assert_eq!(conn.current_method(), TraversalKind::Relayed);
972        assert_eq!(conn.peer_id(), server.peer_id());
973        assert_eq!(conn.remote_addr(), RELAY_ENDPOINT.parse().unwrap());
974        assert!(format!("{conn:?}").contains("FastPeerConnection"));
975        // Served over the relayed transport (tag 11).
976        let resp = conn.query_availability(vec![avail_item()]).await.unwrap();
977        assert_eq!(resp.items[0].total_length, Some(11));
978        // A range stream also opens over the current transport (exercises open_range_stream).
979        let _range = conn
980            .open_range_stream(&RangeRequest::resource(
981                "aa".repeat(32),
982                "cc".repeat(32),
983                0,
984                8,
985            ))
986            .await
987            .unwrap();
988    }
989
990    /// The public [`connect_fast`] entry, direct-only (no relay wired) with NO methods enabled, fails
991    /// cleanly with [`NatError::NoMethodsEnabled`] — exercising `connect_fast` + `compose_ladder` +
992    /// the no-relay branch without any network.
993    #[tokio::test]
994    async fn connect_fast_direct_only_with_no_methods_errors() {
995        let client = test_node("fc/pub/client");
996        let peer = PeerTarget::relay_only(test_node("fc/pub/server").peer_id(), NET);
997        let config = NatConfig::builder().enabled_methods(vec![]).build();
998        let runtime = NatRuntime::default(); // no relay data-plane
999        let err = connect_fast(&peer, &client, &config, &runtime)
1000            .await
1001            .unwrap_err();
1002        assert!(matches!(err, NatError::NoMethodsEnabled));
1003    }
1004
1005    /// (2) Seamless promotion + zero loss: hold a relayed stream mid-transfer, let the slow direct
1006    /// land + pass the empty-availability probe, assert the method flips to Direct, a NEW stream is
1007    /// served by the direct fake, and the PRE-promotion relayed stream still completes over relayed.
1008    #[tokio::test]
1009    async fn promotes_to_direct_without_losing_inflight_relayed_stream() {
1010        let client = test_node("fc/2/client");
1011        let server = test_node("fc/2/server");
1012        let (relayed, _status) = relayed_establisher(&client, &server, 11);
1013        let direct = direct_establisher(&client, &server, 22, Duration::from_millis(150));
1014
1015        let conn = connect_fast_with(
1016            server.peer_id(),
1017            direct,
1018            Some(relayed),
1019            Duration::from_secs(5),
1020            Duration::from_secs(5),
1021        )
1022        .await
1023        .expect("relayed lands first");
1024        assert_eq!(conn.current_method(), TraversalKind::Relayed);
1025
1026        // Open a relayed stream and write the request BEFORE promotion, but DON'T read the response
1027        // yet — keep the stream open across the promotion boundary.
1028        let mut pre = conn.open_stream().await.unwrap();
1029        pre.write_all(
1030            &AvailabilityRequest {
1031                items: vec![avail_item()],
1032            }
1033            .encode(),
1034        )
1035        .await
1036        .unwrap();
1037        pre.flush().await.unwrap();
1038
1039        // Wait for the promotion to Direct.
1040        let mut rx = conn.subscribe();
1041        tokio::time::timeout(Duration::from_secs(5), async {
1042            while *rx.borrow_and_update() != TraversalKind::Direct {
1043                rx.changed().await.unwrap();
1044            }
1045        })
1046        .await
1047        .expect("promoted to Direct");
1048        assert_eq!(conn.current_method(), TraversalKind::Direct);
1049
1050        // The PRE-promotion stream still completes over the RELAYED transport (tag 11) — no loss,
1051        // no migration: an in-flight stream finishes on the transport it started on.
1052        let pre_resp = AvailabilityResponse::decode(&mut pre).await.unwrap();
1053        assert_eq!(
1054            pre_resp.items[0].total_length,
1055            Some(11),
1056            "in-flight stream stayed relayed"
1057        );
1058
1059        // A NEW stream is now served by the DIRECT fake (tag 22).
1060        let post = conn.query_availability(vec![avail_item()]).await.unwrap();
1061        assert_eq!(post.items[0].total_length, Some(22), "new stream is direct");
1062    }
1063
1064    /// (3) Teardown: after promotion + drain, the peer is gone from the relay reservation's tunnel
1065    /// registry, while the reservation itself stays Connected (only the per-peer tunnel is released).
1066    #[tokio::test]
1067    async fn drains_and_releases_relay_tunnel_after_promotion() {
1068        let client = test_node("fc/3/client");
1069        let server = test_node("fc/3/server");
1070        let (relayed, status) = relayed_establisher(&client, &server, 11);
1071        let direct = direct_establisher(&client, &server, 22, Duration::from_millis(100));
1072        let server_hex = server.peer_id().to_hex();
1073
1074        let conn = connect_fast_with(
1075            server.peer_id(),
1076            direct,
1077            Some(relayed),
1078            Duration::from_millis(100),
1079            Duration::from_secs(5),
1080        )
1081        .await
1082        .unwrap();
1083
1084        // Await promotion, then let the (short) drain elapse.
1085        let mut rx = conn.subscribe();
1086        tokio::time::timeout(Duration::from_secs(5), async {
1087            while *rx.borrow_and_update() != TraversalKind::Direct {
1088                rx.changed().await.unwrap();
1089            }
1090        })
1091        .await
1092        .expect("promoted");
1093        // Give the background drain task time to release the tunnel (grace cap = 100ms).
1094        wait_until(Duration::from_secs(3), || {
1095            !status.open_tunnel_exists(&server_hex)
1096        })
1097        .await;
1098
1099        assert!(
1100            !status.open_tunnel_exists(&server_hex),
1101            "per-peer relay tunnel released after promotion+drain"
1102        );
1103        assert!(
1104            status.is_connected(),
1105            "the relay reservation stays Connected"
1106        );
1107    }
1108
1109    /// (4) Direct never lands: `connect_fast` stays relayed, usable, with the reservation intact.
1110    #[tokio::test]
1111    async fn stays_relayed_when_direct_never_lands() {
1112        let client = test_node("fc/4/client");
1113        let server = test_node("fc/4/server");
1114        let (relayed, status) = relayed_establisher(&client, &server, 11);
1115        // A direct establisher that always fails.
1116        let direct: Establisher = Arc::new(|| {
1117            Box::pin(async {
1118                Err(NatError::AllMethodsFailed(vec![MethodError::failed(
1119                    TraversalKind::Direct,
1120                    "no direct path",
1121                )]))
1122            })
1123        });
1124
1125        let conn = connect_fast_with(
1126            server.peer_id(),
1127            direct,
1128            Some(relayed),
1129            Duration::from_millis(200),
1130            Duration::from_secs(5),
1131        )
1132        .await
1133        .unwrap();
1134
1135        // Give the guard a moment; it must NOT promote.
1136        tokio::time::sleep(Duration::from_millis(200)).await;
1137        assert_eq!(conn.current_method(), TraversalKind::Relayed);
1138        let resp = conn.query_availability(vec![avail_item()]).await.unwrap();
1139        assert_eq!(resp.items[0].total_length, Some(11));
1140        assert!(status.is_connected());
1141    }
1142
1143    /// (6) Identity-mismatch guard: a direct fake returning a DIFFERENT peer's cert/BLS is REFUSED —
1144    /// the connection stays relayed. (SECURITY-CRITICAL: the identity-equality invariant.)
1145    #[tokio::test]
1146    async fn refuses_promotion_on_identity_mismatch() {
1147        let client = test_node("fc/6/client");
1148        let server = test_node("fc/6/server");
1149        let impostor = test_node("fc/6/impostor");
1150        let (relayed, _status) = relayed_establisher(&client, &server, 11);
1151        // The direct fake pins + serves the IMPOSTOR identity (a different peer_id + BLS).
1152        let direct = direct_establisher(&client, &impostor, 22, Duration::from_millis(100));
1153
1154        let conn = connect_fast_with(
1155            server.peer_id(),
1156            direct,
1157            Some(relayed),
1158            Duration::from_millis(200),
1159            Duration::from_secs(5),
1160        )
1161        .await
1162        .unwrap();
1163
1164        // The direct path lands but its identity != the relayed peer's → promotion refused.
1165        tokio::time::sleep(Duration::from_millis(400)).await;
1166        assert_eq!(
1167            conn.current_method(),
1168            TraversalKind::Relayed,
1169            "promotion refused on identity mismatch"
1170        );
1171        let resp = conn.query_availability(vec![avail_item()]).await.unwrap();
1172        assert_eq!(resp.items[0].total_length, Some(11), "still relayed");
1173    }
1174
1175    /// (5) Post-promotion direct death → fallback: after promoting to direct, killing the direct
1176    /// transport makes the guard fall back — the method flips back to Relayed and a fresh
1177    /// `open_stream` succeeds over the re-dialed transport. (Uses duplex establishers for BOTH roles
1178    /// so the reusable fallback can be re-dialed; the relayed-role establisher reports Relayed.)
1179    #[tokio::test]
1180    async fn falls_back_to_relayed_when_promoted_direct_dies() {
1181        let client = test_node("fc/5/client");
1182        let server = test_node("fc/5/server");
1183        // Relayed-role establisher (reusable — a fresh duplex server per call), reports Relayed/tag 11.
1184        let relayed = duplex_establisher(
1185            &client,
1186            &server,
1187            TraversalKind::Relayed,
1188            11,
1189            Duration::ZERO,
1190            None,
1191        );
1192        // Direct-role establisher (tag 22) that lands after a delay and can be KILLED.
1193        let kill = Arc::new(Notify::new());
1194        let direct = duplex_establisher(
1195            &client,
1196            &server,
1197            TraversalKind::Direct,
1198            22,
1199            Duration::from_millis(120),
1200            Some(Arc::clone(&kill)),
1201        );
1202
1203        let conn = connect_fast_with(
1204            server.peer_id(),
1205            direct,
1206            Some(relayed),
1207            Duration::from_millis(100),
1208            Duration::from_secs(5),
1209        )
1210        .await
1211        .unwrap();
1212
1213        // Wait for the promotion to Direct.
1214        let mut rx = conn.subscribe();
1215        tokio::time::timeout(Duration::from_secs(5), async {
1216            while *rx.borrow_and_update() != TraversalKind::Direct {
1217                rx.changed().await.unwrap();
1218            }
1219        })
1220        .await
1221        .expect("promoted to Direct");
1222        let post = conn.query_availability(vec![avail_item()]).await.unwrap();
1223        assert_eq!(post.items[0].total_length, Some(22), "served over direct");
1224
1225        // Kill the direct transport → the guard must fall back to Relayed.
1226        kill.notify_waiters();
1227        tokio::time::timeout(Duration::from_secs(5), async {
1228            while *rx.borrow_and_update() != TraversalKind::Relayed {
1229                rx.changed().await.unwrap();
1230            }
1231        })
1232        .await
1233        .expect("fell back to Relayed after direct death");
1234        assert_eq!(conn.current_method(), TraversalKind::Relayed);
1235
1236        // A fresh stream now succeeds over the re-dialed relayed transport (tag 11).
1237        let after = conn.query_availability(vec![avail_item()]).await.unwrap();
1238        assert_eq!(after.items[0].total_length, Some(11), "re-dialed relayed");
1239    }
1240
1241    /// (7) Gate-3 REFUSES a post-TLS blackhole, BOUNDED by the probe timeout. A direct fake whose
1242    /// mTLS + identity match the relayed peer but which then blackholes the mux layer must NOT be
1243    /// promoted — the empty-availability probe hangs, the `probe_timeout` fires, and the connection
1244    /// stays relayed (fail-closed). SECURITY-CRITICAL (gate 3).
1245    ///
1246    /// Red-verify: deleting the gate-3 probe block in `try_promote` (the
1247    /// `tokio::time::timeout(probe_timeout, direct_conn.query_availability(vec![]))` match) makes this
1248    /// test FAIL — with no probe the blackhole peer promotes to Direct.
1249    #[tokio::test]
1250    async fn refuses_promotion_to_a_post_tls_blackhole() {
1251        let client = test_node("fc/7/client");
1252        let server = test_node("fc/7/server");
1253        let (relayed, _status) = relayed_establisher(&client, &server, 11);
1254        // Direct fake: SAME identity (server node) but blackholes the mux after TLS.
1255        let direct = blackhole_direct_establisher(&client, &server, Duration::from_millis(100));
1256
1257        let conn = connect_fast_with(
1258            server.peer_id(),
1259            direct,
1260            Some(relayed),
1261            Duration::from_millis(200), // grace
1262            Duration::from_millis(300), // probe_timeout — bounds the blackhole probe
1263        )
1264        .await
1265        .unwrap();
1266        assert_eq!(conn.current_method(), TraversalKind::Relayed);
1267
1268        // Allow the direct fake to land + the (bounded) probe to time out and be refused.
1269        tokio::time::sleep(Duration::from_millis(700)).await;
1270        assert_eq!(
1271            conn.current_method(),
1272            TraversalKind::Relayed,
1273            "post-TLS blackhole refused — probe timed out, stays relayed"
1274        );
1275        let resp = conn.query_availability(vec![avail_item()]).await.unwrap();
1276        assert_eq!(resp.items[0].total_length, Some(11), "still relayed");
1277    }
1278
1279    /// (8) Gate-2 BLS leg REFUSES same-peer_id / different-BLS. A direct fake whose `peer_id` matches
1280    /// the relayed peer but whose BLS pubkey differs must be refused. Isolates the BLS clause (test 6
1281    /// differs in BOTH peer_id and BLS, so it cannot catch a dropped BLS clause). SECURITY-CRITICAL.
1282    ///
1283    /// Red-verify: removing `|| direct_conn.peer_bls_pub != relayed_slot.peer_bls_pub` from gate 2
1284    /// makes this test FAIL — peer_id alone matches, so the different-BLS peer would promote to Direct.
1285    #[tokio::test]
1286    async fn refuses_promotion_on_bls_mismatch_same_peer_id() {
1287        let client = test_node("fc/8/client");
1288        let server = test_node("fc/8/server");
1289        let (relayed, _status) = relayed_establisher(&client, &server, 11);
1290        // Direct fake: peer_id == server (matches expected), but BLS pubkey overwritten to differ.
1291        let direct = mismatched_bls_establisher(&client, &server, 22, Duration::from_millis(100));
1292
1293        let conn = connect_fast_with(
1294            server.peer_id(),
1295            direct,
1296            Some(relayed),
1297            Duration::from_millis(200),
1298            Duration::from_secs(5),
1299        )
1300        .await
1301        .unwrap();
1302
1303        // The direct path lands with a matching peer_id but a mismatched BLS → gate 2 refuses.
1304        tokio::time::sleep(Duration::from_millis(400)).await;
1305        assert_eq!(
1306            conn.current_method(),
1307            TraversalKind::Relayed,
1308            "promotion refused on BLS mismatch despite matching peer_id"
1309        );
1310        let resp = conn.query_availability(vec![avail_item()]).await.unwrap();
1311        assert_eq!(resp.items[0].total_length, Some(11), "still relayed");
1312    }
1313
1314    /// (9) An in-flight relayed stream SURVIVES a short grace cap. With a sub-millisecond
1315    /// `fast_connect_grace`, the post-promotion drain task releases its own hold as soon as the cap
1316    /// elapses — but a stream held across the promotion boundary keeps the relayed slot alive via its
1317    /// OWN `Arc<TransportSlot>`, so it still completes (no truncation on cap).
1318    ///
1319    /// Red-verify: a hypothetical drain that TRUNCATED the slot on cap (e.g. forcibly closed the
1320    /// relayed session when `deadline` fires instead of only dropping its own reference) would make the
1321    /// held `pre` stream's `decode` fail — this assertion (the held stream still yields tag 11) guards
1322    /// against that regression.
1323    #[tokio::test]
1324    async fn inflight_relayed_stream_survives_short_grace_cap() {
1325        let client = test_node("fc/9/client");
1326        let server = test_node("fc/9/server");
1327        let (relayed, _status) = relayed_establisher(&client, &server, 11);
1328        let direct = direct_establisher(&client, &server, 22, Duration::from_millis(150));
1329
1330        let conn = connect_fast_with(
1331            server.peer_id(),
1332            direct,
1333            Some(relayed),
1334            Duration::from_millis(1), // tiny grace — the drain cap fires almost immediately
1335            Duration::from_secs(5),
1336        )
1337        .await
1338        .expect("relayed lands first");
1339        assert_eq!(conn.current_method(), TraversalKind::Relayed);
1340
1341        // Open a relayed stream + write the request BEFORE promotion; hold it open across the boundary.
1342        let mut pre = conn.open_stream().await.unwrap();
1343        pre.write_all(
1344            &AvailabilityRequest {
1345                items: vec![avail_item()],
1346            }
1347            .encode(),
1348        )
1349        .await
1350        .unwrap();
1351        pre.flush().await.unwrap();
1352
1353        // Wait for promotion to Direct (the drain task then fires its ~1ms cap immediately).
1354        let mut rx = conn.subscribe();
1355        tokio::time::timeout(Duration::from_secs(5), async {
1356            while *rx.borrow_and_update() != TraversalKind::Direct {
1357                rx.changed().await.unwrap();
1358            }
1359        })
1360        .await
1361        .expect("promoted to Direct");
1362        // Let the grace cap elapse + the drain task run to completion.
1363        tokio::time::sleep(Duration::from_millis(200)).await;
1364
1365        // The held stream STILL completes over the relayed transport (tag 11) — its Arc kept the slot
1366        // alive past the grace cap; the cap only bounded the drain task's own hold, not the stream.
1367        let pre_resp = AvailabilityResponse::decode(&mut pre).await.unwrap();
1368        assert_eq!(
1369            pre_resp.items[0].total_length,
1370            Some(11),
1371            "in-flight relayed stream survived the short grace cap"
1372        );
1373    }
1374
1375    #[test]
1376    fn fallback_backoff_is_zero_for_a_lone_death_then_capped_exponential() {
1377        let base = Duration::from_millis(50);
1378        let cap = Duration::from_secs(5);
1379        // A lone death (counter 0) re-dials immediately.
1380        assert_eq!(fallback_backoff(0, base, cap), Duration::ZERO);
1381        // Rapid re-deaths back off exponentially from the base.
1382        assert_eq!(fallback_backoff(1, base, cap), Duration::from_millis(50));
1383        assert_eq!(fallback_backoff(2, base, cap), Duration::from_millis(100));
1384        assert_eq!(fallback_backoff(3, base, cap), Duration::from_millis(200));
1385        // Clamped to the cap and never overflows for a large death count.
1386        assert_eq!(fallback_backoff(20, base, cap), cap);
1387        assert_eq!(fallback_backoff(u32::MAX, base, cap), cap);
1388    }
1389
1390    fn avail_item() -> AvailabilityItem {
1391        AvailabilityItem {
1392            store_id: "bb".repeat(32),
1393            root: None,
1394            retrieval_key: None,
1395        }
1396    }
1397
1398    /// Poll `cond` until true or `budget` elapses (a small helper for background-task settling).
1399    async fn wait_until(budget: Duration, mut cond: impl FnMut() -> bool) {
1400        let deadline = tokio::time::Instant::now() + budget;
1401        while tokio::time::Instant::now() < deadline {
1402            if cond() {
1403                return;
1404            }
1405            tokio::time::sleep(Duration::from_millis(10)).await;
1406        }
1407    }
1408}