rings_node/extension/transport/mod.rs
1//! Transport relay — one abstraction for TCP, HTTP and (future) UDP.
2//!
3//! # Why these are *not* three protocols
4//!
5//! The rings overlay (DHT + swarm + backend envelopes) already provides a **reliable,
6//! ordered, bidirectional message channel between two DIDs** — call it the *virtual
7//! circuit*. TCP / HTTP / UDP "services" are all the **same thing**: a *relay* that
8//! maps a local I/O resource (a socket) onto that virtual circuit. They differ only in
9//! the shape of the local resource, along three axes:
10//!
11//! ```text
12//! axis TCP HTTP UDP
13//! ---------------------- ----------------- --------------------- ------------------
14//! session cardinality ω (endless stream) 1 (one req/resp, 0 (no session,
15//! affine: Req ⊸ Resp) datagrams)
16//! framing byte stream HTTP messages datagrams
17//! lifecycle open → data* → close open → 1×req → 1×resp none
18//! → close
19//! ordering / reliability ordered, reliable ordered, reliable unordered, lossy
20//! (semantics chosen
21//! when tunnelled)
22//! ```
23//!
24//! Categorically they are one structure at three points of a single "session
25//! cardinality" axis:
26//!
27//! - **TCP** = a bidirectional byte **stream** — the cofree stream / a long-lived
28//! process; cardinality **ω**.
29//! - **HTTP** = the **affine** degeneration of TCP: exactly one exchange
30//! `Request ⊸ Response` (a use-once session); cardinality **1**.
31//! - **UDP** = the **0-session** degeneration: `Datagram → [Datagram]`, a discrete
32//! transducer with no lifecycle; cardinality **0**.
33//!
34//! So adding UDP later is not a fourth subsystem — it is this axis taken to 0.
35//!
36//! # How it sits on the effect base (`backend::ext`)
37//!
38//! Pure/effect separation is preserved:
39//!
40//! - The **interpreter owns the live resources** (the `TcpStream` / `UdpSocket`), keyed
41//! by [`SessionId`], in a resource table. These are non-purifiable OS handles and so
42//! live only in the imperative shell — never in a protocol's state.
43//! - A protocol's **pure `step`** holds only session *metadata* (which `SessionId` maps
44//! to which peer/service, framing state, counters) — never a live socket.
45//! - Generic transport **effects** (run by the interpreter): stream ops
46//! `Connect` / `Write` / `Close`; datagram ops `Bind` / `SendTo`.
47//! - Local reads / accepts **re-inject** [`Frame`]s as events (the event trace of the
48//! effect monad): the read task feeds `Data` / `Close` / `Datagram` back through the
49//! router → `step` → an `Effect::Send` over the virtual circuit.
50//!
51//! TCP / HTTP / UDP are then thin instances over this one relay: TCP uses the stream
52//! ops with an ω session; HTTP adds "one request → one response → close" session logic
53//! (expressible purely in `step`); UDP uses only the datagram ops with no session.
54
55// The relay's imperative resource tables are private to the relay interpreter — not a public
56// API. Reachable in-crate by the relay extension only.
57#[cfg(rings_native)]
58pub(crate) mod engine;
59pub(crate) mod platform;
60#[cfg(rings_browser)]
61pub(crate) mod wt;
62
63use std::sync::atomic::AtomicU64;
64use std::sync::atomic::Ordering;
65#[cfg(rings_native)]
66use std::time::Duration;
67
68use bytes::Bytes;
69use rings_core::dht::Did;
70use serde::Deserialize;
71use serde::Serialize;
72
73/// Maximum silence before an abandoned local socket/flow is reclaimed.
74#[cfg(rings_native)]
75pub(crate) const RELAY_IDLE_TIMEOUT: Duration = Duration::from_secs(5 * 60);
76
77/// Allocate one counter value without ever wrapping back to a live ABA-equivalent value.
78pub(crate) fn allocate_non_reusing(counter: &AtomicU64) -> Option<u64> {
79 counter
80 .fetch_update(Ordering::Relaxed, Ordering::Relaxed, |value| {
81 value.checked_add(1)
82 })
83 .ok()
84}
85
86/// Identifier of a relayed session/flow (a virtual circuit ↔ local socket pairing).
87///
88/// TCP uses it for a connection; UDP uses it for a *flow* (a NAT-like mapping that
89/// routes responses back to the right local client) — see [`TransportKind`].
90#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
91pub struct SessionId(pub u64);
92
93/// Which end **opened** a relay session, from the perspective of the node holding the key.
94///
95/// Necessary because two nodes that simultaneously open a tunnel to each other both mint
96/// `SessionId(0)`: without an initiator, "the session I opened to peer B" and "the session B
97/// opened to me" would collide on `(peer=B, namespace, session=0)`, and a wire `Data(0)`
98/// would be ambiguous. The initiator splits the id space into two halves per `(peer,
99/// namespace)`.
100#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize)]
101pub enum Initiator {
102 /// This node opened the session (a client tunnel).
103 Local,
104 /// The peer opened the session (this node is the server).
105 Remote,
106}
107
108/// Result of applying one ordered peer-to-local transport effect without
109/// waiting for backend backpressure.
110#[derive(Clone, Copy, Debug, Eq, PartialEq)]
111pub(crate) enum EffectEnqueue {
112 /// The effect was accepted by the current backend generation.
113 Enqueued,
114 /// No backend generation exists; pure state must simply forget the key.
115 Missing,
116 /// The current backend generation failed or saturated; pure state must
117 /// forget it and notify the peer with `Close`.
118 Failed,
119}
120
121/// Maximum number of ordered peer-to-local operations retained by a browser transport session.
122#[cfg(any(test, rings_browser))]
123pub(crate) const MAX_OUTBOUND_QUEUE_OPS: usize = 1024;
124
125/// Maximum aggregate payload retained by a browser transport session.
126#[cfg(any(test, rings_browser))]
127pub(crate) const MAX_OUTBOUND_QUEUE_BYTES: usize = 8 * 1024 * 1024;
128
129/// Pure resource account for a browser transport's deferred operation trace.
130///
131/// Invariant: `operations <= MAX_OUTBOUND_QUEUE_OPS` and
132/// `data_bytes <= MAX_OUTBOUND_QUEUE_BYTES` after every successful reservation.
133#[derive(Default)]
134#[cfg(any(test, rings_browser))]
135pub(crate) struct OutboundQueueBudget {
136 operations: usize,
137 data_bytes: usize,
138}
139
140/// Pure ownership state for the single browser writer-drain task.
141#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
142#[cfg(any(test, rings_browser))]
143pub(crate) enum OutboundDrainState {
144 /// No task owns the queue; the next successful enqueue must start one.
145 #[default]
146 Idle,
147 /// Exactly one task owns the queue and will observe later enqueues.
148 Active,
149}
150
151#[cfg(any(test, rings_browser))]
152impl OutboundDrainState {
153 /// Claim an idle queue. Returns whether the caller acquired drain ownership.
154 pub(crate) fn claim(&mut self) -> bool {
155 match self {
156 Self::Idle => {
157 *self = Self::Active;
158 true
159 }
160 Self::Active => false,
161 }
162 }
163
164 /// Release drain ownership after observing an empty queue.
165 pub(crate) fn release(&mut self) {
166 *self = Self::Idle;
167 }
168}
169
170#[cfg(any(test, rings_browser))]
171impl OutboundQueueBudget {
172 /// Reserve one operation carrying `data_bytes`, or leave the budget unchanged.
173 pub(crate) fn try_reserve(&mut self, data_bytes: usize) -> bool {
174 let Some(operations) = self.operations.checked_add(1) else {
175 return false;
176 };
177 let Some(total_bytes) = self.data_bytes.checked_add(data_bytes) else {
178 return false;
179 };
180 if operations > MAX_OUTBOUND_QUEUE_OPS || total_bytes > MAX_OUTBOUND_QUEUE_BYTES {
181 return false;
182 }
183 self.operations = operations;
184 self.data_bytes = total_bytes;
185 true
186 }
187
188 /// Release one previously reserved operation, or leave the budget unchanged when the
189 /// supplied values do not refine the current state.
190 pub(crate) fn release(&mut self, data_bytes: usize) -> bool {
191 let Some(operations) = self.operations.checked_sub(1) else {
192 return false;
193 };
194 let Some(total_bytes) = self.data_bytes.checked_sub(data_bytes) else {
195 return false;
196 };
197 self.operations = operations;
198 self.data_bytes = total_bytes;
199 true
200 }
201}
202
203/// A relay session's full identity — the unit used to key live sessions and to address
204/// transport effects.
205///
206/// A bare [`SessionId`] is **not** a valid address: the id on the wire is assigned by the
207/// opener, so two ends can both pick `SessionId(0)`. The key scopes a session by `(peer,
208/// namespace, session, initiator)`, where `peer` is the **authenticated** other end
209/// (`event.from`, the verified signer) and `initiator` records which end opened it. Because a
210/// peer cannot forge `event.from`, it can only ever address sessions whose `peer` is itself
211/// (owner rejection); and `initiator` keeps a peer's session distinct from one of ours that
212/// happened to get the same id (bidirectional-open safety).
213#[derive(Clone, PartialEq, Eq, Hash, Debug)]
214pub struct SessionKey {
215 /// The authenticated remote end of the session (`event.from` for inbound frames).
216 pub peer: Did,
217 /// The transport namespace the session lives under (e.g. `tcp`, `udp`).
218 pub namespace: String,
219 /// The opener-assigned session id, unique only within `(peer, namespace, initiator)`.
220 pub session: SessionId,
221 /// Which end opened the session (disambiguates colliding ids on simultaneous open).
222 pub initiator: Initiator,
223}
224
225impl SessionKey {
226 /// Build a session key from its parts.
227 pub fn new(
228 peer: Did,
229 namespace: impl Into<String>,
230 session: SessionId,
231 initiator: Initiator,
232 ) -> Self {
233 Self {
234 peer,
235 namespace: namespace.into(),
236 session,
237 initiator,
238 }
239 }
240}
241
242/// Which local socket a relay session is backed by.
243///
244/// Both kinds share the same [`Frame`] vocabulary (`Open`/`Data`/`Close`); only the
245/// socket differs. UDP is *flow*-based rather than truly sessionless because a relayed
246/// datagram still needs a return path to the originating local client, so each flow
247/// carries a [`SessionId`] just like a TCP connection. `Data` preserves message
248/// boundaries (one datagram per frame) for UDP.
249#[derive(Clone, Copy, PartialEq, Eq, Debug)]
250pub enum TransportKind {
251 /// Connection-oriented byte stream.
252 Tcp,
253 /// Datagram flow (per-flow socket; message boundaries preserved per `Data`).
254 Udp,
255}
256
257/// The relay's overlay wire message — the payload carried under a transport namespace.
258///
259/// One vocabulary for both kinds (TCP connections and UDP flows):
260///
261/// ```text
262/// Open(session, service) → Data(session, bytes)* → Close(session)
263/// ```
264///
265/// `Open` is always sent by the session's opener. `Data`/`Shutdown`/`Close` flow in both
266/// directions over the *same* opener-assigned id, so they carry `from_opener` — whether the
267/// **sender** of this frame opened the session. The receiver flips it to recover its own
268/// [`Initiator`], so a peer's session never collides with one of ours sharing the same id.
269#[derive(Clone, Debug, Serialize, Deserialize)]
270pub enum Frame {
271 /// Open a session/flow to a named local service (always sent by the opener).
272 Open {
273 /// Session identifier (assigned by the opener).
274 session: SessionId,
275 /// Local service name to connect to.
276 service: String,
277 },
278 /// Bytes on an open session (one datagram per frame for UDP).
279 Data {
280 /// Session the bytes belong to.
281 session: SessionId,
282 /// Whether the sender of this frame opened the session.
283 from_opener: bool,
284 /// Payload bytes.
285 bytes: Bytes,
286 },
287 /// Half-close: the sender has no more `Data` this direction (a TCP FIN). The
288 /// receiver shuts down its local write side but keeps the reverse direction open.
289 /// Ignored by UDP (datagram flows have no half-close).
290 Shutdown {
291 /// Session being half-closed.
292 session: SessionId,
293 /// Whether the sender of this frame opened the session.
294 from_opener: bool,
295 },
296 /// Close a session/flow (full teardown, both directions).
297 Close {
298 /// Session to close.
299 session: SessionId,
300 /// Whether the sender of this frame opened the session.
301 from_opener: bool,
302 },
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn test_outbound_budget_preserves_both_operation_and_byte_bounds() {
311 let mut operation_bound = OutboundQueueBudget::default();
312 for _ in 0..MAX_OUTBOUND_QUEUE_OPS {
313 assert!(operation_bound.try_reserve(0));
314 }
315 assert!(!operation_bound.try_reserve(0));
316
317 let mut byte_bound = OutboundQueueBudget::default();
318 assert!(byte_bound.try_reserve(MAX_OUTBOUND_QUEUE_BYTES));
319 assert!(!byte_bound.try_reserve(1));
320 }
321
322 #[test]
323 fn test_rejected_outbound_budget_reservation_does_not_consume_capacity() {
324 let mut budget = OutboundQueueBudget::default();
325 assert!(!budget.try_reserve(MAX_OUTBOUND_QUEUE_BYTES + 1));
326 assert!(budget.try_reserve(MAX_OUTBOUND_QUEUE_BYTES));
327 }
328
329 #[test]
330 fn test_released_outbound_budget_can_be_reserved_again() {
331 let mut budget = OutboundQueueBudget::default();
332 assert!(budget.try_reserve(MAX_OUTBOUND_QUEUE_BYTES));
333 assert!(budget.release(MAX_OUTBOUND_QUEUE_BYTES));
334 assert!(budget.try_reserve(MAX_OUTBOUND_QUEUE_BYTES));
335 }
336
337 #[test]
338 fn test_invalid_outbound_budget_release_is_total_and_does_not_mutate() {
339 let mut budget = OutboundQueueBudget::default();
340 assert!(budget.try_reserve(4));
341 assert!(!budget.release(5));
342 assert!(budget.release(4));
343 assert!(!budget.release(0));
344 }
345
346 #[test]
347 fn test_outbound_drain_has_exactly_one_owner_until_empty() {
348 let mut drain = OutboundDrainState::Idle;
349 assert!(drain.claim());
350 assert!(!drain.claim());
351 drain.release();
352 assert!(drain.claim());
353 }
354
355 #[test]
356 fn test_non_reusing_allocator_is_total_at_exhaustion() {
357 let counter = AtomicU64::new(u64::MAX - 1);
358 assert_eq!(allocate_non_reusing(&counter), Some(u64::MAX - 1));
359 assert_eq!(allocate_non_reusing(&counter), None);
360 assert_eq!(counter.load(Ordering::Relaxed), u64::MAX);
361 }
362}