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}