Skip to main content

rings_node/extension/protocols/
relay.rs

1#![warn(missing_docs)]
2//! Generic transport-relay protocol — one pure server-side state machine for TCP and UDP,
3//! native and browser.
4//!
5//! The pure model is generic over the **target** `T` a service resolves to: a
6//! `SocketAddr` natively, a WebTransport `Url` (string) in the browser. The same `step`,
7//! state, duplicate-`Open` rejection and owner-rejection serve both — only the
8//! *interpreter* differs (native `NativeRelay` over OS sockets, browser `WtRelay` over
9//! WebTransport). This is the code realization of "TCP/UDP/native/browser are one relay".
10//!
11//! Every session is identified by the **owner-scoped key** `(from, namespace, session,
12//! initiator)` ([`SessionKey`]). `from` is the authenticated sender (owner rejection: a peer
13//! can only name keys whose `from` is itself), and `initiator` records which end opened it —
14//! so a session a peer opened never collides with one we opened that got the same id
15//! (bidirectional-open safety). A frame's `from_opener` flips to our `initiator`.
16//!
17//! The reducer is the **sole authority** over the session set: `Data`/`Shutdown`/`Close`
18//! emit an effect only for a session in `sessions` (the engine never adjudicates liveness).
19//!
20//! ```text
21//!   S = (services : Name ⇀ T,  sessions : ℘ SessionKey,  next : ℕ)
22//!   k = (from, namespace, session, init)        init = Remote if from_opener else Local
23//!   step (Command(Register n t))                ↦ (S[services∪{n↦t}], ε)
24//!   step (Command(Accepted tok peer svc))       ↦ (S[sessions∪{kₗ}, next+1], [OpenAccepted tok kₗ svc])
25//!                                                   where kₗ=(peer,ns,next,Local)   ← core mints the id
26//!   step (Command(Untrack k))                   ↦ (S[sessions∖{k}], ε)
27//!   step (Frame(from, Open s n)) | k∈sessions   ↦ (S, ε)                            (duplicate)
28//!                                | n∈services    ↦ (S∪{k}, [Connect k t kind])
29//!                                | otherwise     ↦ (S, [SendClose s])
30//!   step (Frame(from, Data s b)) | k∈sessions   ↦ (S, [Write k b])   else (S, ε)
31//!   step (Frame(from, Close s))  | k∈sessions   ↦ (S∖{k}, [Close k]) else (S, ε)
32//! ```
33
34use std::collections::HashMap;
35use std::collections::HashSet;
36use std::sync::Arc;
37
38use bytes::Bytes;
39use rings_core::dht::Did;
40use serde::de::DeserializeOwned;
41use serde::Deserialize;
42use serde::Serialize;
43
44use crate::extension::ext::Ctx;
45use crate::extension::ext::Interpret;
46use crate::extension::ext::MaybeSend;
47use crate::extension::ext::Protocol;
48use crate::extension::ext::Reject;
49use crate::extension::ext::Scope;
50use crate::extension::ext::Transition;
51use crate::extension::ext::Wire;
52use crate::extension::transport::Frame;
53use crate::extension::transport::Initiator;
54use crate::extension::transport::SessionId;
55use crate::extension::transport::SessionKey;
56use crate::extension::transport::TransportKind;
57
58/// Namespace for the TCP relay.
59pub const TCP: &str = "tcp";
60/// Namespace for the UDP relay.
61pub const UDP: &str = "udp";
62
63/// A local control command, re-injected by the provider (provenance = self; never sent by
64/// peers). Generic over the service target `T`.
65#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
66pub enum RelayCommand<T> {
67    /// Map a service name to a local target that `Open` may dial.
68    RegisterService {
69        /// Service name.
70        name: String,
71        /// Local target (`SocketAddr` natively, WebTransport URL in browser).
72        target: T,
73    },
74    /// Remove a service mapping.
75    UnregisterService {
76        /// Service name to remove.
77        name: String,
78    },
79    /// Engine→protocol feedback: a local connection/datagram-flow was accepted, pending
80    /// under engine-local `token`, destined for `peer`'s `service`. The pure `step` mints
81    /// the session id (so id allocation lives in the core, not the shell), records it, and
82    /// replies with [`RelayEffect::OpenAccepted`] to bind the pending resource. The engine
83    /// never mints or decides identity — it only reports the raw accept and executes effects.
84    Accepted {
85        /// Engine-local handle for the pending (not-yet-bound) connection/flow.
86        token: u64,
87        /// The remote peer this session is tunnelled to.
88        peer: Did,
89        /// The remote service to open.
90        service: String,
91    },
92    /// Engine→protocol feedback: a session was torn down by the engine (any side); forget
93    /// it. The single point through which every teardown reaches the pure state.
94    Untrack {
95        /// The remote peer of the session.
96        peer: Did,
97        /// The session id.
98        session: SessionId,
99        /// Which end opened it (so the right key is removed).
100        initiator: Initiator,
101    },
102}
103
104/// The relay's typed input: a self-injected [`RelayCommand`] or an authenticated peer
105/// [`Frame`]. The `from == me` split is resolved in [`Relay::decode`].
106pub enum RelayEvent<T> {
107    /// Runtime service registration (provenance = self).
108    Command(RelayCommand<T>),
109    /// A network frame from an authenticated peer.
110    Frame {
111        /// Authenticated sender.
112        from: Did,
113        /// The frame.
114        frame: Frame,
115    },
116}
117
118/// The relay's own effect algebra (interpreted by `NativeRelay` / `WtRelay`).
119#[derive(Clone, Debug, PartialEq, Eq)]
120pub enum RelayEffect<T> {
121    /// Open a local backend session to `target` and relay it (peer opened a session).
122    Connect {
123        /// Owner-scoped session key.
124        key: SessionKey,
125        /// Local target to dial.
126        target: T,
127        /// Stream (TCP) or datagram (UDP).
128        kind: TransportKind,
129    },
130    /// Write peer bytes to a session's local stream.
131    Write {
132        /// Target session.
133        key: SessionKey,
134        /// Bytes.
135        bytes: Bytes,
136    },
137    /// Half-close a session's local write side (peer FIN).
138    Shutdown {
139        /// Target session.
140        key: SessionKey,
141    },
142    /// Close a session (full teardown).
143    Close {
144        /// Target session.
145        key: SessionKey,
146    },
147    /// Reply a `Frame::Close` to a peer that opened an unknown service. The reply goes out under
148    /// the interpreter's own namespace (its [`Scope`]), so the effect carries no namespace of
149    /// its own.
150    SendClose {
151        /// Peer to reply to.
152        to: Did,
153        /// Session id to close.
154        session: SessionId,
155        /// Whether *we* opened the session (false: the peer did).
156        from_opener: bool,
157    },
158    /// Bind a pending accepted connection/flow (engine-local `token`) to the session `key`
159    /// the pure `step` just minted, then open it to the peer and start relaying. The reply
160    /// to [`RelayCommand::Accepted`] — this is how a step-minted id reaches the engine.
161    OpenAccepted {
162        /// Engine-local handle for the pending connection/flow.
163        token: u64,
164        /// The session key minted by the pure step.
165        key: SessionKey,
166        /// The remote service to open.
167        service: String,
168    },
169}
170
171/// Relay state: the service registry and the set of open (server-side, remote-opened)
172/// sessions. The live OS/WebTransport resources are the interpreter's engine table; this is
173/// the protocol's view used for owner-rejection and duplicate-`Open` rejection.
174#[derive(Clone)]
175pub struct RelayState<T> {
176    services: Arc<HashMap<String, T>>,
177    sessions: HashSet<SessionKey>,
178    /// Monotonic allocator for client-side session ids. Lives in the **pure** state so the
179    /// core (not the engine) mints session identities — `Event → step → Effect` is the sole
180    /// authority for both the session set and its ids.
181    next_session: u64,
182}
183
184impl<T> Default for RelayState<T> {
185    fn default() -> Self {
186        Self {
187            services: Arc::new(HashMap::new()),
188            sessions: HashSet::new(),
189            next_session: 0,
190        }
191    }
192}
193
194/// Transport relay protocol (server side), generic over the service target `T`.
195#[derive(Clone)]
196pub struct Relay<T> {
197    namespace: String,
198    kind: TransportKind,
199    config: HashMap<String, T>,
200}
201
202impl<T> Relay<T> {
203    /// A TCP relay with a fixed service configuration.
204    pub fn tcp(config: HashMap<String, T>) -> Self {
205        Self {
206            namespace: TCP.to_string(),
207            kind: TransportKind::Tcp,
208            config,
209        }
210    }
211
212    /// A UDP relay with a fixed service configuration.
213    pub fn udp(config: HashMap<String, T>) -> Self {
214        Self {
215            namespace: UDP.to_string(),
216            kind: TransportKind::Udp,
217            config,
218        }
219    }
220}
221
222impl<T> Protocol for Relay<T>
223where T: Clone + DeserializeOwned + Serialize + MaybeSend + 'static
224{
225    type State = RelayState<T>;
226    type Event = RelayEvent<T>;
227    type Effect = RelayEffect<T>;
228
229    fn namespace(&self) -> &str {
230        self.namespace.as_str()
231    }
232
233    fn init(&self) -> RelayState<T> {
234        RelayState {
235            services: Arc::new(self.config.clone()),
236            sessions: HashSet::new(),
237            next_session: 0,
238        }
239    }
240
241    fn decode(&self, wire: Wire<'_>) -> Result<RelayEvent<T>, Reject> {
242        if wire.from == wire.me {
243            let command = bincode::deserialize::<RelayCommand<T>>(wire.payload)
244                .map_err(|e| Reject(format!("bad relay command: {e}")))?;
245            Ok(RelayEvent::Command(command))
246        } else {
247            let frame = bincode::deserialize::<Frame>(wire.payload)
248                .map_err(|e| Reject(format!("bad relay frame: {e}")))?;
249            Ok(RelayEvent::Frame {
250                from: wire.from,
251                frame,
252            })
253        }
254    }
255
256    fn step(
257        &self,
258        ctx: Ctx<'_, RelayState<T>>,
259        event: RelayEvent<T>,
260    ) -> Transition<RelayState<T>, RelayEffect<T>> {
261        match event {
262            RelayEvent::Command(command) => {
263                step_command(self.namespace.as_str(), ctx.state, command)
264            }
265            RelayEvent::Frame { from, frame } => {
266                step_frame(self.kind, self.namespace.as_str(), ctx.state, from, frame)
267            }
268        }
269    }
270}
271
272/// Apply a local [`RelayCommand`]. Pure. `Accepted`/`Untrack` are the engine→protocol
273/// feedback that make `step` the sole authority over the session set **and its ids**: the
274/// core mints the id on `Accepted` (the engine reported only a local token) and forgets the
275/// session on `Untrack`.
276fn step_command<T: Clone>(
277    namespace: &str,
278    state: &RelayState<T>,
279    command: RelayCommand<T>,
280) -> Transition<RelayState<T>, RelayEffect<T>> {
281    let mut next = state.clone();
282    match command {
283        RelayCommand::RegisterService { name, target } => {
284            Arc::make_mut(&mut next.services).insert(name, target);
285            Transition::pure(next)
286        }
287        RelayCommand::UnregisterService { name } => {
288            Arc::make_mut(&mut next.services).remove(&name);
289            Transition::pure(next)
290        }
291        RelayCommand::Accepted {
292            token,
293            peer,
294            service,
295        } => {
296            // The core mints the session id (the engine reported only its local token), so
297            // id allocation is part of the pure state transition, not a shell decision.
298            let session = SessionId(next.next_session);
299            next.next_session += 1;
300            // A locally-accepted tunnel: we are the initiator.
301            let key = SessionKey::new(peer, namespace, session, Initiator::Local);
302            next.sessions.insert(key.clone());
303            Transition::with(next, vec![RelayEffect::OpenAccepted {
304                token,
305                key,
306                service,
307            }])
308        }
309        RelayCommand::Untrack {
310            peer,
311            session,
312            initiator,
313        } => {
314            next.sessions
315                .remove(&SessionKey::new(peer, namespace, session, initiator));
316            Transition::pure(next)
317        }
318    }
319}
320
321/// Apply a network [`Frame`]. Pure; emits relay effects scoped to the authenticated `from`.
322fn step_frame<T: Clone>(
323    kind: TransportKind,
324    namespace: &str,
325    state: &RelayState<T>,
326    from: Did,
327    frame: Frame,
328) -> Transition<RelayState<T>, RelayEffect<T>> {
329    match frame {
330        // `Open` is always sent by the opener, so from our side the peer is the initiator.
331        Frame::Open { session, service } => {
332            let key = SessionKey::new(from, namespace, session, Initiator::Remote);
333            // Reject a duplicate/retried Open for a session this peer already holds open.
334            if state.sessions.contains(&key) {
335                return Transition::pure(state.clone());
336            }
337            match state.services.get(service.as_str()) {
338                Some(target) => {
339                    let target = target.clone();
340                    let mut next = state.clone();
341                    next.sessions.insert(key.clone());
342                    Transition::with(next, vec![RelayEffect::Connect { key, target, kind }])
343                }
344                None => Transition::with(state.clone(), vec![RelayEffect::SendClose {
345                    to: from,
346                    session,
347                    // The peer opened it (unknown service); we did not.
348                    from_opener: false,
349                }]),
350            }
351        }
352        // Data/Shutdown/Close are guarded on the authoritative session set: the *reducer*
353        // decides whether the effect happens, not the engine table. `from_opener` (the
354        // sender opened it) flips to our initiator.
355        Frame::Data {
356            session,
357            from_opener,
358            bytes,
359        } => {
360            let key = SessionKey::new(from, namespace, session, opener_to_initiator(from_opener));
361            if state.sessions.contains(&key) {
362                Transition::with(state.clone(), vec![RelayEffect::Write { key, bytes }])
363            } else {
364                Transition::pure(state.clone())
365            }
366        }
367        Frame::Shutdown {
368            session,
369            from_opener,
370        } => {
371            let key = SessionKey::new(from, namespace, session, opener_to_initiator(from_opener));
372            if state.sessions.contains(&key) {
373                Transition::with(state.clone(), vec![RelayEffect::Shutdown { key }])
374            } else {
375                Transition::pure(state.clone())
376            }
377        }
378        Frame::Close {
379            session,
380            from_opener,
381        } => {
382            let key = SessionKey::new(from, namespace, session, opener_to_initiator(from_opener));
383            if state.sessions.contains(&key) {
384                let mut next = state.clone();
385                next.sessions.remove(&key);
386                Transition::with(next, vec![RelayEffect::Close { key }])
387            } else {
388                Transition::pure(state.clone())
389            }
390        }
391    }
392}
393
394/// Map a frame's `from_opener` (the **sender** opened the session) to our own [`Initiator`].
395fn opener_to_initiator(from_opener: bool) -> Initiator {
396    if from_opener {
397        Initiator::Remote
398    } else {
399        Initiator::Local
400    }
401}
402
403/// Encode a `Frame::Close` as bytes for an overlay send. `from_opener` is whether *we* (the
404/// sender of this close) opened the session.
405pub(crate) fn close_frame(session: SessionId, from_opener: bool) -> Bytes {
406    let frame = Frame::Close {
407        session,
408        from_opener,
409    };
410    Bytes::from(bincode::serialize(&frame).unwrap_or_default())
411}
412
413// ── Native interpreter (OS sockets) ───────────────────────────────────────────────────
414
415/// Native relay interpreter: runs [`RelayEffect`]s over the OS-socket engine it owns. The
416/// engine uses the namespace-scoped [`Scope`] capability for both overlay sends and lifecycle
417/// feedback (`Accepted`/`Untrack`), so the engine has no `Processor` of its own.
418#[cfg(feature = "node")]
419pub(crate) struct NativeRelay {
420    engine: Arc<crate::extension::transport::engine::TransportSessions>,
421}
422
423#[cfg(feature = "node")]
424impl NativeRelay {
425    /// Build over a shared engine.
426    pub(crate) fn new(engine: Arc<crate::extension::transport::engine::TransportSessions>) -> Self {
427        Self { engine }
428    }
429}
430
431#[cfg(feature = "node")]
432#[async_trait::async_trait]
433impl Interpret for NativeRelay {
434    type Effect = RelayEffect<std::net::SocketAddr>;
435
436    async fn run(
437        &self,
438        scope: &Scope,
439        effect: RelayEffect<std::net::SocketAddr>,
440    ) -> crate::error::Result<Vec<Bytes>> {
441        match effect {
442            RelayEffect::Connect { key, target, kind } => {
443                self.engine
444                    .clone()
445                    .connect(scope.clone(), key, target, kind)
446                    .await;
447            }
448            RelayEffect::Write { key, bytes } => {
449                self.engine.write(&key, bytes).await;
450            }
451            RelayEffect::Shutdown { key } => {
452                self.engine.shutdown(&key).await;
453            }
454            RelayEffect::Close { key } => {
455                self.engine.close(scope, &key).await;
456            }
457            RelayEffect::SendClose {
458                to,
459                session,
460                from_opener,
461            } => {
462                scope.send(to, close_frame(session, from_opener)).await?;
463            }
464            RelayEffect::OpenAccepted {
465                token,
466                key,
467                service,
468            } => {
469                self.engine
470                    .clone()
471                    .bind_accepted(scope.clone(), token, key, service)
472                    .await;
473            }
474        }
475        Ok(Vec::new())
476    }
477}
478
479// ── Browser interpreter (WebTransport) ────────────────────────────────────────────────
480
481/// Browser relay interpreter: runs [`RelayEffect`]s over the WebTransport engine it owns.
482#[cfg(feature = "browser")]
483pub(crate) struct WtRelay {
484    engine: Arc<crate::extension::transport::wt::WtSessions>,
485}
486
487#[cfg(feature = "browser")]
488impl WtRelay {
489    /// Build over a shared WebTransport engine.
490    pub(crate) fn new(engine: Arc<crate::extension::transport::wt::WtSessions>) -> Self {
491        Self { engine }
492    }
493}
494
495#[cfg(feature = "browser")]
496#[async_trait::async_trait(?Send)]
497impl Interpret for WtRelay {
498    type Effect = RelayEffect<String>;
499
500    async fn run(
501        &self,
502        scope: &Scope,
503        effect: RelayEffect<String>,
504    ) -> crate::error::Result<Vec<Bytes>> {
505        match effect {
506            RelayEffect::Connect { key, target, kind } => {
507                self.engine
508                    .clone()
509                    .connect(scope.clone(), key, target, kind)
510                    .await;
511            }
512            RelayEffect::Write { key, bytes } => {
513                self.engine.write(&key, bytes).await;
514            }
515            RelayEffect::Shutdown { key } => {
516                self.engine.shutdown(&key).await;
517            }
518            RelayEffect::Close { key } => {
519                self.engine.close(scope, &key).await;
520            }
521            RelayEffect::SendClose {
522                to,
523                session,
524                from_opener,
525            } => {
526                scope.send(to, close_frame(session, from_opener)).await?;
527            }
528            // The browser relay is server-side only (no local listener), so it never reports
529            // an `Accepted` and thus never receives `OpenAccepted`.
530            RelayEffect::OpenAccepted { .. } => {
531                tracing::warn!("browser relay received OpenAccepted; it has no local listener");
532            }
533        }
534        Ok(Vec::new())
535    }
536}
537
538// ── Client-side relay handle ──────────────────────────────────────────────────────────
539
540/// Client-facing handle to the relay extension's live engine: open local tunnels and register
541/// local services. This is the relay extension's *own* surface — the relay owns its engine and
542/// installs itself ([`install`](RelayHandle::install)), so nothing about it leaks into the
543/// generic [`Provider`](crate::provider::Provider) (the same way SNARK registers itself).
544/// Cloneable; every clone drives the same shared engine and pure [`Relay`] state.
545/// Holds the two per-namespace scoped capabilities (`tcp` / `udp`); each method picks one and
546/// can only act within it, so the handle cannot address an arbitrary namespace even internally.
547#[cfg(feature = "node")]
548#[derive(Clone)]
549pub struct RelayHandle {
550    engine: Arc<crate::extension::transport::engine::TransportSessions>,
551    tcp: Scope,
552    udp: Scope,
553}
554
555#[cfg(feature = "node")]
556impl RelayHandle {
557    /// Install the relay into an extension registry: register the TCP and UDP interpreters
558    /// over a fresh, relay-owned OS-socket engine and return the client handle. Errors if the
559    /// `tcp`/`udp` namespaces are already taken. Call once per node, after constructing the
560    /// provider — the relay is opt-in, not a `Provider` invariant.
561    pub fn install(extensions: &crate::extension::ext::Extensions) -> crate::error::Result<Self> {
562        let engine = Arc::new(crate::extension::transport::engine::TransportSessions::new());
563        // Atomic: both namespaces register together, or neither (no half-installed relay).
564        extensions.register_many(vec![
565            (Relay::tcp(HashMap::new()), NativeRelay::new(engine.clone())),
566            (Relay::udp(HashMap::new()), NativeRelay::new(engine.clone())),
567        ])?;
568        let core = extensions.core();
569        Ok(Self {
570            engine,
571            tcp: Scope::new(core.clone(), TCP.to_string()),
572            udp: Scope::new(core, UDP.to_string()),
573        })
574    }
575
576    /// Open a local **TCP** tunnel: bind `local_addr` and relay each accepted connection to
577    /// `peer`'s `service` (client side, forward proxy).
578    pub async fn open_tcp_tunnel(
579        &self,
580        local_addr: std::net::SocketAddr,
581        peer: Did,
582        service: String,
583    ) -> crate::error::Result<()> {
584        self.open_tunnel(&self.tcp, local_addr, peer, service, TransportKind::Tcp)
585            .await
586    }
587
588    /// Open a local **UDP** tunnel: bind `local_addr` and relay each datagram flow to `peer`'s
589    /// `service` (client side, forward proxy).
590    pub async fn open_udp_tunnel(
591        &self,
592        local_addr: std::net::SocketAddr,
593        peer: Did,
594        service: String,
595    ) -> crate::error::Result<()> {
596        self.open_tunnel(&self.udp, local_addr, peer, service, TransportKind::Udp)
597            .await
598    }
599
600    async fn open_tunnel(
601        &self,
602        scope: &Scope,
603        local_addr: std::net::SocketAddr,
604        peer: Did,
605        service: String,
606        kind: TransportKind,
607    ) -> crate::error::Result<()> {
608        // Bind a local listener on the relay engine with this namespace's scope. Each accepted
609        // connection is reported back through the pure relay (`Accepted`), so
610        // `RelayState.sessions` stays the sole authority.
611        self.engine
612            .clone()
613            .listen(scope.clone(), local_addr, peer, service, kind)
614            .await;
615        Ok(())
616    }
617
618    /// Register (at runtime) a local service the **TCP** relay may dial (`name` → `addr`).
619    pub async fn register_tcp_service(
620        &self,
621        name: String,
622        addr: std::net::SocketAddr,
623    ) -> crate::error::Result<()> {
624        register_service(&self.tcp, name, addr).await
625    }
626
627    /// Register (at runtime) a local service the **UDP** relay may dial (`name` → `addr`).
628    pub async fn register_udp_service(
629        &self,
630        name: String,
631        addr: std::net::SocketAddr,
632    ) -> crate::error::Result<()> {
633        register_service(&self.udp, name, addr).await
634    }
635}
636
637/// Map a service `name` → `target` by self-injecting a `RegisterService` command into the
638/// scope's own namespace (provenance = self).
639#[cfg(any(feature = "node", feature = "browser"))]
640async fn register_service<T>(scope: &Scope, name: String, target: T) -> crate::error::Result<()>
641where T: Serialize {
642    let command = RelayCommand::RegisterService { name, target };
643    let payload = bincode::serialize(&command).map_err(|_| crate::error::Error::EncodeError)?;
644    scope.inject(Bytes::from(payload)).await
645}
646
647/// Client-facing handle to the browser relay extension's live WebTransport engine: register
648/// local WebTransport-backed services. The browser relay is server-side only (no local
649/// listener), so it has no tunnel-open surface. Cloneable. See the native [`RelayHandle`].
650/// Holds the two per-namespace scoped capabilities (`tcp` / `udp`). The browser relay is
651/// server-side only (no local listener), so this handle just registers services. See the
652/// native [`RelayHandle`].
653#[cfg(feature = "browser")]
654#[derive(Clone)]
655pub struct RelayHandle {
656    tcp: Scope,
657    udp: Scope,
658}
659
660#[cfg(feature = "browser")]
661impl RelayHandle {
662    /// Install the browser relay into an extension registry: register the TCP and UDP
663    /// interpreters over a fresh, relay-owned WebTransport engine and return the client handle.
664    /// Errors if the `tcp`/`udp` namespaces are already taken. Call once per node, after
665    /// constructing the provider — the relay is opt-in, not a `Provider` invariant.
666    ///
667    /// This is a **Rust-wasm-facing** surface: there is no `wasm_bindgen` install/handle for JS
668    /// yet (unlike `provider.on(...)`), so browser relay is reachable only from Rust-wasm apps.
669    /// A JS-facing extension install API can be added when a JS consumer needs WebTransport
670    /// relay; it must not put these methods back on the generic `Provider`.
671    pub fn install(extensions: &crate::extension::ext::Extensions) -> crate::error::Result<Self> {
672        let engine = Arc::new(crate::extension::transport::wt::WtSessions::new());
673        // Atomic: both namespaces register together, or neither (no half-installed relay).
674        extensions.register_many(vec![
675            (Relay::tcp(HashMap::new()), WtRelay::new(engine.clone())),
676            (Relay::udp(HashMap::new()), WtRelay::new(engine)),
677        ])?;
678        let core = extensions.core();
679        Ok(Self {
680            tcp: Scope::new(core.clone(), TCP.to_string()),
681            udp: Scope::new(core, UDP.to_string()),
682        })
683    }
684
685    /// Register a WebTransport-backed service for the browser **TCP** relay, mapping
686    /// `name` → WebTransport `url` (under the `tcp` namespace).
687    pub async fn register_wt_service(&self, name: String, url: String) -> crate::error::Result<()> {
688        register_service(&self.tcp, name, url).await
689    }
690
691    /// Register a WebTransport-backed service for the browser **UDP** relay (datagrams),
692    /// mapping `name` → WebTransport `url` (under the `udp` namespace).
693    pub async fn register_wt_udp_service(
694        &self,
695        name: String,
696        url: String,
697    ) -> crate::error::Result<()> {
698        register_service(&self.udp, name, url).await
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use std::collections::HashMap;
705    use std::collections::HashSet;
706    use std::net::SocketAddr;
707
708    use bytes::Bytes;
709    use rings_core::dht::Did;
710
711    use super::Frame;
712    use super::Initiator;
713    use super::Relay;
714    use super::RelayCommand;
715    use super::RelayEffect;
716    use super::RelayState;
717    use super::SessionId;
718    use super::SessionKey;
719    use super::TransportKind;
720    use crate::extension::ext::Ctx;
721    use crate::extension::ext::Protocol;
722    use crate::extension::ext::Transition;
723    use crate::extension::ext::Wire;
724
725    fn this_node() -> Did {
726        Did::from(1u32)
727    }
728    fn peer_a() -> Did {
729        Did::from(2u32)
730    }
731    fn peer_b() -> Did {
732        Did::from(3u32)
733    }
734    fn web_addr() -> SocketAddr {
735        "127.0.0.1:8080".parse().unwrap()
736    }
737
738    /// A server-side (peer-opened) key on the TCP relay — the common case in these tests.
739    fn rkey(peer: Did, session: u64) -> SessionKey {
740        SessionKey::new(peer, super::TCP, SessionId(session), Initiator::Remote)
741    }
742    /// Peer `Data` on a peer-opened session (`from_opener = true`).
743    fn data(session: u64, bytes: &'static [u8]) -> Frame {
744        Frame::Data {
745            session: SessionId(session),
746            from_opener: true,
747            bytes: Bytes::from_static(bytes),
748        }
749    }
750    /// Peer `Close` on a peer-opened session (`from_opener = true`).
751    fn close(session: u64) -> Frame {
752        Frame::Close {
753            session: SessionId(session),
754            from_opener: true,
755        }
756    }
757    /// Peer `Open` for `service`.
758    fn open(session: u64, service: &str) -> Frame {
759        Frame::Open {
760            session: SessionId(session),
761            service: service.to_string(),
762        }
763    }
764
765    fn web_relay() -> Relay<SocketAddr> {
766        let mut config = HashMap::new();
767        config.insert("web".to_string(), web_addr());
768        Relay::tcp(config)
769    }
770
771    /// Decode a peer frame then step.
772    fn step_frame(
773        relay: &Relay<SocketAddr>,
774        state: &RelayState<SocketAddr>,
775        from: Did,
776        frame: &Frame,
777    ) -> Transition<RelayState<SocketAddr>, RelayEffect<SocketAddr>> {
778        let payload = bincode::serialize(frame).unwrap();
779        let event = relay
780            .decode(Wire {
781                from,
782                me: this_node(),
783                payload: payload.as_ref(),
784            })
785            .unwrap();
786        relay.step(
787            Ctx {
788                did: this_node(),
789                state,
790            },
791            event,
792        )
793    }
794
795    /// Decode a self command then step.
796    fn step_command(
797        relay: &Relay<SocketAddr>,
798        state: &RelayState<SocketAddr>,
799        command: &RelayCommand<SocketAddr>,
800    ) -> Transition<RelayState<SocketAddr>, RelayEffect<SocketAddr>> {
801        let payload = bincode::serialize(command).unwrap();
802        let event = relay
803            .decode(Wire {
804                from: this_node(),
805                me: this_node(),
806                payload: payload.as_ref(),
807            })
808            .unwrap();
809        relay.step(
810            Ctx {
811                did: this_node(),
812                state,
813            },
814            event,
815        )
816    }
817
818    #[test]
819    fn open_known_service_connects_and_records_the_session() {
820        let relay = web_relay();
821        let t = step_frame(&relay, &relay.init(), peer_a(), &open(7, "web"));
822        let expected = rkey(peer_a(), 7);
823        match t.effects.as_slice() {
824            [RelayEffect::Connect { key, target, kind }] => {
825                assert_eq!(*key, expected);
826                assert_eq!(*target, web_addr());
827                assert!(matches!(kind, TransportKind::Tcp));
828            }
829            other => panic!("expected one Connect, got {other:?}"),
830        }
831        assert!(t.state.sessions.contains(&expected));
832    }
833
834    #[test]
835    fn duplicate_open_for_a_live_session_is_rejected() {
836        let relay = web_relay();
837        let opened = step_frame(&relay, &relay.init(), peer_a(), &open(7, "web"));
838        assert!(opened.state.sessions.contains(&rkey(peer_a(), 7)));
839        let again = step_frame(&relay, &opened.state, peer_a(), &open(7, "web"));
840        assert!(
841            again.effects.is_empty(),
842            "duplicate Open must emit no effect"
843        );
844        assert_eq!(again.state.sessions.len(), 1);
845    }
846
847    #[test]
848    fn open_unknown_service_closes_and_records_nothing() {
849        let relay = web_relay();
850        let t = step_frame(&relay, &relay.init(), peer_a(), &open(7, "ssh"));
851        match t.effects.as_slice() {
852            [RelayEffect::SendClose {
853                to,
854                session,
855                from_opener,
856            }] => {
857                assert_eq!(*to, peer_a());
858                assert_eq!(*session, SessionId(7));
859                assert!(!from_opener, "we are not the opener of the peer's session");
860            }
861            other => panic!("expected one SendClose, got {other:?}"),
862        }
863        assert!(t.state.sessions.is_empty());
864    }
865
866    #[test]
867    fn data_writes_to_a_live_keyed_session() {
868        let relay = web_relay();
869        // Data is now guarded on the session set, so open it first.
870        let opened = step_frame(&relay, &relay.init(), peer_a(), &open(7, "web"));
871        let t = step_frame(&relay, &opened.state, peer_a(), &data(7, b"hello"));
872        match t.effects.as_slice() {
873            [RelayEffect::Write { key, bytes }] => {
874                assert_eq!(*key, rkey(peer_a(), 7));
875                assert_eq!(bytes.as_ref(), b"hello");
876            }
877            other => panic!("expected one Write, got {other:?}"),
878        }
879    }
880
881    #[test]
882    fn data_for_an_unknown_session_is_dropped_by_the_reducer() {
883        let relay = web_relay();
884        // No Open first: the reducer (not the engine table) decides there is no such session.
885        let t = step_frame(&relay, &relay.init(), peer_a(), &data(7, b"hello"));
886        assert!(
887            t.effects.is_empty(),
888            "Data for an unknown session emits nothing"
889        );
890    }
891
892    #[test]
893    fn close_removes_the_session_and_emits_close() {
894        let relay = web_relay();
895        let opened = step_frame(&relay, &relay.init(), peer_a(), &open(7, "web"));
896        let t = step_frame(&relay, &opened.state, peer_a(), &close(7));
897        let expected = rkey(peer_a(), 7);
898        match t.effects.as_slice() {
899            [RelayEffect::Close { key }] => assert_eq!(*key, expected),
900            other => panic!("expected one Close, got {other:?}"),
901        }
902        assert!(!t.state.sessions.contains(&expected));
903    }
904
905    #[test]
906    fn register_service_via_self_command_then_open_connects() {
907        let relay = Relay::tcp(HashMap::new());
908        let registered = step_command(&relay, &relay.init(), &RelayCommand::RegisterService {
909            name: "web".to_string(),
910            target: web_addr(),
911        });
912        assert!(registered.effects.is_empty());
913        let t = step_frame(&relay, &registered.state, peer_a(), &open(1, "web"));
914        match t.effects.as_slice() {
915            [RelayEffect::Connect { target, .. }] => assert_eq!(*target, web_addr()),
916            other => panic!("expected one Connect, got {other:?}"),
917        }
918    }
919
920    #[test]
921    fn accepted_mints_in_the_core_then_untrack_removes() {
922        let relay = web_relay();
923        // A client-side accept is fed back as `Accepted{token}`. The core mints the session id
924        // (0 on fresh state, initiator Local) and replies OpenAccepted with that minted key.
925        let accepted = step_command(&relay, &relay.init(), &RelayCommand::Accepted {
926            token: 42,
927            peer: peer_a(),
928            service: "web".to_string(),
929        });
930        let key = SessionKey::new(peer_a(), super::TCP, SessionId(0), Initiator::Local);
931        match accepted.effects.as_slice() {
932            [RelayEffect::OpenAccepted {
933                token,
934                key: k,
935                service,
936            }] => {
937                assert_eq!(*token, 42);
938                assert_eq!(*k, key);
939                assert_eq!(service, "web");
940            }
941            other => panic!("expected one OpenAccepted, got {other:?}"),
942        }
943        assert!(accepted.state.sessions.contains(&key));
944
945        let untracked = step_command(&relay, &accepted.state, &RelayCommand::Untrack {
946            peer: peer_a(),
947            session: SessionId(0),
948            initiator: Initiator::Local,
949        });
950        assert!(untracked.effects.is_empty());
951        assert!(!untracked.state.sessions.contains(&key));
952    }
953
954    #[test]
955    fn a_peer_cannot_address_another_peers_session() {
956        let relay = web_relay();
957        let a_open = step_frame(&relay, &relay.init(), peer_a(), &open(0, "web"));
958        let key_a = rkey(peer_a(), 0);
959        assert!(a_open.state.sessions.contains(&key_a));
960
961        // peer B references session 0 (same id) but never opened it here: the reducer drops
962        // both Data and Close — A's session is untouched. (Owner rejection in the core.)
963        let b_data = step_frame(&relay, &a_open.state, peer_b(), &data(0, b"x"));
964        assert!(
965            b_data.effects.is_empty(),
966            "B's Data for a session it did not open is dropped"
967        );
968        let b_close = step_frame(&relay, &a_open.state, peer_b(), &close(0));
969        assert!(
970            b_close.effects.is_empty(),
971            "B's Close for a session it did not open is dropped"
972        );
973        assert!(b_close.state.sessions.contains(&key_a));
974    }
975
976    #[test]
977    fn local_and_remote_sessions_with_the_same_id_do_not_collide() {
978        // Bidirectional open against the same peer, both id 0: a peer-opened (Remote) session
979        // and a locally-accepted (Local) session must be distinct keys.
980        let relay = web_relay();
981        let opened = step_frame(&relay, &relay.init(), peer_a(), &open(0, "web"));
982        let accepted = step_command(&relay, &opened.state, &RelayCommand::Accepted {
983            token: 1,
984            peer: peer_a(),
985            service: "web".to_string(),
986        });
987        let remote = SessionKey::new(peer_a(), super::TCP, SessionId(0), Initiator::Remote);
988        let local = SessionKey::new(peer_a(), super::TCP, SessionId(0), Initiator::Local);
989        assert_ne!(remote, local);
990        assert!(accepted.state.sessions.contains(&remote));
991        assert!(accepted.state.sessions.contains(&local));
992        assert_eq!(accepted.state.sessions.len(), 2);
993    }
994
995    // ── lifecycle property tests (reviewer-requested) ─────────────────────────────────
996
997    #[test]
998    fn open_then_close_then_data_does_not_resurrect_the_session() {
999        let relay = web_relay();
1000        let key = rkey(peer_a(), 3);
1001        let opened = step_frame(&relay, &relay.init(), peer_a(), &open(3, "web"));
1002        assert!(opened.state.sessions.contains(&key));
1003        let closed = step_frame(&relay, &opened.state, peer_a(), &close(3));
1004        assert!(!closed.state.sessions.contains(&key));
1005        // A late Data for the now-closed session is dropped by the reducer (guarded on the
1006        // session set) — no effect, no resurrection.
1007        let late = step_frame(&relay, &closed.state, peer_a(), &data(3, b"late"));
1008        assert!(late.effects.is_empty());
1009        assert!(late.state.sessions.is_empty());
1010    }
1011
1012    #[test]
1013    fn close_after_close_is_idempotent() {
1014        let relay = web_relay();
1015        let opened = step_frame(&relay, &relay.init(), peer_a(), &open(5, "web"));
1016        let c1 = step_frame(&relay, &opened.state, peer_a(), &close(5));
1017        assert!(matches!(c1.effects.as_slice(), [RelayEffect::Close { .. }]));
1018        // The second close hits no live session: the reducer drops it (no effect, no panic).
1019        let c2 = step_frame(&relay, &c1.state, peer_a(), &close(5));
1020        assert!(c2.effects.is_empty());
1021        assert!(c2.state.sessions.is_empty());
1022    }
1023
1024    #[test]
1025    fn malformed_payload_is_rejected_at_the_boundary() {
1026        let relay = web_relay();
1027        let bad = [0xFFu8, 0xFF, 0xFF, 0xFF, 0xFF];
1028        let result = relay.decode(Wire {
1029            from: peer_a(),
1030            me: this_node(),
1031            payload: &bad,
1032        });
1033        assert!(result.is_err(), "a malformed frame must be rejected");
1034    }
1035
1036    /// Property: across a long, deterministic, collision-prone interleaving of peer frames
1037    /// (Open/Data/Close) from several peers, the pure `State.sessions` never diverges from an
1038    /// independent model, and Data/Close only ever act on live sessions (reducer authority).
1039    #[test]
1040    fn lifecycle_property_state_never_diverges_from_model() {
1041        let relay = web_relay();
1042        let peers = [peer_a(), peer_b(), Did::from(4u32)];
1043        let mut state = relay.init();
1044        let mut model: HashSet<SessionKey> = HashSet::new();
1045        let mut rng: u64 = 0x2545_F491_4F6C_DD1D;
1046        let mut next = move || {
1047            rng ^= rng << 13;
1048            rng ^= rng >> 7;
1049            rng ^= rng << 17;
1050            rng
1051        };
1052
1053        for _ in 0..4000 {
1054            let r = next();
1055            let peer = peers[(r % 3) as usize];
1056            let session = (r >> 2) & 0x7; // 8 ids → frequent collisions
1057            let key = rkey(peer, session);
1058            let transition = match (r >> 8) % 4 {
1059                0 => {
1060                    let t = step_frame(&relay, &state, peer, &open(session, "web"));
1061                    if model.contains(&key) {
1062                        assert!(t.effects.is_empty(), "duplicate Open must emit nothing");
1063                    } else {
1064                        assert!(matches!(t.effects.as_slice(), [
1065                            RelayEffect::Connect { .. }
1066                        ]));
1067                        model.insert(key.clone());
1068                    }
1069                    t
1070                }
1071                1 => {
1072                    let t = step_frame(&relay, &state, peer, &open(session, "nope"));
1073                    if model.contains(&key) {
1074                        assert!(t.effects.is_empty());
1075                    } else {
1076                        assert!(matches!(t.effects.as_slice(), [
1077                            RelayEffect::SendClose { .. }
1078                        ]));
1079                    }
1080                    t
1081                }
1082                2 => {
1083                    let t = step_frame(&relay, &state, peer, &data(session, b"x"));
1084                    if model.contains(&key) {
1085                        match t.effects.as_slice() {
1086                            [RelayEffect::Write { key: k, .. }] => assert_eq!(*k, key),
1087                            other => panic!("expected one Write, got {other:?}"),
1088                        }
1089                    } else {
1090                        assert!(
1091                            t.effects.is_empty(),
1092                            "Data on an unknown session is dropped"
1093                        );
1094                    }
1095                    t
1096                }
1097                _ => {
1098                    let t = step_frame(&relay, &state, peer, &close(session));
1099                    if model.contains(&key) {
1100                        assert!(matches!(t.effects.as_slice(), [RelayEffect::Close { .. }]));
1101                    } else {
1102                        assert!(
1103                            t.effects.is_empty(),
1104                            "Close on an unknown session is dropped"
1105                        );
1106                    }
1107                    model.remove(&key);
1108                    t
1109                }
1110            };
1111            state = transition.state;
1112            assert_eq!(
1113                state.sessions, model,
1114                "State.sessions diverged from the model"
1115            );
1116        }
1117    }
1118
1119    /// A faithful in-test model of a relay engine's resource table: `key → generation`,
1120    /// mirroring `register` (insert a fresh generation), `close` (drop the current handle) and
1121    /// `close_if_current` (drop only if the generation matches, returning whether it did).
1122    /// This logic is identical in the native [`TransportSessions`] and browser `WtSessions`
1123    /// engines, so the model covers both — only the socket vs. WebTransport plumbing differs.
1124    struct EngineModel {
1125        map: HashMap<SessionKey, u64>,
1126        next_gen: u64,
1127    }
1128
1129    impl EngineModel {
1130        fn new() -> Self {
1131            Self {
1132                map: HashMap::new(),
1133                next_gen: 0,
1134            }
1135        }
1136        fn register(&mut self, key: SessionKey) -> u64 {
1137            let gen = self.next_gen;
1138            self.next_gen += 1;
1139            self.map.insert(key, gen);
1140            gen
1141        }
1142        fn close(&mut self, key: &SessionKey) {
1143            self.map.remove(key);
1144        }
1145        /// Returns whether it was the current owner (and removed it). A `false` here means the
1146        /// caller is a stale task: it must send the peer **no** `Close` either.
1147        fn close_if_current(&mut self, key: &SessionKey, gen: u64) -> bool {
1148            if self.map.get(key) == Some(&gen) {
1149                self.map.remove(key);
1150                true
1151            } else {
1152                false
1153            }
1154        }
1155    }
1156
1157    /// Apply a step's effects to the engine model, returning the generation of any handle the
1158    /// effects registered (a relay task's captured generation). Asserts `Write`/`Shutdown`
1159    /// only ever hit a live handle — i.e. the reducer, not the engine table, decided.
1160    fn apply_effects(
1161        eng: &mut EngineModel,
1162        effects: &[RelayEffect<SocketAddr>],
1163    ) -> Option<(SessionKey, u64)> {
1164        let mut registered = None;
1165        for effect in effects {
1166            match effect {
1167                RelayEffect::Connect { key, .. } | RelayEffect::OpenAccepted { key, .. } => {
1168                    registered = Some((key.clone(), eng.register(key.clone())));
1169                }
1170                RelayEffect::Write { key, .. } | RelayEffect::Shutdown { key } => {
1171                    assert!(
1172                        eng.map.contains_key(key),
1173                        "effect targeted a non-live session"
1174                    );
1175                }
1176                RelayEffect::Close { key } => eng.close(key),
1177                RelayEffect::SendClose { .. } => {}
1178            }
1179        }
1180        registered
1181    }
1182
1183    #[test]
1184    fn generation_prevents_a_slow_old_task_deleting_a_reopened_handle() {
1185        // Server session id 7 is opener-chosen, so it can be reused after close. Open → close
1186        // → reopen, then let the *old* task tear down: with generations it must not delete the
1187        // new handle (ABA safety).
1188        let relay = web_relay();
1189        let mut eng = EngineModel::new();
1190
1191        let opened = step_frame(&relay, &relay.init(), peer_a(), &open(7, "web"));
1192        let (key, gen_old) = apply_effects(&mut eng, &opened.effects).expect("registered");
1193
1194        // Peer closes; the reducer removes it and the engine drops the current handle.
1195        let closed = step_frame(&relay, &opened.state, peer_a(), &close(7));
1196        apply_effects(&mut eng, &closed.effects);
1197        assert!(!eng.map.contains_key(&key));
1198
1199        // Peer reopens the same id → a new handle with a fresh generation.
1200        let reopened = step_frame(&relay, &closed.state, peer_a(), &open(7, "web"));
1201        let (_, gen_new) = apply_effects(&mut eng, &reopened.effects).expect("registered");
1202        assert_ne!(gen_old, gen_new);
1203
1204        // The slow OLD task finally tears down with its stale generation: it must neither
1205        // remove the new handle nor (since this returns false) send the peer a `Close`.
1206        let removed = eng.close_if_current(&key, gen_old);
1207        assert!(
1208            !removed,
1209            "stale task must not remove — and so must send no peer Close"
1210        );
1211        assert_eq!(
1212            eng.map.get(&key),
1213            Some(&gen_new),
1214            "old task must not delete the reopened handle"
1215        );
1216    }
1217
1218    #[test]
1219    fn engine_model_stays_consistent_with_step_under_interleaving() {
1220        // Drive Open/Data/Close (peer frames) with *deferred* generation-checked teardowns,
1221        // asserting the pure session set and the engine model's live keys never diverge.
1222        let relay = web_relay();
1223        let peers = [peer_a(), peer_b()];
1224        let mut state = relay.init();
1225        let mut eng = EngineModel::new();
1226        let mut tasks: Vec<(SessionKey, u64)> = Vec::new();
1227        let mut rng: u64 = 0x9E37_79B9_7F4A_7C15;
1228        let mut next = move || {
1229            rng ^= rng << 13;
1230            rng ^= rng >> 7;
1231            rng ^= rng << 17;
1232            rng
1233        };
1234
1235        for _ in 0..3000 {
1236            let r = next();
1237            let peer = peers[(r % 2) as usize];
1238            let session = (r >> 2) & 0x3; // 4 ids → frequent reuse
1239            match (r >> 8) % 4 {
1240                0 => {
1241                    let t = step_frame(&relay, &state, peer, &open(session, "web"));
1242                    if let Some(task) = apply_effects(&mut eng, &t.effects) {
1243                        tasks.push(task);
1244                    }
1245                    state = t.state;
1246                }
1247                1 => {
1248                    let t = step_frame(&relay, &state, peer, &data(session, b"x"));
1249                    apply_effects(&mut eng, &t.effects);
1250                    state = t.state;
1251                }
1252                2 => {
1253                    // Peer close: reducer removes, engine drops current; the matching task is
1254                    // now stale (its later teardown will be a generation no-op).
1255                    let t = step_frame(&relay, &state, peer, &close(session));
1256                    apply_effects(&mut eng, &t.effects);
1257                    state = t.state;
1258                }
1259                _ => {
1260                    // A deferred task teardown fires. `close_if_current` returns whether this
1261                    // task was still current; ONLY then does it Untrack and (would) send the
1262                    // peer a Close. A stale task must do neither — modelled by the `removed`
1263                    // gate, so a reopened session is never torn down by an old task.
1264                    if !tasks.is_empty() {
1265                        let idx = (r >> 16) as usize % tasks.len();
1266                        let (tkey, tgen) = tasks.swap_remove(idx);
1267                        let removed = eng.close_if_current(&tkey, tgen);
1268                        if removed {
1269                            let untrack = RelayCommand::Untrack {
1270                                peer: tkey.peer,
1271                                session: tkey.session,
1272                                initiator: tkey.initiator,
1273                            };
1274                            state = step_command(&relay, &state, &untrack).state;
1275                        }
1276                    }
1277                }
1278            }
1279            // The pure session set and the engine's live keys must agree at every step.
1280            let live: HashSet<SessionKey> = eng.map.keys().cloned().collect();
1281            assert_eq!(
1282                state.sessions, live,
1283                "pure state diverged from the engine model"
1284            );
1285        }
1286    }
1287}