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. Uses the SERVER-role opener so an
845 // incoming ClientHello routes straight to the server (not treated as #1536 glare).
846 let server_tunnel = server_status.open_server_tunnel(&client_hex, NET);
847 let server_tls = dig_tls::server_config(server, BindingPolicy::Opportunistic)
848 .expect("server config")
849 .config;
850 serve_availability(
851 TlsAcceptor::from(server_tls),
852 RelayTunnelStream::new(server_tunnel),
853 tag,
854 None,
855 );
856
857 let server_id = server.peer_id();
858 let node = Arc::clone(client);
859 let transport = Arc::new(ReservationRelayedTransport::new(
860 Arc::clone(&client_status),
861 RELAY_ENDPOINT.parse().unwrap(),
862 ));
863 let est: Establisher = Arc::new(move || {
864 let dialer = MtlsDialer::new(Arc::clone(&node))
865 .with_binding_policy(BindingPolicy::Opportunistic)
866 .with_relayed_dialer(Arc::clone(&transport) as Arc<_>);
867 Box::pin(async move {
868 let peer = PeerTarget::relay_only(server_id, NET);
869 let outcome =
870 MethodOutcome::single(TraversalKind::Relayed, RELAY_ENDPOINT.parse().unwrap());
871 dialer
872 .dial(&peer, &outcome)
873 .await
874 .map_err(|e| NatError::AllMethodsFailed(vec![e]))
875 })
876 });
877 (est, client_status)
878 }
879
880 /// An [`Establisher`] over an in-memory duplex byte stream (mTLS + yamux, no network) reporting
881 /// `method`, serving with `tag`. `delay` lets a test make one path land AFTER another so the
882 /// promotion race is deterministic; `kill` (if set) lets a test tear the server down to simulate
883 /// a transport death. Reusable — each call spins a fresh duplex + server (so it can serve as the
884 /// fallback path re-dialed after a death).
885 fn duplex_establisher(
886 client: &Arc<NodeCert>,
887 server: &Arc<NodeCert>,
888 method: TraversalKind,
889 tag: u64,
890 delay: Duration,
891 kill: Option<Arc<Notify>>,
892 ) -> Establisher {
893 let node = Arc::clone(client);
894 let server = Arc::clone(server);
895 let server_id = server.peer_id();
896 Arc::new(move || {
897 let node = Arc::clone(&node);
898 let server = Arc::clone(&server);
899 let kill = kill.clone();
900 Box::pin(async move {
901 if !delay.is_zero() {
902 tokio::time::sleep(delay).await;
903 }
904 let (client_io, server_io) = tokio::io::duplex(64 * 1024);
905 // Server side: accept mTLS over the duplex + serve availability.
906 let server_tls = dig_tls::server_config(&server, BindingPolicy::Opportunistic)
907 .expect("server config")
908 .config;
909 serve_availability(TlsAcceptor::from(server_tls), server_io, tag, kill);
910
911 // Client side: run the client mTLS handshake over the duplex, pinning server_id.
912 let client_cfg =
913 dig_tls::client_config(&node, Some(server_id), BindingPolicy::Opportunistic)
914 .expect("client config");
915 let captured = client_cfg.captured_peer_id;
916 let captured_bls = client_cfg.captured_bls;
917 let connector = tokio_rustls::TlsConnector::from(client_cfg.config);
918 let sni = rustls_pki_types::ServerName::try_from("peer.dig.invalid").unwrap();
919 let tls = connector.connect(sni, client_io).await.map_err(|e| {
920 NatError::AllMethodsFailed(vec![MethodError::failed(
921 method,
922 format!("mtls handshake: {e}"),
923 )])
924 })?;
925 let verified = captured.get().expect("peer presented a cert");
926 Ok(PeerConnection {
927 peer_id: verified,
928 method,
929 remote_addr: "203.0.113.9:4444".parse().unwrap(),
930 peer_bls_pub: captured_bls.get(),
931 session: PeerSession::client(tls),
932 })
933 })
934 })
935 }
936
937 /// A direct duplex [`Establisher`] (the common case): reports [`TraversalKind::Direct`].
938 fn direct_establisher(
939 client: &Arc<NodeCert>,
940 server: &Arc<NodeCert>,
941 tag: u64,
942 delay: Duration,
943 ) -> Establisher {
944 duplex_establisher(client, server, TraversalKind::Direct, tag, delay, None)
945 }
946
947 /// (1) First-usable latency: a slow direct fake means `connect_fast` returns BEFORE direct
948 /// completes, relayed-active.
949 #[tokio::test]
950 async fn returns_first_usable_relayed_before_slow_direct() {
951 let client = test_node("fc/1/client");
952 let server = test_node("fc/1/server");
953 let (relayed, _status) = relayed_establisher(&client, &server, 11);
954 let direct = direct_establisher(&client, &server, 22, Duration::from_secs(30));
955
956 let conn = tokio::time::timeout(
957 Duration::from_secs(5),
958 connect_fast_with(
959 server.peer_id(),
960 direct,
961 Some(relayed),
962 Duration::from_millis(200),
963 Duration::from_secs(5),
964 ),
965 )
966 .await
967 .expect("connect_fast returns before the slow direct completes")
968 .expect("relayed lands first");
969
970 assert_eq!(conn.current_method(), TraversalKind::Relayed);
971 assert_eq!(conn.peer_id(), server.peer_id());
972 assert_eq!(conn.remote_addr(), RELAY_ENDPOINT.parse().unwrap());
973 assert!(format!("{conn:?}").contains("FastPeerConnection"));
974 // Served over the relayed transport (tag 11).
975 let resp = conn.query_availability(vec![avail_item()]).await.unwrap();
976 assert_eq!(resp.items[0].total_length, Some(11));
977 // A range stream also opens over the current transport (exercises open_range_stream).
978 let _range = conn
979 .open_range_stream(&RangeRequest::resource(
980 "aa".repeat(32),
981 "cc".repeat(32),
982 0,
983 8,
984 ))
985 .await
986 .unwrap();
987 }
988
989 /// The public [`connect_fast`] entry, direct-only (no relay wired) with NO methods enabled, fails
990 /// cleanly with [`NatError::NoMethodsEnabled`] — exercising `connect_fast` + `compose_ladder` +
991 /// the no-relay branch without any network.
992 #[tokio::test]
993 async fn connect_fast_direct_only_with_no_methods_errors() {
994 let client = test_node("fc/pub/client");
995 let peer = PeerTarget::relay_only(test_node("fc/pub/server").peer_id(), NET);
996 let config = NatConfig::builder().enabled_methods(vec![]).build();
997 let runtime = NatRuntime::default(); // no relay data-plane
998 let err = connect_fast(&peer, &client, &config, &runtime)
999 .await
1000 .unwrap_err();
1001 assert!(matches!(err, NatError::NoMethodsEnabled));
1002 }
1003
1004 /// (2) Seamless promotion + zero loss: hold a relayed stream mid-transfer, let the slow direct
1005 /// land + pass the empty-availability probe, assert the method flips to Direct, a NEW stream is
1006 /// served by the direct fake, and the PRE-promotion relayed stream still completes over relayed.
1007 #[tokio::test]
1008 async fn promotes_to_direct_without_losing_inflight_relayed_stream() {
1009 let client = test_node("fc/2/client");
1010 let server = test_node("fc/2/server");
1011 let (relayed, _status) = relayed_establisher(&client, &server, 11);
1012 let direct = direct_establisher(&client, &server, 22, Duration::from_millis(150));
1013
1014 let conn = connect_fast_with(
1015 server.peer_id(),
1016 direct,
1017 Some(relayed),
1018 Duration::from_secs(5),
1019 Duration::from_secs(5),
1020 )
1021 .await
1022 .expect("relayed lands first");
1023 assert_eq!(conn.current_method(), TraversalKind::Relayed);
1024
1025 // Open a relayed stream and write the request BEFORE promotion, but DON'T read the response
1026 // yet — keep the stream open across the promotion boundary.
1027 let mut pre = conn.open_stream().await.unwrap();
1028 pre.write_all(
1029 &AvailabilityRequest {
1030 items: vec![avail_item()],
1031 }
1032 .encode(),
1033 )
1034 .await
1035 .unwrap();
1036 pre.flush().await.unwrap();
1037
1038 // Wait for the promotion to Direct.
1039 let mut rx = conn.subscribe();
1040 tokio::time::timeout(Duration::from_secs(5), async {
1041 while *rx.borrow_and_update() != TraversalKind::Direct {
1042 rx.changed().await.unwrap();
1043 }
1044 })
1045 .await
1046 .expect("promoted to Direct");
1047 assert_eq!(conn.current_method(), TraversalKind::Direct);
1048
1049 // The PRE-promotion stream still completes over the RELAYED transport (tag 11) — no loss,
1050 // no migration: an in-flight stream finishes on the transport it started on.
1051 let pre_resp = AvailabilityResponse::decode(&mut pre).await.unwrap();
1052 assert_eq!(
1053 pre_resp.items[0].total_length,
1054 Some(11),
1055 "in-flight stream stayed relayed"
1056 );
1057
1058 // A NEW stream is now served by the DIRECT fake (tag 22).
1059 let post = conn.query_availability(vec![avail_item()]).await.unwrap();
1060 assert_eq!(post.items[0].total_length, Some(22), "new stream is direct");
1061 }
1062
1063 /// (3) Teardown: after promotion + drain, the peer is gone from the relay reservation's tunnel
1064 /// registry, while the reservation itself stays Connected (only the per-peer tunnel is released).
1065 #[tokio::test]
1066 async fn drains_and_releases_relay_tunnel_after_promotion() {
1067 let client = test_node("fc/3/client");
1068 let server = test_node("fc/3/server");
1069 let (relayed, status) = relayed_establisher(&client, &server, 11);
1070 let direct = direct_establisher(&client, &server, 22, Duration::from_millis(100));
1071 let server_hex = server.peer_id().to_hex();
1072
1073 let conn = connect_fast_with(
1074 server.peer_id(),
1075 direct,
1076 Some(relayed),
1077 Duration::from_millis(100),
1078 Duration::from_secs(5),
1079 )
1080 .await
1081 .unwrap();
1082
1083 // Await promotion, then let the (short) drain elapse.
1084 let mut rx = conn.subscribe();
1085 tokio::time::timeout(Duration::from_secs(5), async {
1086 while *rx.borrow_and_update() != TraversalKind::Direct {
1087 rx.changed().await.unwrap();
1088 }
1089 })
1090 .await
1091 .expect("promoted");
1092 // Give the background drain task time to release the tunnel (grace cap = 100ms).
1093 wait_until(Duration::from_secs(3), || {
1094 !status.open_tunnel_exists(&server_hex)
1095 })
1096 .await;
1097
1098 assert!(
1099 !status.open_tunnel_exists(&server_hex),
1100 "per-peer relay tunnel released after promotion+drain"
1101 );
1102 assert!(
1103 status.is_connected(),
1104 "the relay reservation stays Connected"
1105 );
1106 }
1107
1108 /// (4) Direct never lands: `connect_fast` stays relayed, usable, with the reservation intact.
1109 #[tokio::test]
1110 async fn stays_relayed_when_direct_never_lands() {
1111 let client = test_node("fc/4/client");
1112 let server = test_node("fc/4/server");
1113 let (relayed, status) = relayed_establisher(&client, &server, 11);
1114 // A direct establisher that always fails.
1115 let direct: Establisher = Arc::new(|| {
1116 Box::pin(async {
1117 Err(NatError::AllMethodsFailed(vec![MethodError::failed(
1118 TraversalKind::Direct,
1119 "no direct path",
1120 )]))
1121 })
1122 });
1123
1124 let conn = connect_fast_with(
1125 server.peer_id(),
1126 direct,
1127 Some(relayed),
1128 Duration::from_millis(200),
1129 Duration::from_secs(5),
1130 )
1131 .await
1132 .unwrap();
1133
1134 // Give the guard a moment; it must NOT promote.
1135 tokio::time::sleep(Duration::from_millis(200)).await;
1136 assert_eq!(conn.current_method(), TraversalKind::Relayed);
1137 let resp = conn.query_availability(vec![avail_item()]).await.unwrap();
1138 assert_eq!(resp.items[0].total_length, Some(11));
1139 assert!(status.is_connected());
1140 }
1141
1142 /// (6) Identity-mismatch guard: a direct fake returning a DIFFERENT peer's cert/BLS is REFUSED —
1143 /// the connection stays relayed. (SECURITY-CRITICAL: the identity-equality invariant.)
1144 #[tokio::test]
1145 async fn refuses_promotion_on_identity_mismatch() {
1146 let client = test_node("fc/6/client");
1147 let server = test_node("fc/6/server");
1148 let impostor = test_node("fc/6/impostor");
1149 let (relayed, _status) = relayed_establisher(&client, &server, 11);
1150 // The direct fake pins + serves the IMPOSTOR identity (a different peer_id + BLS).
1151 let direct = direct_establisher(&client, &impostor, 22, Duration::from_millis(100));
1152
1153 let conn = connect_fast_with(
1154 server.peer_id(),
1155 direct,
1156 Some(relayed),
1157 Duration::from_millis(200),
1158 Duration::from_secs(5),
1159 )
1160 .await
1161 .unwrap();
1162
1163 // The direct path lands but its identity != the relayed peer's → promotion refused.
1164 tokio::time::sleep(Duration::from_millis(400)).await;
1165 assert_eq!(
1166 conn.current_method(),
1167 TraversalKind::Relayed,
1168 "promotion refused on identity mismatch"
1169 );
1170 let resp = conn.query_availability(vec![avail_item()]).await.unwrap();
1171 assert_eq!(resp.items[0].total_length, Some(11), "still relayed");
1172 }
1173
1174 /// (5) Post-promotion direct death → fallback: after promoting to direct, killing the direct
1175 /// transport makes the guard fall back — the method flips back to Relayed and a fresh
1176 /// `open_stream` succeeds over the re-dialed transport. (Uses duplex establishers for BOTH roles
1177 /// so the reusable fallback can be re-dialed; the relayed-role establisher reports Relayed.)
1178 #[tokio::test]
1179 async fn falls_back_to_relayed_when_promoted_direct_dies() {
1180 let client = test_node("fc/5/client");
1181 let server = test_node("fc/5/server");
1182 // Relayed-role establisher (reusable — a fresh duplex server per call), reports Relayed/tag 11.
1183 let relayed = duplex_establisher(
1184 &client,
1185 &server,
1186 TraversalKind::Relayed,
1187 11,
1188 Duration::ZERO,
1189 None,
1190 );
1191 // Direct-role establisher (tag 22) that lands after a delay and can be KILLED.
1192 let kill = Arc::new(Notify::new());
1193 let direct = duplex_establisher(
1194 &client,
1195 &server,
1196 TraversalKind::Direct,
1197 22,
1198 Duration::from_millis(120),
1199 Some(Arc::clone(&kill)),
1200 );
1201
1202 let conn = connect_fast_with(
1203 server.peer_id(),
1204 direct,
1205 Some(relayed),
1206 Duration::from_millis(100),
1207 Duration::from_secs(5),
1208 )
1209 .await
1210 .unwrap();
1211
1212 // Wait for the promotion to Direct.
1213 let mut rx = conn.subscribe();
1214 tokio::time::timeout(Duration::from_secs(5), async {
1215 while *rx.borrow_and_update() != TraversalKind::Direct {
1216 rx.changed().await.unwrap();
1217 }
1218 })
1219 .await
1220 .expect("promoted to Direct");
1221 let post = conn.query_availability(vec![avail_item()]).await.unwrap();
1222 assert_eq!(post.items[0].total_length, Some(22), "served over direct");
1223
1224 // Kill the direct transport → the guard must fall back to Relayed.
1225 kill.notify_waiters();
1226 tokio::time::timeout(Duration::from_secs(5), async {
1227 while *rx.borrow_and_update() != TraversalKind::Relayed {
1228 rx.changed().await.unwrap();
1229 }
1230 })
1231 .await
1232 .expect("fell back to Relayed after direct death");
1233 assert_eq!(conn.current_method(), TraversalKind::Relayed);
1234
1235 // A fresh stream now succeeds over the re-dialed relayed transport (tag 11).
1236 let after = conn.query_availability(vec![avail_item()]).await.unwrap();
1237 assert_eq!(after.items[0].total_length, Some(11), "re-dialed relayed");
1238 }
1239
1240 /// (7) Gate-3 REFUSES a post-TLS blackhole, BOUNDED by the probe timeout. A direct fake whose
1241 /// mTLS + identity match the relayed peer but which then blackholes the mux layer must NOT be
1242 /// promoted — the empty-availability probe hangs, the `probe_timeout` fires, and the connection
1243 /// stays relayed (fail-closed). SECURITY-CRITICAL (gate 3).
1244 ///
1245 /// Red-verify: deleting the gate-3 probe block in `try_promote` (the
1246 /// `tokio::time::timeout(probe_timeout, direct_conn.query_availability(vec![]))` match) makes this
1247 /// test FAIL — with no probe the blackhole peer promotes to Direct.
1248 #[tokio::test]
1249 async fn refuses_promotion_to_a_post_tls_blackhole() {
1250 let client = test_node("fc/7/client");
1251 let server = test_node("fc/7/server");
1252 let (relayed, _status) = relayed_establisher(&client, &server, 11);
1253 // Direct fake: SAME identity (server node) but blackholes the mux after TLS.
1254 let direct = blackhole_direct_establisher(&client, &server, Duration::from_millis(100));
1255
1256 let conn = connect_fast_with(
1257 server.peer_id(),
1258 direct,
1259 Some(relayed),
1260 Duration::from_millis(200), // grace
1261 Duration::from_millis(300), // probe_timeout — bounds the blackhole probe
1262 )
1263 .await
1264 .unwrap();
1265 assert_eq!(conn.current_method(), TraversalKind::Relayed);
1266
1267 // Allow the direct fake to land + the (bounded) probe to time out and be refused.
1268 tokio::time::sleep(Duration::from_millis(700)).await;
1269 assert_eq!(
1270 conn.current_method(),
1271 TraversalKind::Relayed,
1272 "post-TLS blackhole refused — probe timed out, stays relayed"
1273 );
1274 let resp = conn.query_availability(vec![avail_item()]).await.unwrap();
1275 assert_eq!(resp.items[0].total_length, Some(11), "still relayed");
1276 }
1277
1278 /// (8) Gate-2 BLS leg REFUSES same-peer_id / different-BLS. A direct fake whose `peer_id` matches
1279 /// the relayed peer but whose BLS pubkey differs must be refused. Isolates the BLS clause (test 6
1280 /// differs in BOTH peer_id and BLS, so it cannot catch a dropped BLS clause). SECURITY-CRITICAL.
1281 ///
1282 /// Red-verify: removing `|| direct_conn.peer_bls_pub != relayed_slot.peer_bls_pub` from gate 2
1283 /// makes this test FAIL — peer_id alone matches, so the different-BLS peer would promote to Direct.
1284 #[tokio::test]
1285 async fn refuses_promotion_on_bls_mismatch_same_peer_id() {
1286 let client = test_node("fc/8/client");
1287 let server = test_node("fc/8/server");
1288 let (relayed, _status) = relayed_establisher(&client, &server, 11);
1289 // Direct fake: peer_id == server (matches expected), but BLS pubkey overwritten to differ.
1290 let direct = mismatched_bls_establisher(&client, &server, 22, Duration::from_millis(100));
1291
1292 let conn = connect_fast_with(
1293 server.peer_id(),
1294 direct,
1295 Some(relayed),
1296 Duration::from_millis(200),
1297 Duration::from_secs(5),
1298 )
1299 .await
1300 .unwrap();
1301
1302 // The direct path lands with a matching peer_id but a mismatched BLS → gate 2 refuses.
1303 tokio::time::sleep(Duration::from_millis(400)).await;
1304 assert_eq!(
1305 conn.current_method(),
1306 TraversalKind::Relayed,
1307 "promotion refused on BLS mismatch despite matching peer_id"
1308 );
1309 let resp = conn.query_availability(vec![avail_item()]).await.unwrap();
1310 assert_eq!(resp.items[0].total_length, Some(11), "still relayed");
1311 }
1312
1313 /// (9) An in-flight relayed stream SURVIVES a short grace cap. With a sub-millisecond
1314 /// `fast_connect_grace`, the post-promotion drain task releases its own hold as soon as the cap
1315 /// elapses — but a stream held across the promotion boundary keeps the relayed slot alive via its
1316 /// OWN `Arc<TransportSlot>`, so it still completes (no truncation on cap).
1317 ///
1318 /// Red-verify: a hypothetical drain that TRUNCATED the slot on cap (e.g. forcibly closed the
1319 /// relayed session when `deadline` fires instead of only dropping its own reference) would make the
1320 /// held `pre` stream's `decode` fail — this assertion (the held stream still yields tag 11) guards
1321 /// against that regression.
1322 #[tokio::test]
1323 async fn inflight_relayed_stream_survives_short_grace_cap() {
1324 let client = test_node("fc/9/client");
1325 let server = test_node("fc/9/server");
1326 let (relayed, _status) = relayed_establisher(&client, &server, 11);
1327 let direct = direct_establisher(&client, &server, 22, Duration::from_millis(150));
1328
1329 let conn = connect_fast_with(
1330 server.peer_id(),
1331 direct,
1332 Some(relayed),
1333 Duration::from_millis(1), // tiny grace — the drain cap fires almost immediately
1334 Duration::from_secs(5),
1335 )
1336 .await
1337 .expect("relayed lands first");
1338 assert_eq!(conn.current_method(), TraversalKind::Relayed);
1339
1340 // Open a relayed stream + write the request BEFORE promotion; hold it open across the boundary.
1341 let mut pre = conn.open_stream().await.unwrap();
1342 pre.write_all(
1343 &AvailabilityRequest {
1344 items: vec![avail_item()],
1345 }
1346 .encode(),
1347 )
1348 .await
1349 .unwrap();
1350 pre.flush().await.unwrap();
1351
1352 // Wait for promotion to Direct (the drain task then fires its ~1ms cap immediately).
1353 let mut rx = conn.subscribe();
1354 tokio::time::timeout(Duration::from_secs(5), async {
1355 while *rx.borrow_and_update() != TraversalKind::Direct {
1356 rx.changed().await.unwrap();
1357 }
1358 })
1359 .await
1360 .expect("promoted to Direct");
1361 // Let the grace cap elapse + the drain task run to completion.
1362 tokio::time::sleep(Duration::from_millis(200)).await;
1363
1364 // The held stream STILL completes over the relayed transport (tag 11) — its Arc kept the slot
1365 // alive past the grace cap; the cap only bounded the drain task's own hold, not the stream.
1366 let pre_resp = AvailabilityResponse::decode(&mut pre).await.unwrap();
1367 assert_eq!(
1368 pre_resp.items[0].total_length,
1369 Some(11),
1370 "in-flight relayed stream survived the short grace cap"
1371 );
1372 }
1373
1374 #[test]
1375 fn fallback_backoff_is_zero_for_a_lone_death_then_capped_exponential() {
1376 let base = Duration::from_millis(50);
1377 let cap = Duration::from_secs(5);
1378 // A lone death (counter 0) re-dials immediately.
1379 assert_eq!(fallback_backoff(0, base, cap), Duration::ZERO);
1380 // Rapid re-deaths back off exponentially from the base.
1381 assert_eq!(fallback_backoff(1, base, cap), Duration::from_millis(50));
1382 assert_eq!(fallback_backoff(2, base, cap), Duration::from_millis(100));
1383 assert_eq!(fallback_backoff(3, base, cap), Duration::from_millis(200));
1384 // Clamped to the cap and never overflows for a large death count.
1385 assert_eq!(fallback_backoff(20, base, cap), cap);
1386 assert_eq!(fallback_backoff(u32::MAX, base, cap), cap);
1387 }
1388
1389 fn avail_item() -> AvailabilityItem {
1390 AvailabilityItem {
1391 store_id: "bb".repeat(32),
1392 root: None,
1393 retrieval_key: None,
1394 }
1395 }
1396
1397 /// Poll `cond` until true or `budget` elapses (a small helper for background-task settling).
1398 async fn wait_until(budget: Duration, mut cond: impl FnMut() -> bool) {
1399 let deadline = tokio::time::Instant::now() + budget;
1400 while tokio::time::Instant::now() < deadline {
1401 if cond() {
1402 return;
1403 }
1404 tokio::time::sleep(Duration::from_millis(10)).await;
1405 }
1406 }
1407}