transport — the wire seam (L4)¶
In one paragraph
transport_t is the seam between the routing plane and one wire technology:
send framed bytes (a single buffer or a scatter-gather iovec), install a
sink for inbound frames. It never sees TLV semantics — only bytes. Implementations:
loopback_channel_t (in-process dev/test), udp_transport_t
(localhost/LAN UDP), tcp_transport_t / transport_tcp_server (reliable
TCP stream, 4-byte u32-LE length-prefix framing — the prefix is transport framing,
not part of the TLV), transport_ws_client / transport_ws_server (the
browser↔robot WebSocket keystone, RFC 6455), transport_can (SocketCAN,
classic + CAN-FD), quic_transport_t and webtransport_transport_t (the
separate libtracer_quic module, msquic-backed).
The seam¶
A transport accepts a complete frame’s bytes — a FWD frame, or a route-handle
control frame (ADVERTISE / COMPACT / HANDLE_NACK) — and emits them; inbound frames
arrive on the installed sink, which may fire on an internal transport thread. Framing
below the TLV is the transport’s own business: a datagram kind needs none, a stream
kind adds a u32-LE length ++ frame record (core/include/libtracer/transport_tcp.hpp),
a CAN kind fragments and reassembles.
The reference catalog describes the same shape — a small callback-based seam, one frame in, one frame out (10-module-catalog.md §Transport ↔ L4). What is an implementation choice rather than a described property is the concurrency: each socket transport owns a receive thread and calls the sink from it, matching how a real socket’s receive loop feeds the router. The seam’s shape is declared implementation-defined (ADR-0013 — v1 scope boundaries); two conforming nodes share the wire format, not this class.
Routing above the seam — which link a frame leaves by, how a return route is grown,
how a link is mounted under /net — belongs to the FWD router, described at
fwd-router. This page stops at the byte boundary.
Two delivery tiers¶
The sink comes in two forms and a transport declares which one it honors.
Tier |
Installed by |
Frame lifetime |
Declared by |
|---|---|---|---|
Borrowed span |
|
valid only for the callback |
the default |
Owning rope |
|
refcounted; may be kept, subroped, forwarded |
|
A transport that can hand up owning frames implements the rope-receiver seam
(ADR-0042 — refcounted receiver seam,
view delivery,
generalized to ropes by ADR-0053 — lazy rope-backed decode, view partial-path
routing):
it overrides delivers_ropes() (core/include/libtracer/transport.hpp:621) and
delivers each inbound frame as a rope_t of refcounted links over segments drawn
from a host-injected mem_backend_t. A contiguous frame is the single-link case; a
scattered one — a CAN reassembly group, a fragmented WebSocket message — crosses the
seam as the rope it already is, never a flatten copy. Ownership is the whole point:
the borrowed span dies when the callback returns, so a receiver that must outlive
the callback needs this tier.
There is deliberately no adapter that wraps a borrowed span into a rope; such a rope’s
refcounts would lie about lifetime. fwd_router_t::add_child (core/src/fwd_router.cpp:845)
therefore branches on the link’s declared capability and installs exactly one sink —
the rope form for an owning link, the span form otherwise (fwd_router.cpp:1019,893, and
fwd_router.cpp:957,964 for the peer-named bus equivalent).
Every socket transport in the tree declares the owning tier: UDP
(transport_udp.hpp:111), TCP client and server (transport_tcp.hpp:218,409),
WebSocket server and client (transport_ws.hpp:280,507), CAN
(transport_can.hpp:606), QUIC (transport_quic.hpp:153) and WebTransport
(transport_webtransport.hpp:235). The borrowed-span path is the base-class default
and the tier an out-of-tree transport gets for free.
Point-to-point links and bus links¶
A point-to-point link carries one peer, so the child NAME the router registers it
under fully addresses the far side. A bus link reaches many peers over one wire
and exposes them through the optional bus_link_t facet
(core/include/libtracer/transport.hpp:76, ADR-0044 — stateless transport peer
enumeration):
enumerate_peers synthesizes the currently-audible names from the wire’s own live
traffic, peer_link resolves one name to a directed sending endpoint, and
set_peer_receiver / set_peer_rope_receiver replace the flat sink with one that
tags each inbound frame with the sending peer’s opaque handle — peer_handle_t,
an 8-byte (index, generation) POD minted once when the peer becomes audible and
valid until it departs (#1294) — from which bus_link_t::peer_name resolves the
NAME the routing plane routes by. Names stay the ADDRESSING surface
(enumerate_peers, peer_link, close_peer), because a name is what a routable
dst segment carries; the per-frame identity is the handle, so a consumer that
wants one no longer re-derives it from a string on every frame. No vertex is created
for a peer and no peer state is stored.
transport_t::bus() returns the facet or nullptr. CAN always returns it
(transport_can.hpp:564); the TCP and WebSocket servers return it when
configured peer-named — one implementation, on the slot-server base both of them
inherit (posix_endpoint.hpp:1159); every other kind keeps the nullptr default.
That base is picked by the BUILD. slot_server_t owns the slot table and answers every
peer query off it, but it is not itself a bus_link_t; the facet — the base subobject, its
peer-named receiver slot and its two peer-lifecycle notifier pairs — lives one tier below,
in bus_slot_server_t, and the two servers derive from stream_server_base_t
(posix_endpoint.hpp:1233), which is that tier or the facet-free flat_slot_server_t
according to tr::net::kBusLinks. So on a target that closed the bus module out a listener
does not merely refuse to hand the facet out: its LAYOUT does not contain one.
Whether a link’s peer-named tier exists is one query, bus_link_t::peer_named()
(transport.hpp:193): the constructed flag for the two stream servers
(posix_endpoint.hpp:715), true by construction for a kind that is a bus outright.
bus_link_t refuses each of its peer-named wiring calls — set_peer_receiver,
set_peer_rope_receiver, set_peer_down_notifier — while it is false. That refusal matters
because bus_link_t is a public base: on a flat server the setters are reachable by an
explicit upcast past the null bus(), and admitting one used to flip the link into
peer-named delivery the null bus() had denied.
For the two stream servers, whose mode is a wiring-time choice, the same flag is the whole
answer: bus(), the per-frame tier select and the departure seam all read it, so a
peer-named server delivers only on the peer tier (an unwired one drops rather than
handing a many-peer link’s frame up untagged) and a flat one only on the flat tier. A
kind that is a bus outright keeps its own delivery precedence — CAN still falls back to the
flat sink for a single-peer consumer that wired no bus facet, which the gate does not
disturb because peer_named() is true there.
Departure follows the same split. A peer-named server evicts exactly the departed peer
(notify_peer_down(name)); a flat server has one routing identity for every peer it
carries — the registered child NAME — so its only seam is the whole link
(transport_t::notify_down), and it therefore waits until the last open session departs
(posix_endpoint.cpp:733). Firing it on a mid-life close would evict the surviving peers’
edges along with the departed one’s.
Closing the bus module out at build time¶
The peer-named tier is a module, and a node whose links are all point-to-point does not
have to carry it. tr::graph::default_config_t::kBusLinks (core/include/libtracer/config.hpp:488)
is the knob; bound false by an
ADR-0068
override fragment, not a -D:
// libtracer/config_override.hpp
namespace tr::graph {
struct flat_node_config_t : default_config_t {
static constexpr bool kBusLinks = false;
};
using config_t = flat_node_config_t;
} // namespace tr::graph
The routing plane reaches the facet through exactly one door, tr::net::bus_of
(core/include/libtracer/transport.hpp:783), and every consumer asks there: the registry’s
mount-shape stamp and its two peer-resolution paths, fwd_router_t::add_child’s peer wiring,
and the connection vertex’s synthesized peer listing. transport_t::bus() itself is
untouched — still a virtual, still nullptr by default — so a transport may still be a
bus; what the knob changes is whether anything asks. Measured on rv32 (-Os -fno-exceptions -fno-rtti, rv32imac_zicsr_zifencei/ilp32, GCC 15.2, per-TU .text): −2,078 B of
flash (fwd_router −1,400, transport_vertex −678) and ±0 B of .bss, because the tier is code and
per-instance state rather than a static table. A LIBTRACER_NET_PLANE=OFF build gains
nothing — it never compiled those translation units.
The PROVIDER side of the same fold is the base-class selection described above
(stream_server_base_t). It is what makes the saving reach a listener’s own bytes rather
than only the routing plane’s code: measured on the esp32c6 full-node profile
(-Os -fno-exceptions -fno-rtti, riscv32-esp-elf 14.2.0), a bus-closed build’s
transport_tcp_server shrinks 208 B → 168 B at rest, −40 B per listener, with a further
−568 B of image .text and ±0 B of .bss. At the default binding the listener’s
size does not move and the seam costs +80 B .text / +96 B .rodata once, for the
forwarding overrides and the two peer-lifecycle hooks.
Asking for a bus on such a build is refused, never quietly served as a flat link:
door |
refusal |
|---|---|
|
a |
|
the factory answers |
a directly constructed peer-named |
|
|
|
A quiet demotion would be the worse outcome, and specifically so: the listener’s own per-frame tier select reads its constructed mode, so a demoted-at-the-router-only server would keep delivering peer-named into a sink the router never installed.
QUIC and WebTransport¶
Both live in the separate libtracer_quic target, configured by
LIBTRACER_WITH_QUIC (core/CMakeLists.txt:272, default OFF because msquic must
be installed). Core itself contains no #ifdef and no msquic reference: the module
extends the transport catalog through register_transport_type, registering
quic_transport_factory() under kind quic and webtransport_transport_factory()
under kind webtransport.
quic_transport_t— TLS 1.3, connection migration, one bidirectional stream carrying the same length-prefix framing as TCP. The hosted, secure link; the MCU class keeps UDP and CAN (ADR-0043 — QUIC/WebTransport optional module, msquic Phase A).webtransport_transport_t— WebTransport over HTTP/3, the browser-reachable form of QUIC: a module-private minimal H3/QPACK handshake layer (core/src/wt_h3.hpp, never installed) in front of one WebTransport bidirectional stream carrying the same framing (ADR-0043 Phase B). The browser side is the TypeScripttransport-webtransportpackage (ADR-0031 — direct browser-to-robot binding and WebTransport).
One msquic dependency serves both, because QUIC is the substrate WebTransport requires.
Both kinds read four kind-private config keys off a :children[] creation SPEC —
cert/key on the LISTEN side and the DIAL-side trust pair ca/insecure. A
SPEC-created dialer verifies its peer’s certificate by default, so reaching a
self-signed peer takes one of those two keys explicitly; the key-by-key reference is
connection config.
Interface¶
using peer_id_t = std::array<std::byte, 16>; // the node identity
class transport_t {
virtual void send(std::span<const std::byte> frame) = 0;
// Scatter-gather: ship a rope's to_iovec() as one frame, no flatten copy.
// The default gathers into a temporary; native transports override
// (sendmsg/writev/RDMA SGE).
virtual void send(std::span<const std::span<const std::byte>> iov);
// Two inbound sinks, {fn, ctx} — no type erasure. ctx must outlive delivery.
using receiver_fn_t = receiver_slot_t<>::span_fn_t; // borrowed span
using rope_receiver_fn_t = receiver_slot_t<>::rope_fn_t; // owning rope
void set_receiver(receiver_fn_t fn, void* ctx) noexcept;
void set_rope_receiver(rope_receiver_fn_t fn, void* ctx) noexcept;
template <typename F> void set_receiver(F& sink) noexcept; // lvalue only
template <typename F> void set_rope_receiver(F& sink) noexcept; // lvalue only
virtual bool delivers_ropes() const; // false by default
using down_fn_t = void (*)(void* ctx);
void set_down_notifier(down_fn_t fn, void* ctx) noexcept;
virtual bus_link_t* bus(); // nullptr = point-to-point
};
class loopback_channel_t { // dev/test transport
loopback_endpoint_t& a(); loopback_endpoint_t& b(); // each a transport_t
void shutdown(); // join recv threads
};
class udp_transport_t : public transport_t {
udp_transport_t(std::uint16_t bind_port, const std::string& peer_host,
std::uint16_t peer_port,
mem::mem_backend_t* backend = &mem::heap_backend(),
std::size_t max_frame = 0, std::size_t recv_stack = 0);
// send(span) = one sendto; send(iov) = one sendmsg(iovec) — a composite rope
// in one syscall. Datagrams land in segments from `backend`; exhaustion drops
// the datagram and ticks dropped_rx. `max_frame` (the universal :settings key,
// 0 = kMaxDatagram) is the largest datagram accepted — a longer one is refused
// and ticks malformed_rx instead of being delivered, while the backend can
// furnish max_frame + 1 bytes (below that it truncates first, #1074).
};
loopback_channel_t wires two endpoints: a frame sent on one is delivered to the
other’s receiver, on that endpoint’s receive thread, modeling asynchronous
cross-wire delivery. shutdown() joins both receive threads before the registered
receivers are destroyed.
The forward hop that feeds a transport¶
What the router hands send(iov) on a forward hop is not a re-encoded frame: the
hop reads a few headers of the inbound frame by offset, builds the shortened headers
in small stack buffers, and scatter-gathers those heads with untouched views of the
inbound frame. The hop costs 0 heap allocations / 0 bytes, measured by replacing
the global operator new/delete with a counting wrapper around exactly one hop
(bench_forward_heap, single-threaded, ZEROHEAP_MAX=0 enforced in perf.yml;
ADR-0038 — net-plane performance
model).
flowchart LR
IN["inbound frame bytes"] --> PEEK["offset peek:<br/>first dst NAME"]
PEEK --> DEMUX["child_registry_t<br/>NAME → transport"]
DEMUX --> SG["stack-built heads<br/>+ untouched frame views"]
SG -->|"send(iov) — one syscall"| T(["transport_t"])
Two nodes over a wire¶
flowchart LR
FA["fwd_router A"] -->|send| EA["endpoint a"]
EA -->|enqueue| QB[("inbox B")]
QB -->|recv thread| EB["endpoint b"]
EB -->|receiver| FB["fwd_router B"]
FB -->|FWD REPLY send| EB2["endpoint b"]
EB2 -->|enqueue| QA[("inbox A")]
QA -->|recv thread| EA2["endpoint a"]
EA2 -->|receiver| FA
classDef m fill:#fef9c3,stroke:#92400e
class EA,EB,EA2,EB2 m
Consequences¶
One seam, many wires — the router and the whole stack above it are transport-agnostic; a new socket transport plugs in with no change upstream.
Deterministic testing — the loopback exercises the full encode → FWD-route → decode path with no sockets, so forward and reply behavior is unit-testable and benchable.
Bytes only — a transport cannot accidentally depend on graph semantics, because no graph type crosses the seam.
Capability, not configuration — the delivery tier is a property the transport declares, so a link that cannot honor owning delivery can never be asked to.
Pitfalls¶
A borrowed span dies at the callback’s return. Storing the span, or a
view_tbuilt over it, hands the router a dangling window on the next receive. A receiver that keeps the frame installs the rope sink and requiresdelivers_ropes().ctxmust outlive every possible delivery. The receive thread is already live when the connection opens, so a sink whose context is a local — or whose context is destroyed beforeshutdown()— races a frame already in flight. Both sinks and both notifiers must be installed before frames flow.“Before frames flow” is not free on a DIAL link. A transport that connects and spawns its receive thread in one constructor leaves no such window: the peer’s push is provoked by our own connect, so its first message can be decoded before the owner’s next statement runs, and an empty sink drops it with no counter moving (#1025).
start_receiving()is the second phase that opens the window — a no-op default, so an owner calls it unconditionally as its last wiring step;transport_ws_clientandtcp_transport_thonor it when constructed withdefer_recv(#1045), which is howtransport_vertex_tbuilds a SPEC-createdwsortcpdialer. The ESP-IDF-native WS client honors it too, in its own shape: its recv thread does the dialling, sodefer_recvholds the first dial behind thestart_receiving()latch (ADR-0081’s defer-the-dial arm, #1102) — one-shot, since reconnects happen only on an already-armed link — and the embedder passes the flag itself, there being nowsfactory on a chip target.webtransport’s DIAL side honors it as well, withdefer_rx(#1101) — it owns no receive thread to withhold, so per ADR-0081 §2 the hold is msquic’s per-stream receive window: the frame channel’s RECEIVE events consume zero bytes andstart_receiving()re-enables them, while the H3/QPACK state machine keeps consuming its own streams throughout. Nothing is buffered library-side.quicstill takes the no-op default, with the LISTEN-side gates its own follow-up (#1114).udphas no DIAL constructor of this shape — it binds an ephemeral port, never::connects and sends nothing in the constructor, so no peer can learn its source port to push to.On a bus the window needs no provocation at all.
canhas no dial to defer and no peer flow-control window to hold bytes in, and its RX callback cannot be withheld without starving the liveness bookkeeping it drives (last_heard, the pending/reassembly sweeps). Any bystander traffic already on the wire lands in the window, so the answer is ADR-0081 §4’s other arm — drop, and ticktransport_can::dropped_presink()(#1103).The callable sugar binds by address.
set_receiver(F& sink)andset_rope_receiver(F& sink)take an lvalue; a temporary lambda does not compile, and a callable destroyed early dangles exactly like a stalectx.Overriding
send(iov)is not optional for a scatter-gather wire. The base implementation gathers into a temporary buffer and, when that allocation fails, drops the frame rather than aborting (transport.hpp:457). A transport with a nativesendmsg/writevthat does not override it silently pays a copy per forward hop and inherits a drop path it did not intend.The egress gather draws from the link’s own injected store. That temporary — and the
iov_table_toverflow block the socket transports’::iovectables grow into — comes fromtransport_t::egress_source(), amem::block_source_tthe transport factory wires per link (register_builtin_transports’egress_srcargument, fed bytransport_vertex_t’s), defaulting to the process heap. Both the entry count and the byte count are the sending peer’s choice, so sizing that store is what bounds a node’s egress allocation — ADR-0079’s “bounded node is a property the deployer injects”. Exhaustion is unchanged: the frame is dropped and counted, never truncated. A store whose concurrency contract is single-threaded belongs to a link only one thread sends on.The link-down notifier is a routing seam, not a log hook. It re-enters the routing plane to evict the departed link’s subscriber edges, so it must be fired with no internal transport locks held; a connectionless kind simply never fires it.
API reference¶
The seams¶
-
class transport_t¶
A point-to-point (or bus-facet-exposing) transport link: the byte seam between the routing plane and one wire (ws/tcp/udp/quic/CAN).
The router sends complete TLV frames via send and receives them through an installed sink (set_receiver for borrowed spans, or set_rope_receiver for owning refcounted rope frames when delivers_ropes is true). A multi-peer bus link additionally exposes a bus_link_t facet via bus.
Subclassed by tr::net::loopback_endpoint_t, tr::net::quic_transport_t, tr::net::self_heal_link_t, tr::net::slot_server_t, tr::net::tcp_transport_t, tr::net::transport_can, tr::net::transport_ws_client, tr::net::udp_transport_t, tr::net::webtransport_transport_t
Public Types
-
using receiver_fn_t = receiver_slot_t<>::span_fn_t¶
The borrowed-span inbound sink fn: (ctx, frame) — the frame is valid only for the callback.
-
using rope_receiver_fn_t = receiver_slot_t<>::rope_fn_t¶
The OWNING inbound sink fn (ADR-0042, generalized to ropes per ADR-0053): (ctx, frame) — each frame is a
rope_tof refcounted links the receiver may keep, subrope, or forward — a contiguous frame is the trivial single-link case (“delivers views” and “delivers ropes” are ONE capability, not two tiers — CONTEXT.md §ingress rope delivery).
-
using down_fn_t = void (*)(void *ctx)¶
The link-down notifier fn: (ctx) — the link carries its own identity via ctx.
Public Functions
-
virtual void send(std::span<const std::byte> frame) = 0¶
Emit one frame (a complete TLV’s bytes) onto the wire.
-
inline virtual transport_drop_stats_t drop_stats() const noexcept¶
This link’s shed-frame counters — the interface-level observability seam.
The DEFAULT is all-zero, which is the honest answer for a link that counts nothing (an in-process or test stub): “no drops observed here”, never a fabricated number. A concrete transport overrides it with its own counters (#932); the per-transport accessors stay for callers that hold the concrete type.
-
inline virtual void send(std::span<const std::span<const std::byte>> iov)¶
Scatter-gather send: emit the gathered spans as ONE frame, no flatten copy.
Hand a rope’s
to_iovec()straight to the wire. The default gathers into a temporary and calls send(std::span<const std::byte>); transports with native scatter-gather (sendmsg/writev/RDMA SGE) override this to avoid the copy.- Parameters:
iov – The spans to emit, in order, as a single frame.
-
inline mem::block_source_t &egress_source() const noexcept¶
The EGRESS store this link’s per-send gather allocations draw from (ADR-0079’s net-plane failable store, #873 family 1).
Every allocation an outbound frame provokes on this link — the base send(std::span<const std::span<const std::byte>>) gather temporary above, and the
tr::net::iov_table_toverflow block of the socket transports that build a gather table — is drawn from HERE rather than from the process-widemem::heap_source(). The entry count and the byte count are both the SENDING peer’s choice (a rope’s link count x its region count), so this is the seam that makes “bounded node” a property the deployer injects (ADR-0079 §Decision 4) instead of one the library fixes: size the store and the egress path is bounded by it, with exhaustion answered the way it already is — the frame is DROPPED and counted, never truncated and neverabort().The default is the process heap, so a link nothing was wired into behaves exactly as it did before this seam existed.
-
inline void set_egress_source(mem::block_source_t &src) noexcept¶
Wire this link’s egress store — the transport-factory injection point.
Same contract as the receiver slots: call it during bring-up, BEFORE frames flow. The built-in factories apply it to every socket they construct (the
egress_srcargument ofregister_builtin_transports), which is how a deployer choosing ADR-0079’s MID composition hands the whole net plane its own store, or its NARROW fan gives each link’s own thread a contention-free one.srcmust outlive this transport.Warning
A link’s egress store is touched by EVERY thread that sends on that link, so
srcmust declare a concurrency contract covering them (block_source_t§”each source declares its own”).heap_source_tdoes; apool_source_t<sync_none_t>or abump_source_tdoes NOT, and belongs to a link only one thread ever sends on — which is the ADR-0079 NARROW shape, and the reason it is per-link rather than one node-wide store. None of the six egress sites holds a transport lock across the allocation, so a lockingsrcintroduces no lock-ordering obligation here (#1049).Warning
This setter reaches the allocations a send makes THROUGH this base — it does not re-seat a concrete link’s CONSTRUCTION-BOUND buffers. A
mem::block_array_tmember takes its source in its own constructor and keeps it for life, so a link that owns one (e.g.transport_ws_client::tx_buf_) takes the store as a CONSTRUCTOR argument and applies it to both halves there (#873). Wiring such a link only through this setter would leave that buffer on whatever source it was built with.
-
inline void set_receiver(receiver_fn_t fn, void *ctx) noexcept¶
Register the borrowed-span sink for inbound frames (the bridge’s ingest).
Must be set before frames flow; delivery may occur on an internal transport thread. The delivered span is valid only for the callback — a receiver that needs to keep the frame uses set_rope_receiver instead.
- Parameters:
fn – The inbound frame sink;
ctxis passed back as its first argument.ctx – Caller-owned context; must outlive every possible delivery.
-
template<typename F>
inline void set_receiver(F &sink) noexcept¶ Register the borrowed-span sink from a caller-owned callable.
Zero-erasure sugar over the
{fn, ctx}form:sinkis bound by address (lvalues only — a temporary would dangle) and MUST outlive every delivery.
-
inline void set_rope_receiver(rope_receiver_fn_t fn, void *ctx) noexcept¶
Register the optional OWNING inbound sink (the ADR-0042 receiver seam).
A transport that can hand up owning frames (its delivers_ropes returns true) delivers each inbound frame to the sink as a
view::rope_twhose links are refcounted views over segments drawn from a host-injectedmem_backend_t— the receiver may pin, subrope, or forward the frame beyond the callback (unlike the borrowed span of set_receiver, which dies when the callback returns). A contiguous frame arrives as a single-link rope; a scattered one (a CAN reassembly group, fragmented WS message) crosses this seam AS THE ROPE IT ALREADY IS — reassembly is chaining views, never a memcpy (ADR-0053 §5). Must be set before frames flow; delivery may occur on an internal transport thread.A span-only transport never dispatches to this sink, honestly — there is NO adapter that wraps a borrowed span into a rope whose refcounts would lie about lifetime (ADR-0042 §1). A transport that honors this seam MUST override delivers_ropes to return true, so
fwd_router_t::add_childinstalls the receiver matching the link’s capability.- Parameters:
fn – The owning frame sink;
ctxis passed back as its first argument.ctx – Caller-owned context; must outlive every possible delivery.
-
template<typename F>
inline void set_rope_receiver(F &sink) noexcept¶ Register the OWNING sink from a caller-owned callable.
Zero-erasure sugar over the
{fn, ctx}form:sinkis bound by address (lvalues only — a temporary would dangle) and MUST outlive every delivery.
-
inline virtual void start_receiving()¶
Begin delivering inbound frames — the second half of a two-phase bring-up.
Every sink above says “must be set before frames flow”, and for a transport whose receive thread starts inside its own constructor that contract is UNSATISFIABLE from the outside: the thread is already draining the socket while the owner is still installing its sinks, so a frame the peer pushes the instant the connection comes up is decoded into an empty slot and dropped — silently, with no counter moving (#1025). A DIAL connection is where that bites, because the peer’s push is triggered by our own connect. This is the window: construct (dial + handshake), install the sinks, then call this.
IDEMPOTENT, and the DEFAULT IS A NO-OP — a transport that is already receiving from its constructor has nothing left to do — so an owner may call it unconditionally on any link.
transport_vertex_t::make_connectiondoes exactly that, once the link is registered andfwd_router_t::add_childhas installed its receiver.
-
inline virtual bool delivers_ropes() const¶
The owning-delivery capability (ADR-0042 §1): true iff this transport honors set_rope_receiver by delivering refcounted rope frames.
-
inline virtual bool link_up() const noexcept¶
Liveness: true while this link can still carry frames (#1059).
The uniform PULL-side liveness query, the poll twin of set_down_notifier’s push — an owner can ask any link the same question. It is deliberately NOT the concrete types’
ok():ok()is the CAME-UP predicate (did construction — the dial, the handshake, the bind — succeed), answered once, right after construction (themake_checkedgate), and it never reverts; THIS is the runtime state, cleared by the transport’s own teardown path when its one connection dies. After a teardown the two diverge:ok()stays true (the link DID come up),link_up()answers false.The default is TRUE: a connectionless (UDP) or bus (CAN) kind has no closure concept — its link is as up as it ever is — and a multi-peer server outlives any one peer. Connection-oriented transports override it. Implementations read a relaxed atomic (or state that is already atomic): this is a hint, never a synchronisation point, and deliberately carries no is-always-lock-free assertion (one target is an rv32 core without the A extension).
-
inline virtual peer_handle_t inbound_peer() const noexcept¶
The peer HANDLE of the frame this link is delivering RIGHT NOW — the WHO seam (#375 Part 2), answerable at either setting of
peer_named(ADR-0082).The bus seam already tags each frame with its peer, so a peer-named link’s consumers never need this. A FLAT link has no such seam by design — one routing identity for every peer it carries — and ADR-0082 is explicit that the two claims are independent: a subject must be reachable at
peer_named=falseor the decouple is not real. This is the door that makes it reachable WITHOUT the addressing facet: the link states which peer the in-flight frame came from, and nothing about where that peer sits in the graph.Warning
Valid ONLY for the duration of a receive callback, read on the thread that is running it. Outside one the answer is unspecified (implementations return the last delivery’s handle or a default one); it is never a query about link state.
- Returns:
The in-flight frame’s peer, or a default-constructed (not peer_handle_t::valid) handle when this kind has no per-peer identity — the DEFAULT, so a dialer, a datagram kind and every custom transport keep today’s behaviour: the subject falls back to the inbound link’s own name.
-
inline virtual std::string_view peer_subject(peer_handle_t peer, std::span<char> scratch) const¶
The SUBJECT token of the peer
peernames — who wrote this, never where (ADR-0082 §Decision 1).Called at the resolve TERMINUS, once per locally-terminating operation, to derive the ACL caller context (ADR-0018’s pluggable subject token) and the
graph::write_ctx_ta HANDLER sees. It is deliberately NOTbus_link_t::peer_name: that is an ADDRESSING answer gated on the bus facet, and this must answer on a FLAT link too. A kind whose two answers coincide — the stream servers, whose subject is the samep<slot>session token their peer name is — implements both from one place.Warning
The token attests to NOTHING about the far end on its own: it is minted by this node’s own transport at accept, exactly as ADR-0082 §Guidance warns. An authenticated subject is the identity-layer’s job (ADR-0045); this is the per-writer discriminator the ACL evaluates until one exists.
- Parameters:
peer – The handle to resolve — typically inbound_peer’s answer.
scratch – Caller storage the token may be formatted into, at least
kPeerNameCharsbytes. The returned view points either intoscratchor at storage that outlives the call.
- Return values:
{} –
peeris not peer_handle_t::valid, or this kind mints no per-peer subject — the DEFAULT, on which the terminus falls back to the inbound link’s own name, i.e. exactly the pre-#375 caller context.
-
inline void set_down_notifier(down_fn_t fn, void *ctx) noexcept¶
Register the link-down notifier — the point-to-point half of the link-teardown eviction seam (RFC-0009 §D extended to peer departure).
The transport invokes it (possibly on an internal transport thread) when its ONE connection dies — remote hangup, protocol CLOSE, or a fatal receive error.
fwd_router_t::add_childinstalls a notifier that evicts the child’s subscriber edges and label state under the child’s registered NAME (fwd_router_t:: link_down). Must be set before frames flow, like the receivers; a connectionless kind (UDP) has no closure concept and never fires it. Fire with no internal transport locks held — the notifier re-enters the routing plane, which takes graph locks.- Parameters:
fn – The notifier;
ctxis passed back as its first argument.ctx – Caller-owned context; must outlive every possible notification.
-
inline virtual bus_link_t *bus()¶
The multi-peer (bus) capability (ADR-0044): non-null iff this link reaches many peers and exposes them via bus_link_t.
A point-to-point transport keeps the default nullptr; a bus transport (the CAN binding) returns its own bus_link_t facet, which the router and the connection vertex consult for peer resolution and peer enumeration.
Protected Functions
-
inline void notify_down() const¶
Fire the link-down notifier (no-op when none installed) — see set_down_notifier for the calling discipline.
Protected Attributes
-
receiver_slot_t rx_¶
The delivery-tier slot (the ONE tier-select mechanism, ADR-0042 / ADR-0053): adapters dispatch inbound frames through it —
rx_.deliver(view)for owning frames,rx_.deliver_borrowed(span)for borrowed ones — and key receive-buffer strategy offrx_.has_rope().
-
using receiver_fn_t = receiver_slot_t<>::span_fn_t¶
-
struct peer_handle_t¶
An opaque per-peer LINK HANDLE — the identity the peer-receiver seam carries (#1294), minted once when a peer becomes audible and valid until it departs.
The seam used to re-supply a peer NAME string on every inbound frame, which forced every consumer that wanted a per-peer identity to re-derive one from that string per frame — a hash and a map find on the subscribe path (#1266), and nothing at all to hang a per-peer auth subject off (#375 Part 2). This handle is that identity, handed down instead.
It is
(index, generation), the same node-local-index-plus-validate-on-use-stamp primitive the in-tree edge binding (#830), the RFC-0024 vref and the ESP link’s session ref already mint — a 8-byte trivially-copyable POD, cheap to copy per frame and cheap to key a table by. The two fields are OPAQUE to a consumer: only the minting link knows what an index means, and a consumer may only compare handles, hash them, and hand them back.It is not a session reference. A session ref (
httpd_ws_link_t::session_ref_t, #1146/#1262) is ONE SUPPLIER of a handle, not the handle itself: an announce-census CAN peer has no session at all and still needs a stable link key, so the handle is the general concept and the session ref produces one.It does not carry the subject. A per-peer auth subject is DERIVED from the handle at the terminus (
graph::op_resolver_t’s subject seam) rather than carried in it, which is what keeps the per-frame POD minimal (#1294 ruling 2).It is never absent on the bus seam. Every handle the peer-receiver seam hands down is
valid(): a link with no meaningful per-peer identity mintskSolePeerHandleonce at link-up and hands that down for every frame, so no consumer of that seam needs a “handle
absent” branch (#1294 ruling 3). A DEFAULT-constructed handle is still the “no peer here” value, and is what a link with no per-peer identity at all reports.
Public Functions
-
inline constexpr bool valid() const noexcept¶
True iff this handle names a peer (a zero generation never does).
-
inline constexpr std::uint64_t bits() const noexcept¶
The handle’s whole identity as one integer — the key an interning consumer (#1266) hashes, so it never has to know the field split.
Public Members
-
std::uint32_t index = 0¶
The minting link’s own peer INDEX — meaningless to anyone else.
-
std::uint32_t generation = 0¶
The validate-on-use stamp;
0is reserved to mean “no peer”.
Friends
-
friend constexpr bool operator==(peer_handle_t, peer_handle_t) noexcept = default¶
Handles compare by identity — same index AND same generation.
-
inline constexpr bool valid() const noexcept¶
-
class bus_link_t¶
The optional multi-peer (bus) capability of a transport link (ADR-0044).
A point-to-point link (ws/tcp/udp/quic) carries exactly one peer, so its child NAME fully addresses the far side. A BUS link (CAN) reaches many peers over one wire; this interface is how such a link exposes them to the routing plane with ZERO stored graph state (ADR-0044 §1 — no vertex is ever created for a peer):
enumerate_peers synthesizes, on the fly, the names of the peers currently audible on the bus (from the transport kind’s own live announce/heartbeat traffic) — the
:children[]listing of the link’s connection vertex;peer_link resolves one such NAME to a directed sending endpoint, the seam
child_registry_tfalls back to when a FWD’s nextdstsegment names no static child — so a peer name IS a routable hop segment;set_peer_receiver replaces the flat inbound sink with a peer-named one: each inbound frame arrives tagged with the sending peer’s peer_handle_t, from which peer_name resolves the hop’s inbound NAME — so the return route grown into
srcnames the bus peer to route the reply back to, symmetrically, with no per-request state.
Peer names are transport-defined but MUST be deterministic and collision-safe within the bus (the CAN binding derives them from the structured ID’s
nodefield). All three calls may race the transport’s receive thread; impls synchronize internally.The per-frame identity is the HANDLE, not the name (#1294). The name is the ADDRESSING surface — enumerate_peers, peer_link and close_peer still speak it, because a name is what a routable
dstsegment carries. The inbound seam speaks handles, because a name is a string a consumer would have to re-derive an identity from on every frame. peer_name is the one bridge between them.Subclassed by tr::net::bus_slot_server_t, tr::net::transport_can
Public Types
-
using peer_visitor_t = std::function<void(std::string_view)>¶
Visitor invoked once per currently-audible peer name.
-
using peer_receiver_fn_t = receiver_slot_t<peer_handle_t>::span_fn_t¶
The peer-named inbound sink fn: (ctx, sending peer’s HANDLE, frame bytes).
-
using peer_rope_receiver_fn_t = receiver_slot_t<peer_handle_t>::rope_fn_t¶
The OWNING peer-named sink fn (ADR-0053 §5): (ctx, sending peer’s HANDLE, the reassembled frame as the rope it already is — refcounted links the receiver may keep, subrope, or forward past the callback).
-
using peer_down_fn_t = void (*)(void *ctx, peer_handle_t handle, std::string_view peer)¶
The peer-departure notifier fn: (ctx, the departed peer’s HANDLE, its NAME). The handle is the one minted at arrival and is RETIRED by this call — after it the link may hand the same index back at a higher generation.
-
using peer_up_fn_t = void (*)(void *ctx, peer_handle_t handle, std::string_view peer)¶
The peer-ARRIVAL notifier fn: (ctx, the arriving peer’s HANDLE, its NAME). This is where the handle is MINTED, so it is also where a consumer binds whatever hangs off it (an intern slot, #1266; an auth subject, #375 Part 2).
Public Functions
-
virtual void enumerate_peers(const peer_visitor_t &visit) const = 0¶
Visit the peers currently audible on the bus (a live-traffic snapshot).
Note
Synthesized on the fly — no call allocates peer state or graph structure.
-
virtual std::string_view peer_name(peer_handle_t peer, std::span<char> scratch) const = 0¶
Resolve a peer HANDLE back to the peer NAME it addresses — the ONE bridge between the handle the inbound seam carries and the name the routing plane grows into
src(#1294).Every kind answers this as a PURE FUNCTION of the handle’s index, because every kind’s peer name already is one:
slot_server_tnames a peerp<slot>for the slot it landed in, and transport_can names onen<node>for its bus node id. So the call takes no lock, allocates nothing, and is safe to make from the delivery callback on the transport’s own receive thread — which is where the router makes it, once per inbound frame, exactly where the name used to arrive for free.Being positional, the answer is about the SLOT and not about the session that occupies it — the same distinction peer_link documents. A caller that wants the SESSION’s identity holds the handle, whose generation is what tells the two apart.
- Parameters:
peer – The handle a delivery was tagged with.
scratch – Caller-owned characters the impl MAY format into; at least
kPeerNameChars. The returned view points either intoscratchor into storage the link owns for its lifetime, so it is valid for as long as BOTH survive.
- Return values:
{} –
peeris not peer_handle_t::valid, or names no peer of this kind.
-
virtual transport_t *peer_link(std::string_view peer) = 0¶
Resolve a peer NAME to a directed sending endpoint on this bus.
The returned transport sends to THAT peer only (the bus binding’s directed framing); it is owned by this link and stays valid for the link’s lifetime.
RESOLVE PER USE — never cache the pointer across a possible departure (#1153). Pointer VALIDITY and peer IDENTITY are two different guarantees, and only the first holds for every kind. Where a kind names peers POSITIONALLY, the endpoint is scoped to the SLOT, not to the session that occupied it: after the named peer departs, a pointer resolved for it addresses whatever session inherits the slot, and the endpoint’s own liveness check is satisfied by that stranger. The pointer never dangles; it silently changes who it means. A caller that re-resolves before each send is unexposed, which is why no production caller is affected today —
child_registry_tresolves and sends in one expression, and a remote subscriber edge stores the peer NAME rather than this pointer.Which kinds are exposed follows from the naming regime alone:
IDENTITY-derived names are immune — transport_can names a peer
n<node-id>for its own bus node id, so the name, the table key and the endpoint are one identity that no other peer can inherit.POSITIONAL names are exposed — slot_server_t names a peer
p<slot>for the slot index it landed in, and slots are recycled in place.
- Return values:
nullptr –
peernames no currently-known bus peer.
-
inline virtual bool close_peer(std::string_view peer)¶
Close one peer’s connection by NAME, freeing its slot for reuse.
Tears down exactly the peer
peernames, exactly as a remote hangup would: the recycle is asynchronous (the link’s own receive loop observes the close and reclaims the slot), so enumerate_peers stops listing it shortly after this returns true. A point-to-point kind (the default) has no per-peer teardown and returns false; a bus link that supports directed teardown overrides this.- Return values:
true –
peernamed an open connection and its teardown was initiated.false –
peernames no open peer, or this kind cannot close one peer.
-
inline virtual bool peer_named() const noexcept¶
The MODE AUTHORITY: true iff this link’s peer-named tier exists (#889).
A kind that is a bus by construction (the CAN binding) keeps the default
true. A kind whose multi-peer surface is a WIRING-TIME choice — the tcp/ws listeners, constructedpeer_namedor FLAT — reports that choice here, and itstransport_t::bus()returns null for the same reason: without the facet the link keeps point-to-point hop naming, inbound frames carry the registered child NAME, andsend()fans out to every open peer.Each of the six peer-named wiring calls declared below — set_peer_receiver and set_peer_rope_receiver (both spellings each) and set_peer_down_notifier and set_peer_up_notifier — passes this gate, so a link that reports false ends up with an empty
peer_rx_and neither peer-lifecycle notifier. (A DERIVED class can still reach the protectedpeer_rx_directly; the gate governs this interface’s own doors.) It is a query, not a knob:bus_link_tis a PUBLIC base, so a flat link’sset_peer_receiveris reachable by an explicit upcast past the nullbus(), and before this gate that call silently flipped the link into peer-named delivery thebus() == nullptrcontract said did not exist.A kind whose mode is CONSTRUCTED — the tcp/ws listeners, i.e.
slot_server_t— additionally routes its per-frame tier select and its departure seam through the same flag, so for those two “which mode is this link in” has one answer. A kind that is a bus outright keeps its own delivery precedence (the CAN binding still falls back to the flat sink for a single-peer consumer that wired no bus facet), which this gate does not disturb:peer_named()is true there.Note
Cold path only (wiring frequency, ADR-0047 §4) — an implementation’s own per-frame tier select reads its stored mode directly, never this virtual.
-
inline void set_peer_down_notifier(peer_down_fn_t fn, void *ctx) noexcept¶
Register the peer-departure notifier — the bus half of the link-teardown eviction seam (RFC-0009 §D extended to peer departure).
The bus adapter invokes it (possibly on an internal transport thread) each time a peer’s session dies — remote hangup, protocol CLOSE, or a teardown initiated by close_peer — carrying the NAME the peer was audible under (the same NAME inbound frames were tagged with, i.e. the routing plane’s inbound link name for that peer).
fwd_router_t::add_childinstalls a notifier that evicts the departed peer’s subscriber edges and label state (fwd_router_t::link_down). Must be set before frames flow, like the receivers; a kind with no departure concept simply never fires it. The peer’s HANDLE rides alongside the name (#1294) so a consumer that keyed per-peer state by handle at arrival can drop it here without a name lookup.Note
REFUSED on a link that is not peer_named — a flat link’s departure is the whole link’s (
transport_t::set_down_notifier), so this wiring would be dead.- Parameters:
fn – The notifier;
ctxis passed back as its first argument.ctx – Caller-owned context; must outlive every possible notification.
-
inline void set_peer_up_notifier(peer_up_fn_t fn, void *ctx) noexcept¶
Register the peer-ARRIVAL notifier — the seam that says “this node’s own accept
policy just admitted a session”, and the boundary ADR-0044 §Decision 1 was scoped to by its 2026-08-13 amendment (#1223).
The mirror of set_peer_down_notifier, fired from the thread that observed the session become usable — for
slot_server_tthat isaccept()for a raw stream peer and the101 Switching Protocolspublish for a WS peer, i.e. exactly the transition whose inverse fires the departure notifier.Only an accepting listener fires it, and that is the whole point.
An announce-census bus (CAN, ADR-0030) learns of a peer from ANOTHER node’s traffic, has no closure event by design (RFC-0009 §D.5), and keeps §Decision 1 in full force; it therefore never fires this seam and never grows a session vertex. So “does
this kind fire peer-up” IS the announced-peer / accepted-session line, expressed as a capability rather than as a kind check at the consumer.
fwd_router_t::add_childinstalls a notifier that registers (or REVIVES) the session’s identity anchor in the graph’s vertex map, so the session gains an index and a saturating generation. Must be set before frames flow, like the receivers.Note
REFUSED on a link that is not peer_named, for the reason set_peer_down_notifier is: a flat link has one routing identity for every peer it carries, so there is no per-session identity to anchor.
- Parameters:
fn – The notifier;
ctxis passed back as its first argument.ctx – Caller-owned context; must outlive every possible notification.
-
inline void set_peer_receiver(peer_receiver_fn_t fn, void *ctx) noexcept¶
Register the peer-named inbound sink (used INSTEAD of
set_receiver).Must be set before frames flow; delivery may occur on an internal transport thread. When set, it takes precedence over a flat transport_t receiver.
Note
REFUSED on a link that is not peer_named (#889): a flat link has no peer-named tier to install into, and admitting the sink here is exactly the silent mode flip the null
bus()contract denied.- Parameters:
fn – The sink;
ctxis passed back as its first argument.ctx – Caller-owned context; must outlive every possible delivery.
-
template<typename F>
inline void set_peer_receiver(F &sink) noexcept¶ Register the peer-named inbound sink from a caller-owned callable.
Zero-erasure sugar over the
{fn, ctx}form:sinkis bound by address (lvalues only — a temporary would dangle) and MUST outlive every delivery. Routed through the{fn, ctx}overload, so the mode gate is stated once.
-
inline void set_peer_rope_receiver(peer_rope_receiver_fn_t fn, void *ctx) noexcept¶
Register the OWNING peer-named sink (ADR-0053 §5) — used INSTEAD of set_peer_receiver when the bus delivers_ropes.
A reassembling bus (CAN groups, fragmented WS) hands the frame up as the rope its reassembly already built — chained refcounted slice views, never a flatten memcpy; transport padding is trimmed by shortening the tail link. A span-only bus never dispatches to this sink (the honesty rule of
transport_t::set_rope_receiver): install per delivers_ropes.Note
REFUSED on a link that is not peer_named (#889), for the same reason set_peer_receiver is.
- Parameters:
fn – The sink;
ctxis passed back as its first argument.ctx – Caller-owned context; must outlive every possible delivery.
-
template<typename F>
inline void set_peer_rope_receiver(F &sink) noexcept¶ Register the OWNING peer-named sink from a caller-owned callable.
Zero-erasure sugar over the
{fn, ctx}form:sinkis bound by address (lvalues only — a temporary would dangle) and MUST outlive every delivery. Routed through the{fn, ctx}overload, so the mode gate is stated once.
-
inline virtual bool delivers_ropes() const¶
True iff this bus delivers OWNING ropes to the peer-named rope sink (ADR-0053 §5).
-
template<typename ...Tag>
class receiver_slot_t¶ The delivery-tier receiver slot every transport adapter shares.
Holds the two inbound sinks of the ADR-0042/ADR-0053 receiver seam — the borrowed-span sink and the owning-rope sink — as trivially-copyable
{fn, ctx}pairs, and performs the tier select on delivery: an owning frame prefers the rope sink and falls back to handing the same bytes borrowed; a borrowed frame can only ever go to the span sink (no adapter wraps a span into a rope whose refcounts would lie about lifetime, ADR-0042 §1).Thread contract: setters may race the transport’s receive thread; every deliver snapshots the pairs under the lock and dispatches OUTSIDE it (a sink may re-enter the transport). The context pointer’s lifetime is the caller’s responsibility and must cover every possible delivery.
- Template Parameters:
Tag – Extra leading sink parameters a transport tags deliveries with (e.g.
peer_handle_t— a bus link’s sending-peer handle, #1294).
Public Types
Public Functions
-
inline void set(span_fn_t fn, void *ctx) noexcept¶
Install (or clear, with nullptr) the borrowed-span sink.
- Parameters:
fn – The sink;
ctxis passed back as its first argument.ctx – Caller-owned context; must outlive every possible delivery.
-
inline void set_rope(rope_fn_t fn, void *ctx) noexcept¶
Install (or clear, with nullptr) the owning-rope sink.
- Parameters:
fn – The sink;
ctxis passed back as its first argument.ctx – Caller-owned context; must outlive every possible delivery.
-
inline bool has_rope() const noexcept¶
True iff an owning-rope sink is currently installed.
The receive-loop strategy query: a transport that must choose its buffer strategy BEFORE the blocking read (recv into a refcounted segment vs a borrowed scratch) keys it off this, per iteration.
-
inline bool has_any() const noexcept¶
True iff ANY sink (span or rope) is currently installed.
The precedence query for transports with two slots (a bus link’s peer-named slot vs the flat
transport_tslot): deliver to the higher-precedence slot iff it has a sink, else fall back.
-
inline void deliver(Tag... tag, view::view_t frame) const¶
Deliver one OWNING frame — the tier select.
Prefers the rope sink (the frame crosses as a single-link rope the sink may pin, subrope, or forward); with only a span sink installed, hands the same bytes borrowed (the view is released when the call returns). No sink installed drops the frame.
- Parameters:
tag – The transport’s delivery tags (the
Tag...pack).frame – The frame, narrowed to its exact length, owning its segment.
-
inline void deliver_rope(Tag... tag, view::rope_t frame, mem::mem_backend_t &backend = mem::heap_backend()) const¶
Deliver one OWNING frame that is already a rope (a reassembling bus’s group — chained slice views, never a flatten).
The rope sink takes it as-is (zero-copy). A span-only sink needs contiguous bytes: a single-link rope hands its bytes borrowed (zero-copy); a multi-link rope pays ONE materialize into
backend— the span tier’s honesty cost, never the rope tier’s. A REFUSED materialize (an OOM, or a DEVICE link the CPU cannot read) DROPS the frame (#917): before the refusal had a name, its empty view was handed to the span sink as though those were the frame’s bytes — a truncated frame reported as a complete one.- Parameters:
tag – The transport’s delivery tags (the
Tag...pack).frame – The reassembled frame as the rope it already is.
backend – Where a span-only fallback materializes a multi-link rope.
-
inline void deliver_borrowed(Tag... tag, std::span<const std::byte> frame) const¶
Deliver one BORROWED frame — span sink only, by construction.
A borrowed span cannot become an owning rope (ADR-0042 §1), so an installed rope sink is honestly ignored here; transports that can hand up owning frames use deliver instead.
- Parameters:
tag – The transport’s delivery tags (the
Tag...pack).frame – The frame bytes, valid only for the duration of the call.
-
enum class tr::net::link_state_t : std::uint8_t¶
The connection vertex’s link-liveness value (RFC-0014 §4).
The 1-byte VALUE a connection vertex stores —
await-able and subscribable, so asubscribe /net/<module>/<name>streams every transition (assign-then-deliver under RFC-0008 §D). Supersedes the binary up/downset_link_state(name, bool). The six states are RFC-0014 §4’s table, in table order; the byte encoding becomes normative on the S7 conformance-vector merge (the RFC defers it, so these values are the reference encoding until then).DORMANTkeeps the old “down”0x00so a resting link stays the falsy default.DIAL links move through
DORMANT/DIALING/RECONNECTING/UP; LISTEN links report listen-socket reachability asLISTENING/BIND_FAILED(never per-accepted-peer). The DIAL transitions are driven by the RFC-0014 S5 liveness engine (tr::net::self_heal_link_t, #492) for kinds registered withtransport_kind_traits_t::self_heal_dial; everywhere else the value is still set manually (eagerly-constructed sockets reportUP/LISTENINGat creation; provided links report via transport_vertex_t::set_link_state).Values:
-
enumerator DORMANT¶
DIAL: vertex exists; no socket (refcount 0).
-
enumerator DIALING¶
DIAL: a connect attempt is in flight.
-
enumerator RECONNECTING¶
DIAL: retrying toward
UPbetween backoff waits.
-
enumerator UP¶
DIAL: socket connected, bidirectional.
-
enumerator LISTENING¶
LISTEN: listen socket bound and accepting.
-
enumerator BIND_FAILED¶
LISTEN: the listen socket could not bind.
-
enumerator DORMANT¶
-
struct transport_kind_traits_t¶
Per-kind CAPABILITY declarations a transport factory registers with (RFC-0014 §4, S5) — properties of the KIND, not of one connection, so they live in the factory catalog and never on the shared conn_settings_t (the ADR-0043 §5 leanness ruling protects that record; this struct is the catalog’s row, not the SPEC’s).
The defaults preserve every existing registration: a kind registered through the traits-less overload keeps today’s eager-construction behaviour exactly.
Public Members
-
bool self_heal_dial = false¶
Opt this kind’s DIAL connections into the RFC-0014 §4 S5 liveness engine (tr::net::self_heal_link_t).
When set, a DIAL creation constructs NO socket: the vertex is minted
DORMANTand the engine dials on demand (any op auto-wakes it, bounded byconnect_timeout), self-heals withbackoffwhile a standing binding holds it, and closes the socket back to dormant on the last release. The kind’s factory is then run once per dial attempt — it must be re-runnable (every built-in socket factory is). LISTEN connections of the same kind are untouched (RFC-0014 §4: a LISTEN link ignores refcount; it binds eagerly at creation as before).Only for POINT-TO-POINT, connection-oriented kinds: a bus kind (CAN) must keep the default — the engine has no socket at creation, so the router’s bus-facet wiring (
bus_ofat add_child) would never see the facet.
-
bool delivers_ropes = false¶
The kind’s delivery capability (
transport_t::delivers_ropes), declared statically because the engine must answer it forfwd_router_t::add_childBEFORE any socket exists. Ignored unless self_heal_dial is set.
-
bool self_heal_dial = false¶
-
class self_heal_link_t : public tr::net::transport_t¶
The RFC-0014 §4 S5 liveness engine over one owned DIAL connection — a
transport_twhose inner socket is constructed, healed, and closed by the engine itself (#492).transport_vertex_tmints one of these instead of running the kind’s factory when the kind was registered withtransport_kind_traits_t::self_heal_dialand the connection’s role isDIAL. The engine owns the factory (a copy), the parsed universal settings, and the SPEC’s raw config bytes, so it can re-run construction on every dial — creation itself constructs NO socket (the vertex is mintedDORMANT, RFC-0014 §4’s refcount-0 resting state).The state machine (DIAL subset of
link_state_t, published to the connection vertex through the installed liveness publisher — the engine is the sole writer of these transitions):DORMANT→DIALING: any op auto-wakes the link (send blocks for ONE connect attempt, bounded byconnect_timeout, then serves or drops — the §4 stall-on-dial), and acquire kicks the same wake without blocking.DIALING→UPon a successful construct; →RECONNECTING(a standing binding holds) or back toDORMANT(none does — a lone one-shot’s failed dial triggers NO background retry, §4’s transient-hold rule) on a failed one.UP→RECONNECTINGon socket loss with refcount > 0: the self-heal retry loop — an attempt perbackoffinterval, FOREVER (no give-up bound and no terminal state, the §4 no-synthetic-limits ruling). Ops on aRECONNECTINGlink fail fast (dropped and counted), never block on a dead peer.UP→DORMANTon socket loss with refcount 0, and on the LAST release (§4: refcount → 0 → close socket, go dormant, stop retrying).
The refcount counts STANDING bindings (acquire / release — the seam the routing plane’s subscription/await integration drives; S6 wires the callers). A one-shot op’s transient hold is implicit in send itself: it wakes a dormant link and rides the attempt, and its release is invisible at this seam (send is fire-and-forget), so an op-woken socket with no standing binding stays up until loss rather than being torn down per-op. That keep-up is the MAY of §4.1 (Amendment 1, 2026-08-21): the amendment leaves the close-on-transient-release question to the implementation, and this engine exercises the keep-up arm, because a dial per one-shot op is exactly the hidden-handshake latency the RFC’s own §Alternatives rejects. The three §4.1 MUSTs are what this engine is held to, and all three hold here: no background retry at refcount 0, re-dormant with no retry on loss (or on a failed wake-dial) at refcount 0, and close-plus-re-dormant on the last STANDING release.
Threading. One worker thread per engine, started lazily on the first transition and joined by stop / the destructor; it is the only thread that dials and the only publisher of liveness, so transitions publish in order. Dead sockets are reaped off the notifier thread (a socket’s down-notifier fires ON its own receive thread, which its destructor joins — reaping in place would self-deadlock). The engine takes no lock of
transport_vertex_tor the router; the publisher writes the graph vertex directly, so the owner may hold its control mutex while joining this engine (stop()), and the declared lock order is never entered backwards.Note
Engine-managed kinds are POINT-TO-POINT: bus is nullptr by construction (there is no socket to ask at creation, and a bus kind must not be registered
self_heal_dial— its peer facet would be invisible to the router’s bus wiring).Public Types
-
using liveness_publish_fn_t = std::function<void(link_state_t)>¶
The liveness sink the engine publishes every transition through — installed once by the owner (a write of the 1-byte
link_state_tVALUE to the connection vertex), before the link is wired into the router.
Public Functions
-
self_heal_link_t(transport_vertex_t::transport_factory_t factory, conn_settings_t settings, std::vector<std::byte> raw_config, bool inner_delivers_ropes)¶
Bind the engine over
factorywith the connection’s creation-time config.- Parameters:
factory – The kind’s transport factory (copied; re-run on every dial).
settings – The parsed universal settings.
backoff_ms/connect_timeout_msof 0 are resolved to the engine defaults HERE, so the factory and the engine see the same effective values (RFC-0014 §4: config overrides the engine’s defaults).raw_config – The SPEC’s
configSETTINGS TLV, re-encoded to owned bytes (empty = the SPEC carried none): the kind-private keys the factory re-parses on each dial.inner_delivers_ropes – The kind’s delivery capability (
transport_kind_traits_t::delivers_ropes): the engine must answer delivers_ropes BEFORE any socket exists, becausefwd_router_t::add_childinstalls the matching receiver on the ENGINE exactly once, at registration.
-
void set_liveness_publisher(liveness_publish_fn_t fn)¶
Install the liveness publisher — call after the connection vertex exists and BEFORE the engine is wired into the router (no transition can fire earlier).
The engine’s worker invokes it with no engine lock held, so the sink may take graph locks freely; it must tolerate a write to an already-retired vertex (teardown stops the worker first, but the sink is the safety net).
-
void acquire()¶
A STANDING binding takes its hold (RFC-0014 §4: a routed subscription or
awaitthat needs the peer reachable).Non-blocking: a dormant link is kicked toward
UP(the worker dials); the caller that must WAIT forUPusesawaiton the connection vertex (S6’s verb). While refcount > 0 the engine self-heals on loss, forever.
-
void release()¶
The standing binding releases its hold.
The LAST release closes an
UPsocket and re-dormants the link, and stops an in-flight self-heal at its next gate (§4: refcount → 0 → close socket, go dormant, stop retrying). Unbalanced releases are ignored.
-
void stop()¶
Stop the engine: join the worker, tear down every socket. Idempotent.
transport_vertex_t::remove_connectioncalls this BEFORE retiring the connection vertex, so no liveness write can land on a retired vertex; after it returns the engine publishes nothing and send drops everything. A dial attempt in flight is waited for (the factory’s own connect deadline bounds the wait).
-
link_state_t state() const¶
The engine’s current liveness state (the owner/test introspection door).
-
virtual void send(std::span<const std::byte> frame) override¶
Emit one frame — the §4 op door.
UPsends on the inner socket;DORMANTauto-wakes (blocks for ONE attempt, bounded byconnect_timeout) then sends or drops;RECONNECTINGfails fast. Every drop counts in drop_stats.
-
virtual void send(std::span<const std::span<const std::byte>> iov) override¶
Scatter-gather twin of send(std::span<const std::byte>) — same gate.
-
inline virtual bool delivers_ropes() const override¶
The kind’s delivery capability, answered for the router at registration time (see the constructor’s
inner_delivers_ropes).
-
virtual bool link_up() const noexcept override¶
Runtime liveness (#1059): true iff the engine is
UP.
-
virtual transport_drop_stats_t drop_stats() const noexcept override¶
The CURRENT socket’s counters plus the engine’s own fail-fast drops (
dropped_tx). A healed link’s previous socket takes its counts with it.
-
using tr::net::peer_id_t = std::array<std::byte, 16>¶
The POSIX scaffold¶
-
class posix_endpoint_t¶
The shared recv-thread scaffold of the POSIX socket transports.
A protected base (inherited privately by the concrete transports) owning the
stop_flag and the receive thread, plus the socket-timeout/poll idioms that make a blocking loop shutdown-responsive: every blocking wait is bounded to 100 ms (SO_RCVTIMEO orpoll(2)), after which the loop re-checksstop_.Teardown invariant (derived destructors): call stop_and_join FIRST, before releasing ANY resource the thread body touches (sockets, receivers, buffers) — the thread may be mid-loop until the join returns.
Stream transports (tcp / ws) layer the shared one-peer fd/teardown discipline on top via stream_endpoint_t — the write-serialization and teardown-under-write-lock invariants live there, with the code.
Subclassed by tr::net::stream_endpoint_t, tr::net::udp_transport_t
Protected Functions
-
~posix_endpoint_t()¶
Joins a still-running thread as a last resort.
Derived destructors must have called stop_and_join already (see the teardown invariant above) — by the time this runs, derived members the thread touches are gone. The defensive join only covers a derived class that never spawned a thread or already joined it (both no-ops).
-
void start(std::function<void()> body, std::size_t stack_size = 0)¶
Spawn the receive thread running
body.Call at most once, after the socket is up and every resource
bodytouches is initialized.bodymust poll stop_ (directly or via the bounded waits below) and return promptly once it is set. Usually that is the derived constructor; a transport offering the two-phase bring-up (transport_t::start_receiving— the owner installs its sinks BEFORE any frame can be decoded) calls it from there instead, and owns the one-shot latch that keeps “at most once” true.Spawns via
pthread_create(notstd::thread): the constructor of the latter THROWS on failure, which under-fno-exceptions(the MCU build)std::aborts — a thread-spawn OOM on a starved node would bring the whole process down instead of soft-failing.pthread_createreturns an error code; a failed spawn leaves the endpoint simply not receiving (no abort).- Parameters:
body – The thread body (the transport’s accept/recv loop).
stack_size – Recv-thread stack size in bytes, or 0 for the platform default (the ONLY value that preserves prior behavior). A non-zero hint is applied via
pthread_attr_setstacksize, honored by glibc AND the ESP-IDF pthread layer (where it maps to the FreeRTOS task stack) — the portable knob that lets an integrator right-size this thread instead of inflatingCONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULTfor every pthread in the system. A hint below the platform floor is ignored (the default stack is used) rather than failing the spawn.
-
void stop_and_join()¶
Request shutdown and join the receive thread (idempotent).
Sets
stop_and joins the thread if one is running. MUST be the FIRST act of every derived destructor — only after it returns may the destructor release the resources the thread body touches.
Protected Attributes
-
std::atomic<bool> stop_ = {false}¶
The shutdown flag every blocking loop polls (set by stop_and_join; read with relaxed order — it is a flag, not a synchronizer; the join provides the ordering).
Protected Static Functions
-
static void set_rcv_timeout(int fd)¶
Arm the 100 ms receive timeout (SO_RCVTIMEO) on
fd.The idiom that keeps a blocking
recv/recvfromloop shutdown- responsive: each blocked read wakes within 100 ms so the loop can re-checkstop_and resume (or exit) — one home for the constant.- Parameters:
fd – The socket to arm.
-
static void set_snd_timeout(int fd)¶
Arm the bounded SEND timeout (
SO_SNDTIMEO,kBoundedWaitMs) onfd(#838).The egress twin of set_rcv_timeout, and the syscall-level half of the #838 fix: without it a
send/sendmsginto a peer whose TCP receive window is full blocks INDEFINITELY — with the write mutex held — so one stalled-but-not-dead peer freezes the sending application thread and everything queued behind it.The option is deliberately the short 100 ms quantum rather than the policy bound: it is what makes each blocked syscall RETURN so the software deadline (stream_endpoint_t::write_all_iov’s
bound_ms, derived from the liveness window) can be observed. Putting the policy bound on the socket instead would bound each syscall but not the record, since a stream write may need several.- Parameters:
fd – The socket to arm.
-
static int poll_readable(int fd)¶
One bounded readability wait:
poll(2)for POLLIN with a 100 ms timeout onfd.The poll-flavored twin of set_rcv_timeout for loops that wait before reading. Returns the raw
poll(2)result —> 0readable,0timeout (re-checkstop_and continue),< 0error.- Parameters:
fd – The socket to wait on.
- Returns:
The
poll(2)return value.
-
static int poll_accept(int listen_fd)¶
One iteration of the poll-100ms-recheck accept loop.
Waits up to 100 ms for
listen_fdto become readable, then accepts. Returns the accepted fd, or -1 on timeout / poll error / accept failure — the caller’s loop simply continues, re-checking stop_ each pass.- Parameters:
listen_fd – The bound+listening socket.
- Returns:
The accepted connection fd, or -1 when there is none this pass.
-
~posix_endpoint_t()¶
The full-write helpers below report how one record’s write ended, so a stalled peer can be counted and closed rather than blocked on forever (#838):
-
enum class tr::net::write_outcome_t : std::uint8_t¶
How a full-record write to one peer ended (#838).
The stream transports need more than “it returned”: a record that only PARTLY reached the socket has desynced that stream’s framing permanently (every later byte parses under the wrong length), which is a different fault class from a record that never started — and both are different from the peer simply being gone.
Values:
-
enumerator COMPLETE¶
Every byte of the record reached the socket.
-
enumerator STALLED¶
The send bound expired with the record unfinished — the peer is not taking bytes.
-
enumerator FAILED¶
Abandoned: the socket is dead (#66 lifecycle), or the call itself was rejected past its one re-attempt (counted in
write_fault_stats).
-
enumerator COMPLETE¶
-
struct write_result_t¶
The result of one full-record write — its outcome plus whether the stream survived it (#838).
Public Members
-
write_outcome_t outcome = write_outcome_t::COMPLETE¶
How the write ended.
-
bool partial = false¶
True when SOME but not all of the record reached the socket.
On a live connection this is a permanent framing desync, so it condemns the session IMMEDIATELY, bypassing the
kMaxConsecutiveStallsstreak — the same rule #837’s short-write guard applies on the MCU (a different fault class: the stream is broken, not a frame missing). Meaningless once the socket is dead.
-
write_outcome_t outcome = write_outcome_t::COMPLETE¶
-
class stream_endpoint_t : protected tr::net::posix_endpoint_t¶
The one-peer fd/teardown discipline every POSIX STREAM transport shares (tcp dial+listen, ws server, ws client).
Owns the live peer fd (conn_fd_) and the write mutex (write_m_), and is the ONE home of the invariants that keep a concurrent
send()and the recv thread’s connection teardown safe against each other:Write-serialization invariant: every write to the peer fd happens with write_m_ held across the WHOLE write — so (a) two senders can never interleave their records on the stream, and (b) the recv thread cannot close and reset the fd underneath an in-flight write.
send()reads conn_fd_ INSIDE the lock, pairing with the teardown below.Teardown-under-write-lock invariant: a recv thread that closes the peer fd MUST reset conn_fd_ to -1 under write_m_ BEFORE
close(2)(teardown_peer) — so a sender never writes to (or reads) a closed/reused fd.The one-peer accept loop shape (poll-100ms-recheck accept → per-peer setup → serve → teardown → re-accept) shared by tcp’s LISTEN mode and the ws server lives here too (run_accept_loop). A protected base, inherited privately by the concrete stream transports; udp stays on plain posix_endpoint_t — a datagram socket has no per-peer fd to tear down and its single-syscall sends need no serialization.
Subclassed by tr::net::slot_server_t, tr::net::tcp_transport_t, tr::net::transport_ws_client
Protected Functions
-
~stream_endpoint_t()¶
Closes a leftover peer fd (one the recv thread never tore down).
Runs AFTER the derived destructor, whose first act was stop_and_join (the posix_endpoint_t teardown invariant) — so no thread can race this. A normally-torn-down connection already reset conn_fd_ to -1 and this is a no-op; it only catches a never-spawned thread (a failed dial / handshake left the fd parked) so nothing double-closes.
-
bool note_write_result(const write_result_t &r, int fd, std::uint8_t &streak)¶
Account one finished write and condemn a peer that keeps stalling (#838).
The per-class policy of the #838 ruling, at the one seam every stream sender passes through. A STALLED record is never silently dropped: it is counted (stalled_tx_, and the caller’s own
dropped_tx_via the return value) so the shed frame is visible to an observer, and the peer that caused it accrues a strike. The peer is then CLOSED —shutdown(SHUT_RDWR), which takes effect on this line, needs no cooperation from the stalled socket, makes every later write fail at once and raises the readable-at-EOF the recv/poll thread turns into the ordinary remote-departure teardown (the same path slot_server_t::close_peer uses) — in two cases:write_result_t::partial: the record half-reached the wire, so this stream’s framing is desynced permanently. Immediate, bypassing the streak.streakreachingkMaxConsecutiveStalls: the peer is broken, not busy.
Call with write_m_ held (it reads the fd and mutates
streak, both of which that lock guards) and with the fd the record was written to.- Parameters:
r – The write’s result.
fd – The peer socket the record went to.
streak – The peer’s consecutive-stall count, updated in place (reset by any completed record).
- Return values:
true – The frame was SHED — the caller ticks its own
dropped_tx_.
-
void send_all_locked(std::span<const std::byte> bytes)¶
Write
bytesto the live peer as one serialized record.The whole write-serialization invariant in one call: takes write_m_, reads conn_fd_ inside the lock, and write_all s the bytes. No-op while no peer is connected.
- Parameters:
bytes – One complete encoded record’s bytes.
-
void teardown_peer(int fd)¶
Tear the peer connection down (recv-thread side).
The teardown-under-write-lock invariant as code: resets conn_fd_ to -1 under write_m_, THEN
close(2)on the fd — a concurrentsend()either finished against the still-open fd or reads -1 and no-ops.- Parameters:
fd – The peer fd the recv loop was serving.
-
void run_accept_loop(int listen_fd, const std::function<bool(int)> &on_accept, const std::function<void(int)> &serve_peer)¶
The one-peer accept loop (tcp LISTEN / ws server shape).
Until
stop_: one poll-100ms-recheck accept pass (poll_accept); on a new connection runon_accept(per-peer setup — socket options, handshake; return false to reject: the fd is closed and the loop re-accepts), publish the fd to conn_fd_, runserve_peer, then teardown_peer and re-accept the next peer.- Parameters:
listen_fd – The bound+listening socket.
on_accept – Per-peer setup; false rejects the connection.
serve_peer – The per-connection recv loop; returns on peer departure or
stop_.
Protected Attributes
-
std::atomic<int> conn_fd_ = {-1}¶
The live peer connection (-1 = none).
-
std::uint32_t liveness_window_ms_ = 0¶
The injected peer liveness window, ms (0 =
kDefaultLivenessWindowMs) — the number every per-record send bound on this endpoint derives from (derive_send_bound_ms). Set once at construction, read-only after.
-
std::atomic<std::uint64_t> stalled_tx_ = {0}¶
Records shed because their send bound expired (#838) — the “how many frames
did a stalled peer cost us” counter, distinct from the other
dropped_tx_causes. Relaxed: a diagnostic tally, not a synchronizer.
Protected Static Functions
-
static write_result_t write_all(int fd, std::span<const std::byte> bytes, std::uint32_t bound_ms = 0)¶
Write
bytestofdcompletely, resuming partial writes.A stream write may stop anywhere; loops
send(2)(MSG_NOSIGNAL — a vanished peer must not SIGPIPE the process) until done. A signal that interrupts the blocked write before any byte moved (EINTR) is RESUMED, not abandoned — the connection is healthy, and a partial frame left on a live framed stream would desync the peer’s framing permanently (#903). A socket-dead errno drops the rest silently (link-down is #66 lifecycle); every OTHER errno means the call itself was malformed, is re-attempted once and counted inwrite_fault_stats()rather than mistaken for a disconnect (#948). The caller holds write_m_ per the write-serialization invariant.A peer that stops taking bytes is bounded by
bound_ms(#838): the record is abandoned once the deadline passes, and write_result_t says whether the stream survived it. The caller holds write_m_ for the whole call, so this bound is also the bound on that lock hold — which is the actual defect #838 fixes, since an unbounded write under the mutex froze every other sender on the link too.- Parameters:
fd – The destination fd; a negative fd is a no-op.
bytes – The bytes to write.
bound_ms – Deadline for the WHOLE record, ms; 0 = unbounded (the pre-#838 behaviour, kept for sockets with no
SO_SNDTIMEOarmed — there a blocked write never returns to observe a deadline anyway).
- Returns:
How the write ended.
-
static write_result_t write_all_iov(int fd, std::span<const ::iovec> vec, std::uint32_t bound_ms = 0)¶
Write the gathered
vecentries tofdcompletely as ONE record, resuming partial writes — the zero-copy scatter-gather twin of write_all.sendmsg(2)(MSG_NOSIGNAL — a vanished peer must not SIGPIPE the process) emits every iovec in one syscall; a stream write may stop anywhere, so the loop resumes from the first unwritten byte.vecis READ-ONLY (#932): the gather is NOT consumed, so the same array may be fanned to many fds with no per-fd copy — the resume path finishes a partially-written entry with a plain write_all and re-gathers from the next entry boundary, which needs no mutable copy of the caller’s array and no scratch store on the egress path. EINTR resumes, a socket-dead errno drops the rest silently, and any other errno is a malformed call that is re-attempted once and counted — the same ONE write-fault policy as write_all (#903 / #948; link-down is #66 lifecycle). The caller holds write_m_ per the write-serialization invariant.The
bound_msdeadline covers the WHOLE record, resume path included, exactly as in write_all (#838).- Parameters:
fd – The destination fd; a negative fd is a no-op.
vec – The entries to gather, in order, as ONE record.
bound_ms – Deadline for the whole record, ms; 0 = unbounded (see write_all).
- Returns:
How the write ended.
-
~stream_endpoint_t()¶
-
class slot_server_t : public tr::net::transport_t, protected tr::net::stream_endpoint_t¶
The MULTI-peer slot/poll machinery every stream SERVER shares (transport_tcp_server, transport_ws_server) — one listener, N recycled peer slots, one poll thread (#871).
The tier above stream_endpoint_t — that one owns a single peer fd, this one owns a VECTOR of them. Everything the two servers used to restate line-for-line lives here exactly once — the slot struct and its threading rule, the bind/listen/getsockname bring-up, the free-slot-or-grow accept with its
max_peersrefusal andp<slot>naming, the poll loop, the two-phase teardown, the peer query trio, and the broadcast’s pristine-iovec-copy-per-peer fan-out. Only the FRAMING and the HANDSHAKE differ between the two servers, and those are the variance points below (themsquic_endpoint_tshape: runtime virtuals, appropriate per ADR-0047 §4 because peer arrival/departure is wiring-frequency, not hot path).**It is NOT a bus_link_t** (#1438, the provider half of #375 deliverable 3). The queries a bus facet needs are all here — they are questions about the slot table, which exists either way — but the FACET itself (the base subobject, its
peer_rx_slot, its two peer-lifecycle notifier pairs and their vtable entries) lives one tier down in bus_slot_server_t, so a build that closed the ADR-0044 bus module out carries a listener whose LAYOUT does not contain it. Concrete servers derive fromtr::net::stream_server_base_t, which is that arm or flat_slot_server_t according to tr::net::kBusLinks.Slot threading rule, ONE rule for both halves of a slot’s lifecycle: session_base_t::fd / session_base_t::open are atomics MUTATED only under
write_m_— accept publishes them (fd FIRST, so “open ⇒ fd
valid” is an invariant, #891), teardown resets them (open first) — and read by senders under that same lock, so a sender never sees a half-published slot.
session_base_t::name is guarded by peers_m_; every protocol buffer a slot carries is poll-thread-only. The destructor’s closing sweep is the one mutation outside the lock and runs after the poll thread is joined. Every access to the two atomics isrelaxed: the lock, not the memory order, is what orders them. Lock order where nested: peers_m_ →write_m_.Warning
A derived destructor MUST call
stop_and_join()as its FIRST act: the poll thread dispatches the variance points below into the derived object, which must still be alive when it does.Subclassed by tr::net::bus_slot_server_t, tr::net::flat_slot_server_t
Variance points (runtime virtuals — ADR-0047 §4 wiring-frequency).
-
virtual std::unique_ptr<session_base_t> make_session() = 0¶
Allocate one fresh slot of the derived server’s session type, with its session_base_t::peer_endpoint facade wired to this server.
Called under peers_m_ when no free slot exists and the cap allows growth.
-
virtual bool on_accept(session_base_t &s, int fd) = 0¶
Per-accept setup: socket options and the slot’s protocol buffers, run after the slot is named and before its fd is published.
- Parameters:
s – The slot being admitted (named, not yet published).
fd – The accepted socket.
- Returns:
The slot’s INITIAL
openvalue — true where the protocol has no handshake (a raw stream peer is open the moment it is accepted), false where the session only carries frames past a handshake the framing hook completes (WS holdsopenuntil its 101 is on the wire).
-
virtual void on_readable(session_base_t &s, const std::byte *data, std::size_t len) = 0¶
Per-readable-chunk framing: hand
lenbytes just read offs‘s socket to the derived server’s reassembler (or its handshake parser).Runs on the poll thread with no transport lock held. The hook owns the decision to teardown_slot on a framing violation; a peer that simply closed is torn down by the caller before this is reached.
- Parameters:
s – The slot the bytes arrived on.
data – The chunk (borrowed; valid only for this call).
len – The chunk length, always > 0.
-
virtual void on_slot_reset(session_base_t &s) = 0¶
Reset the slot’s protocol buffers as it is recycled (teardown side).
- Parameters:
s – The slot being freed; its fd is already closed.
-
inline virtual void on_slot_publishing()¶
TEST SEAM dispatch: run inside the accept-side
write_m_hold, with the fd published and the slot ONE store from open.Default: nothing. A derived server overrides it to fire its own hook pointer — the instant a test holds open to prove the two stores are atomic to senders (#891).
The peer-LIFECYCLE seam (#1438) — the only two places this tier needs the facet.
publish_peer_upandteardown_slotrun HERE, in the tier that owns the slot table, but the notifiers they end in arebus_link_t’s protected members and this tier is no longer abus_link_t. These two hooks are the join: inert in the base, overridden by bus_slot_server_t to firenotify_peer_up/notify_peer_down.Virtual rather than the static seam the per-frame tier select uses, because these are the base tier’s own call sites and a base cannot resolve a derived name statically. The cost is two vtable slots per concrete server and one indirect call per peer ARRIVAL and DEPARTURE — wiring frequency, explicitly the tier ADR-0047 §4 admits a runtime virtual at — and zero bytes per listener, since the vptr is already there.
-
inline virtual void announce_peer_up(peer_handle_t handle, std::string_view peer)¶
Announce an arrival to the facet (no-op without one).
-
inline virtual void announce_peer_down(peer_handle_t handle, std::string_view peer)¶
Announce a departure to the facet (no-op without one).
Public Types
-
using peer_visitor_t = bus_link_t::peer_visitor_t¶
The peer-visitor shape the query trio speaks — bus_link_t’s, so the facet arm’s overrides are the same signature and no consumer sees two.
Public Functions
-
inline bool ok() const noexcept¶
True if the listen socket is bound and listening — and, on a target that closed the bus module out, only if this server did not ask to be peer-named (#375).
The came-up predicate
make_checkedasks (#1059), so the second limb is what turns akBusLinks = falsebuild’s refusal into an ordinary “this link did not come up” for every door — the SPEC factory and a direct constructor alike. It is a REFUSAL rather than a quiet demotion to FLAT because a demotion is not observable and this is: a deployment that configured peer-named addressing on a build that carries none has a configuration error, and a listener that answersok()would hide it.At the default binding the
if constexpris discarded and this islisten_fd_ >= 0, the predicate it always was — same instructions, verified by object-filecmp.
-
inline std::uint16_t local_port() const noexcept¶
The actual bound TCP port (resolves an ephemeral 0 request).
-
inline std::uint64_t stalled_tx() const noexcept¶
Records shed because their send bound expired, summed over every peer this server has carried (#838) — the subset of
dropped_tx()a stalled peer caused.kMaxConsecutiveStallsof them in a row on one session, or any one that half-reached the wire, closes that session.
-
inline std::uint32_t liveness_window_ms() const noexcept¶
The injected peer liveness window this server bounds its sends by, ms — the value as configured,
0meaningkDefaultLivenessWindowMs(#838).
-
inline std::size_t max_peers() const noexcept¶
The concurrent-peer admission cap actually ENFORCED on the accept path — the constructor argument resolved through
derive_max_peers, so never 0 and never above the window’s ceiling (#1295). Also the denominator of directed_send_bound_ms.
-
inline std::uint32_t directed_send_bound_ms() const noexcept¶
The per-record send bound a DIRECTED send to one peer of this server gets, ms (#1295).
The liveness window divided by max_peers — NOT by 1. A directed send is a round of one, but it is not the only one a node can have in flight: every open peer can be the target of a concurrent directed send, and on one server those serialize behind
write_m_. Dividing by the cap makes the SUM over every peer that could be stalled one window, which is the same aggregate claim broadcast_iov makes for the fan.
-
inline bool peer_named() const noexcept¶
The mode authority (#889): the
peer_namedthis server was constructed with.The ONE answer to “which mode is this link in” —
bus(), the two servers’ per-frame tier select, and the departure branch in teardown_slot all key off this flag (not off whether a peer sink happens to be installed), andbus_link_trefuses every peer-named wiring call while it is false.It reads bus_mode, not the constructor argument, so the one answer stays one answer on a target that closed the bus module out: there the server is FLAT in every respect, and ok is what reports that the configuration was refused (#375).
Not
overridehere since #1438: this tier is not a bus_link_t, so there is no virtual to override until bus_slot_server_t re-declares it. The ANSWER is unchanged, and so is every caller’s spelling.
-
void enumerate_peers(const peer_visitor_t &visit) const¶
Visit the currently-OPEN peers’ names,
p<slot>(#426).
-
std::string_view peer_name(peer_handle_t peer, std::span<char> scratch) const¶
Resolve an inbound handle back to its peer name,
p<slot>(#1294).A pure function of the handle’s index, exactly as the accept-side stamp is (ADR-0073 §2) — formatted into
scratchwith no lock and no slot lookup, so the router pays nothing for asking on the delivery callback that a name string used to arrive on.
-
inline virtual peer_handle_t inbound_peer() const noexcept override¶
The in-flight frame’s peer — the WHO seam, answered at either setting of peer_named (#375 Part 2, ADR-0082).
A peer-named server tags every frame through the bus seam and never needs this. A FLAT one cannot: it has exactly one routing identity for every peer it carries, and the
p<slot>tag it computes is thrown away at the delivery fork. This is where that tag survives — stamped on the poll thread immediately before the flat delivery and read back, on that same thread, by the router’s terminus.Warning
Poll-thread state, meaningful ONLY inside a receive callback. It is a plain member and not an atomic on purpose: one server owns one poll thread (ADR-0071’s shared-nothing epoll), the store and every legitimate load happen on it, and paying for an atomic on the per-frame delivery path to make an off-thread read merely defined rather than correct buys nothing.
-
virtual std::string_view peer_subject(peer_handle_t peer, std::span<char> scratch) const override¶
The SUBJECT token of
peer—p<slot>, this kind’s session identity.The same string peer_name answers with, reachable without the
bus_link_tfacet; see the implementation’s note on why the two coincide in value and not in availability.
-
transport_t *peer_link(std::string_view peer)¶
Resolve an open peer’s name to its directed sending endpoint.
Owned by the peer’s slot and pointer-valid for this server’s lifetime (slots are never freed, only recycled). After the peer departs its sends no-op until the slot is reused.
- Return values:
nullptr –
peernames no currently-open connection.
-
bool close_peer(std::string_view peer)¶
Close the open peer named
peer, freeing its slot for reuse.Shuts the socket down (
SHUT_RDWR) under the sender lock order (peers_m_ →write_m_); the poll thread’s next pass observes the close and runs the IDENTICAL remote-FIN teardown, so the recycle is asynchronous (within one poll bound) and no poll-thread-only buffer is ever touched off-thread.- Return values:
true –
peernamed an open connection and its socket was shut down.false –
peernames no currently-open connection.
Protected Functions
-
inline slot_server_t(std::size_t max_peers, bool peer_named, std::uint32_t liveness_window_ms = 0)¶
Constructs inert: no listen socket, no slots, no thread.
- Parameters:
max_peers – Requested concurrent-peer admission cap; a deployment-injected bound (RFC-0006) — a connection beyond it is accepted and immediately closed (a clean refusal, not a hung SYN). Resolved through
derive_max_peers, so0no longer means UNBOUNDED (#1295): it takes the window’s own ceiling, and a request above that ceiling is clamped to it. Read the enforced value back from max_peers.peer_named – Expose the bus_link_t facet (see bus).
liveness_window_ms – The app-provided peer liveness window, ms (0 =
kDefaultLivenessWindowMs) — see broadcast_iov for how one fan-out round is bounded by it (#838), and directed_send_bound_ms for the directed twin (#1295).
-
~slot_server_t()¶
Closes the listen socket and sweeps every slot’s fd.
Runs AFTER the derived destructor, whose first act was
stop_and_join— the poll thread is gone, so nothing races this sweep and no virtual is dispatched from it.
-
bool bind_listen(std::uint16_t bind_port)¶
The shared bring-up: socket + SO_REUSEADDR + bind + listen(SOMAXCONN) + getsockname, publishing listen_fd_ and bound_port_.
SOMAXCONN is the OS’s own accept-queue bound — admission is per-connection in the accept path (the
max_peersdeployment cap), never a synthetic backlog.- Parameters:
bind_port – TCP port to listen on (host byte order; 0 → ephemeral, resolved into local_port).
- Return values:
false – The socket could not be bound/listened; the caller must NOT spawn the poll thread (
ok()stays false).
-
void run()¶
The ONE poll thread body: one
poll(2)pass multiplexes the listen socket and every live peer — no per-peer thread (the MCU-shaped choice, #362), bounded to 100 ms so the loop stays shutdown-responsive.Spawn it from the DERIVED constructor (
start([this] { run(); }, recv_stack)), last, once every member the variance points touch is initialized.
-
void teardown_slot(session_base_t &s)¶
Tear one slot down and free it for reuse (poll thread only).
Two phases: stop name resolution under peers_m_ (so no new sender targets the dying slot), then reset
open/fdunderwrite_m_BEFOREclose(2)(so an in-flight send either finished against the still-open fd or observes the reset). on_slot_reset clears the protocol buffers, and the departure seam (RFC-0009 §D.5) fires LAST with no transport lock held — the notifier re-enters the routing plane. Which seam depends on peer_named() — the departed peer’s own name when peer-named, the whole link when flat, and then only once no open session is left (#889).- Parameters:
s – The slot to recycle.
-
std::size_t broadcast_iov(std::span<const ::iovec> rec)¶
Fan one already-encoded gathered record to EVERY open peer.
write_all_iovreads its gather without consuming it (#932), so every peer writes straight fromrec— no per-peer copy, and no scratch store that could exhaust and drop the frame. Takes peers_m_ →write_m_, the header lock order.The ROUND is bounded (#838): the per-peer record bound is the liveness window divided by the number of open peers this round actually writes to (
derive_send_bound_ms), so a fan-out in which EVERY peer has stopped reading still releases both locks — and the calling application thread — inside one window instead of blocking forever on the first stalled peer. A peer whose record stalls is counted and strikes only ITSELF (stream_endpoint_t::note_write_result); the healthy peers behind it in the same round still get the frame.- Parameters:
rec – The assembled record (framing entry first, payload spans after).
- Returns:
How many peers the record was SHED for (each one a
dropped_tx_the caller ticks — the counters live in the derived servers).
-
void publish_peer_up(const session_base_t &s)¶
Announce
sas a live, named session — the arrival half of the seam whose departure half isteardown_slot’snotify_peer_down(#1223 step 2).Called from the POLL THREAD at the moment the slot becomes usable to senders, which is kind-specific and therefore not a single site: a raw stream peer is live the instant it is accepted, a WS peer only once its
101is on the wire — the same two transitionsopenitself is stored at, so arrival and departure bracket exactly the same interval. A FLAT (not peer_named) server announces nothing: it has one routing identity for every peer it carries, so there is no per-session identity to announce.Fired with NO transport lock held, per the bus facet’s arrival-notifier contract — the notifier re-enters the routing plane and takes graph locks.
-
inline bool bus_mode() const noexcept¶
The constructed mode AS THIS BUILD CAN HONOUR IT — the one predicate every peer-named branch in this class and its two derived servers reads (#375).
peer_named_is the REQUEST; this is the request conjoined with whether the target carries a bus module at all (tr::graph::default_config_t::kBusLinks). The two differ in exactly one build, the one that closed the module out, and there this is constantfalse— so the per-frame tier select, the departure seam, the arrival seam andbus()all collapse to their FLAT arms at compile time and the peer-named halves are never emitted. That build cannot reach those arms at run time either, because ok refuses such a server outright; the folding is what makes the refusal FREE rather than merely safe.Non-virtual and inline on purpose: it is read once per inbound frame on the poll thread, where
peer_named()’s virtual would be a per-frame dispatch. At the default binding it ISpeer_named_— one member load, the instruction sequence that was there before.
Protected Attributes
-
mutable std::mutex peers_m_¶
Guards the slot vector and every slot’s NAME — the cross-thread reads (enumerate_peers / peer_link) against the poll thread’s accept/teardown. See the class-level threading rule; lock order where nested: this →
write_m_.
-
std::vector<std::unique_ptr<session_base_t>> slots_¶
The peer slots: insert-only, recycled in place, never freed early.
-
int listen_fd_ = -1¶
The bound+listening socket (-1 = not bound).
-
std::uint16_t bound_port_ = 0¶
The resolved bound port (see local_port()).
-
std::size_t max_peers_ = 1¶
The ENFORCED admission cap (RFC-0006), resolved once by
derive_max_peersat construction — never 0, never above the window’s ceiling (#1295).
-
bool peer_named_ = false¶
Expose the bus facet — a wiring-time deployment choice. Stored HERE rather than in the facet arm because ok reads it to refuse a peer-named server on a build that carries no facet at all (#375), and that refusal has to work in the arm where the facet does not exist.
-
peer_handle_t delivering_ = {}¶
The peer whose frame is being delivered RIGHT NOW — inbound_peer’s storage. Stamped by the derived server’s receive loop immediately before it hands a frame up the FLAT tier, on the poll thread, and never read off it.
-
struct session_base_t¶
The protocol-agnostic half of ONE peer slot.
Slots are never destroyed while the server lives — recycled in place on departure — so the endpoint facade peer_link hands out stays pointer-valid for the server’s lifetime. A derived server extends this with its own framing state (a length-prefix framer, a WS reassembler + byte buffers) and owns the concrete peer_endpoint facade object. See the class-level threading rule for who may touch what.
Public Functions
-
session_base_t() = default¶
Constructs a free slot (no fd, not open, unnamed).
-
virtual ~session_base_t() = default¶
Virtual: the base owns the slot vector and deletes derived slots.
Public Members
-
std::atomic<int> fd = {-1}¶
The peer socket; -1 ⇒ free slot.
-
std::atomic<bool> open = {false}¶
True while the session may carry frames.
-
std::string name¶
The peer’s routable NAME,
p<slot>— a pure function of the slot index (ADR-0073 §2, #426): stamped at accept, moved out by teardown (the eviction seam), so a reused slot gets the SAME name back. A legal path segment, unlike the old<ip>:<port>. It identifies a SESSION, not a device — a reconnecting peer may land in a different slot; device-stable identity is a named link (RFC-0014).
-
peer_handle_t handle¶
This session’s identity HANDLE,
(slot index, generation)— what the peer-receiver seam tags every inbound frame with (#1294).Minted at accept and RETIRED at teardown (
generation = 0, i.e. not peer_handle_t::valid), so a handle minted against the session that used to hold this slot never matches its successor — the same validate-on-use stamp the ESP link’s session ref carries. Written on the poll thread under peers_m_ beside name, and read on that thread by the delivery path.
-
std::uint32_t gen_seq = 0¶
This slot’s monotonic tenancy counter — the source of the generation handle is minted at. Bumped once per accept (never reused, never zero), so the counter survives the retire that clears handle.
-
std::string endpoint_str¶
The remote
<ip>:<port>— DIAGNOSTIC only, never a name and never in the graph (#584 owns any future per-peer facet). Refreshed per accept.
-
transport_t *peer_endpoint = nullptr¶
The directed facade peer_link returns — the derived slot’s own member, registered here by make_session.
-
std::uint8_t tx_stall_streak = 0¶
This session’s consecutive-stall streak (#838) — guarded by
write_m_, like the two atomics above, because it is mutated by exactly the senders that hold it. Cleared as the slot is recycled, so a stalled peer’s strikes can never be inherited by its successor in the slot.
-
session_base_t() = default¶
-
virtual std::unique_ptr<session_base_t> make_session() = 0¶
-
class bus_slot_server_t : public tr::net::slot_server_t, public tr::net::bus_link_t¶
The stream-server arm WITH the ADR-0044 bus facet — slot_server_t plus the
bus_link_tbase subobject, itspeer_rx_slot and its two peer-lifecycle notifier pairs (#1438).Everything a bus facet needs to ANSWER already lives one tier up, because every one of those questions is a question about the slot table:
p<slot>is a pure function of the slot index either way. What lives HERE is the facet itself — the identitytransport_t::bus()hands out, the peer-named receiver slot, and the arrival/departure notifiers — so the fourbus_link_tpure virtuals below are one-line forwards to the implementations they always had, not a second copy of them.Re-declaring them here is also what keeps the names UNAMBIGUOUS: with the same signature reachable through slot_server_t and through
bus_link_t, an unqualifiedserver.enumerate_peers(...)would otherwise be an ambiguous lookup; the override in the most-derived tier hides both.The per-frame peer-delivery seam, live — the tier select in both servers’
receive loops reaches
peer_rx_through exactly these.-
inline bool peer_tier_wants_rope() const noexcept¶
True iff the peer-named tier wants OWNING ropes (ADR-0053 §5).
-
inline void deliver_to_peer(peer_handle_t peer, view::view_t frame)¶
Deliver an owning view up the peer-named tier.
-
inline void deliver_to_peer_borrowed(peer_handle_t peer, std::span<const std::byte> frame)¶
Deliver a borrowed span up the peer-named tier.
-
inline void deliver_to_peer_rope(peer_handle_t peer, view::rope_t frame)¶
Deliver an owning rope up the peer-named tier.
Public Types
-
using peer_visitor_t = bus_link_t::peer_visitor_t¶
The one visitor shape — declared here so the name reaching this tier through two bases (identical types, ambiguous LOOKUP) resolves to one declaration.
Public Functions
-
inline virtual bus_link_t *bus() override¶
The bus_link_t facet (ADR-0044) when constructed
peer_named, elsenullptr. With the facet the router tags inbound frames per peer (each peer gets its own return-route identity and adstsegment routes back to that one session); without it the link keeps point-to-point hop naming — inbound frames carry the registered child NAME andsend()fans out to every open peer.Note
Departure eviction (RFC-0009 §D.5) follows the same split: peer-named mode evicts just the departed peer’s edges (
notify_peer_down(name)), while FLAT mode reports the whole link down (notify_down()) — but only when the LAST open session departs (#889). A flat link has ONE routing identity for all its peers (the registered child NAME), so firing that on a mid-life close would evict the surviving peers’ edges too.
-
inline virtual bool peer_named() const noexcept override¶
The mode authority as the facet’s own virtual — slot_server_t::peer_named, so “which mode is this link in” still has exactly one answer (#889).
-
inline virtual void enumerate_peers(const peer_visitor_t &visit) const override¶
The facet’s spelling of slot_server_t::enumerate_peers.
-
inline virtual std::string_view peer_name(peer_handle_t peer, std::span<char> scratch) const override¶
The facet’s spelling of slot_server_t::peer_name.
-
inline virtual transport_t *peer_link(std::string_view peer) override¶
The facet’s spelling of slot_server_t::peer_link.
-
inline virtual bool close_peer(std::string_view peer) override¶
The facet’s spelling of slot_server_t::close_peer.
Protected Functions
-
inline virtual void announce_peer_up(peer_handle_t handle, std::string_view peer) override¶
Arrival: fire the facet’s peer-up notifier (#1223 step 2).
-
inline virtual void announce_peer_down(peer_handle_t handle, std::string_view peer) override¶
Departure: fire the facet’s peer-down notifier (RFC-0009 §D.5).
-
inline slot_server_t(std::size_t max_peers, bool peer_named, std::uint32_t liveness_window_ms = 0)¶
Constructs inert: no listen socket, no slots, no thread.
- Parameters:
max_peers – Requested concurrent-peer admission cap; a deployment-injected bound (RFC-0006) — a connection beyond it is accepted and immediately closed (a clean refusal, not a hung SYN). Resolved through
derive_max_peers, so0no longer means UNBOUNDED (#1295): it takes the window’s own ceiling, and a request above that ceiling is clamped to it. Read the enforced value back from max_peers.peer_named – Expose the bus_link_t facet (see bus).
liveness_window_ms – The app-provided peer liveness window, ms (0 =
kDefaultLivenessWindowMs) — see broadcast_iov for how one fan-out round is bounded by it (#838), and directed_send_bound_ms for the directed twin (#1295).
-
inline bool peer_tier_wants_rope() const noexcept¶
-
class flat_slot_server_t : public tr::net::slot_server_t¶
The stream-server arm WITHOUT the ADR-0044 bus facet — the layout a target that closed the bus module out actually gets (#1438).
Adds nothing to slot_server_t but the INERT half of the per-frame peer-delivery seam. Those members exist so the two servers’ tier select is ONE piece of source under both bindings; they are unreachable here, because the select is guarded by
slot_server_t::bus_mode(), which this arm only ever compiles as the constantfalse.The seam is deliberately NOT virtual, unlike the two peer-lifecycle hooks: it is read once per inbound FRAME, and the concrete servers derive from
tr::net::stream_server_base_t, so which arm’s members they see is a compile-time fact. Nothing is dispatched.The per-frame peer-delivery seam, inert (see @ref bus_slot_server_t for the live
half). Every one of these is dead code under this arm’s own
bus_mode().-
static inline bool peer_tier_wants_rope() noexcept¶
No peer tier, so nothing wants ropes on it.
-
static inline void deliver_to_peer(peer_handle_t peer, view::view_t frame)¶
No peer tier to deliver an owning view to.
-
static inline void deliver_to_peer_borrowed(peer_handle_t peer, std::span<const std::byte> frame)¶
No peer tier to deliver a borrowed span to.
-
static inline void deliver_to_peer_rope(peer_handle_t peer, view::rope_t frame)¶
No peer tier to deliver an owning rope to.
Protected Functions
-
inline slot_server_t(std::size_t max_peers, bool peer_named, std::uint32_t liveness_window_ms = 0)¶
Constructs inert: no listen socket, no slots, no thread.
- Parameters:
max_peers – Requested concurrent-peer admission cap; a deployment-injected bound (RFC-0006) — a connection beyond it is accepted and immediately closed (a clean refusal, not a hung SYN). Resolved through
derive_max_peers, so0no longer means UNBOUNDED (#1295): it takes the window’s own ceiling, and a request above that ceiling is clamped to it. Read the enforced value back from max_peers.peer_named – Expose the bus_link_t facet (see bus).
liveness_window_ms – The app-provided peer liveness window, ms (0 =
kDefaultLivenessWindowMs) — see broadcast_iov for how one fan-out round is bounded by it (#838), and directed_send_bound_ms for the directed twin (#1295).
-
static inline bool peer_tier_wants_rope() noexcept¶
Datagram and stream transports¶
-
class udp_transport_t : public tr::net::transport_t, private tr::net::posix_endpoint_t¶
A single-peer UDP datagram transport_t (one datagram = one frame).
Binds a local UDP socket and sends to one peer; a listener-mode instance learns its peer from the first inbound datagram’s source address. Supports the owning rope-receiver seam (ADR-0042 §2): each datagram is received straight into a refcounted segment from a host-injected
mem_backend_t, which also bounds the datagram size a node accepts.The ingress bound gains its second half: the universal
:settings max_framekey (effective_max_frame), the same keytcp_transport_t, thewstransports,quicandwebtransportaccept. A datagram longer than the configured cap is refused and counted in malformed_rx instead of being delivered, and the RX segment is drawn at the cap rather than at kMaxDatagram. That refusal is unconditional on the borrowed-span path; on the owning path it holds while the injected backend can furnishmax_frame + 1bytes — a backend bounded tighter than the cap truncates the datagram before the cap is ever consulted (#1074).Public Functions
-
udp_transport_t(std::uint16_t bind_port, const std::string &peer_host, std::uint16_t peer_port, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0, std::size_t recv_stack = 0)¶
Bind a local UDP socket on
bind_portand targetpeer_host:peer_port.bind_port0 = ephemeral (see local_port).peer_hostis an IPv4 dotted-quad (e.g. “127.0.0.1”). Listener mode: with an unresolved peer (peer_hostempty orpeer_port0) the transport LEARNS its peer from each inbound datagram’s source address — the single-peer UDP-server shape that lets a config-createdlistenerconnection (#83) reply to a dialing client whose ephemeral port is unknowable in advance; until the first datagram arrives, send is a no-op.backendis the host-injected RX memory seam (ADR-0042 §2): when a rope receiver is installed, each datagram is recvfrom’d straight into a fresh segment drawn from it, sizedmin(kMaxDatagram, backend->max_segment_size())— so the backend BOUNDS the datagram a node accepts (a pool over a static MCU slab works as-is; default is the process heap, unbounded, keeping the full cap). Exhaustion is backpressure — the datagram is dropped and dropped_rx ticks, never an OOM. Must outlive the transport.- Parameters:
max_frame – The universal
:settings max_framereceive cap, in bytes — the largest datagram this connection accepts (0 → kMaxDatagram). A longer datagram is refused: it is never delivered, malformed_rx ticks, and the socket stays usable — providedbackendcan furnishmax_frame + 1bytes, since a segment bounded below that truncates the datagram before its length can be judged (#1074). It also sizes the RX segment, so a tight cap is a RAM lever, not only an admission one. Tighten-only by construction: a datagram cannot exceed kMaxDatagram, so a larger configured value is inert.recv_stack – Recv-thread stack size in bytes, 0 = platform default (
posix_endpoint_t::start). Non-zero right-sizes this transport’s recv thread on an MCU instead of raising the global pthread default.
-
virtual void send(std::span<const std::byte> frame) override¶
Emit one frame (a complete TLV’s bytes) onto the wire.
-
virtual void send(std::span<const std::span<const std::byte>> iov) override¶
Scatter-gather send: emit the gathered spans as ONE frame, no flatten copy.
Hand a rope’s
to_iovec()straight to the wire. The default gathers into a temporary and calls send(std::span<const std::byte>); transports with native scatter-gather (sendmsg/writev/RDMA SGE) override this to avoid the copy.- Parameters:
iov – The spans to emit, in order, as a single frame.
-
inline virtual bool delivers_ropes() const override¶
True — this transport honors set_rope_receiver (ADR-0042 §2): one datagram = one frame = one refcounted segment from the injected backend, handed up owning; span-only sinks keep the borrowed path.
-
inline bool ok() const noexcept¶
True iff the socket bound successfully.
-
inline std::uint16_t local_port() const noexcept¶
The bound local port (resolves an ephemeral
bind_portof 0).
-
inline std::uint64_t dropped_rx() const noexcept¶
Datagrams dropped because the RX backend was exhausted (backpressure, ADR-0039 §4 / ADR-0042 §2) — never an OOM.
-
inline std::uint64_t malformed_rx() const noexcept¶
Datagrams refused for exceeding effective_max_frame — the peer’s fault, not this node’s resources (the tcp/ws counter vocabulary: over-cap is
malformed_rx, exhaustion is dropped_rx). UDP is connectionless, so nothing is torn down; the next datagram is served normally.
-
inline std::uint64_t dropped_tx() const noexcept¶
Datagrams shed on the way OUT (#932): no peer learned or configured yet, no socket, or a refused gather store — each one used to be a bare return.
-
inline virtual transport_drop_stats_t drop_stats() const noexcept override¶
The interface-level snapshot (#932) — what a generic
transport_t*reads.
-
inline std::size_t effective_max_frame() const noexcept¶
The largest datagram accepted:
min(max_frame, kMaxDatagram), with 0 meaning kMaxDatagram. The RX segment is additionally bounded by the injected backend’smax_segment_size(), which never widens this.
Public Static Attributes
-
static constexpr std::size_t kMaxDatagram = 65536¶
The largest datagram one frame can occupy — the RX segment size a view receiver’s frames are allocated at (the UDP payload bound, one datagram = one frame = one segment, ADR-0042 §2). It is also the ceiling on effective_max_frame — a datagram cannot be larger than this, so a configured
max_frameabove it is inert rather than loosening.
-
udp_transport_t(std::uint16_t bind_port, const std::string &peer_host, std::uint16_t peer_port, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0, std::size_t recv_stack = 0)¶
-
class tcp_transport_t : public tr::net::transport_t, private tr::net::stream_endpoint_t¶
A TCP stream transport_t (M6) — length-prefix framing over one peer.
Every frame is sent as
u32-LE length ++ frame bytes; the receive thread reads the prefix (reassembling it across TCP segment boundaries), then reads exactlylenbytes straight into a refcounted segment drawn from the injectedmem_backend_t(ADR-0042 §2 — no library buffer beyond the per-frame segment). With a view receiver installed the frame is handed up OWNING; the span receiver otherwise gets a borrowed span over the same segment bytes.Public Functions
-
tcp_transport_t(const std::string &peer_host, std::uint16_t peer_port, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0, std::size_t recv_stack = 0, bool defer_recv = false, std::uint32_t liveness_window_ms = 0)¶
DIAL mode: connect to
peer_host:(synchronous).The TCP connect happens in the constructor (the transport_ws_client shape) — confirm with ok(); on failure no thread is spawned. By default the receive thread starts immediately, so receivers must be installed before frames flow (the set_receiver contract);
defer_recvis what makes that contract satisfiable on this socket at all.- Parameters:
peer_host – Dotted-quad IPv4 address of the peer (e.g. “127.0.0.1”).
peer_port – TCP port of the peer (host byte order).
backend – The host-injected RX memory seam (ADR-0042 §2): each inbound frame is read into a fresh exactly-
len-byte segment from it (default: the process heap; a bounded host passes its pool). Exhaustion is backpressure — the frame is drained off the stream, dropped, and dropped_rx() ticks; never an OOM. Must outlive the transport.recv_stack – Recv-thread stack size in bytes, 0 = platform default (
posix_endpoint_t::start). Non-zero right-sizes this transport’s recv thread on an MCU.defer_recv – Two-phase bring-up (#1045, the transport_ws_client contract verbatim): with
truethe connect still runs HERE (so ok() answers for it on return) but the recv thread is NOT spawned — not one byte is read off the socket until start_receiving. That is the ordering in which the set_receiver contract above is satisfiable on a DIAL socket: a peer that pushes the instant our connect completes has its first frame in flight before this constructor returns, and the default (false, the historical shape) decodes it on the recv thread into whatever sink is installed by then — possibly none, in which case it is dropped with no counter moving.liveness_window_ms – The app-provided PEER LIVENESS WINDOW in ms,
0=kDefaultLivenessWindowMs(#838): how long this peer may fail to take bytes before it is treated as broken. It bounds everysend(and the write-mutex hold it takes), so no peer can freeze the sending thread;kMaxConsecutiveStallsrecords in a row that hit it — or one that half-reached the wire — close the connection. The same contract CAN’speer_ttl(ADR-0044) states, and the number RFC-0014’s §S5 liveness engine converges on.
-
explicit tcp_transport_t(std::uint16_t bind_port, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0, std::size_t recv_stack = 0, std::uint32_t liveness_window_ms = 0)¶
LISTEN mode: bind+listen on
bind_port, accept ONE inbound peer.The same one-peer model as transport_ws_server: one connected client at a time; after a peer departs the accept loop resumes for the next. Use ok() to confirm the listen socket bound; the bound port (an ephemeral 0 request resolved) is observable via local_port().
- Parameters:
bind_port – TCP port to listen on (host byte order; 0 → ephemeral).
backend – The RX memory seam — see the DIAL constructor.
recv_stack – Recv-thread stack size in bytes, 0 = platform default (
posix_endpoint_t::start).liveness_window_ms – The peer liveness window — see the DIAL constructor (#838).
-
~tcp_transport_t() override¶
Stop the receive thread and close all sockets.
-
virtual void send(std::span<const std::byte> frame) override¶
Send
frameas one length-prefixed record on the stream.Writes
u32-LE frame.size()then the frame bytes — one writev, partial writes resumed until complete. No-op until a peer is connected (and after the connection is torn down). Thread-safe (writes are serialized, so two senders can never interleave records on the stream).- Parameters:
frame – A complete TLV’s bytes.
-
virtual void send(std::span<const std::span<const std::byte>> iov) override¶
Scatter-gather send: the prefix + every span as ONE record, no gather copy — the length prefix rides as the first iovec entry and the rope’s spans follow, lowered to writev (partials resumed).
- Parameters:
iov – The frame’s spans (a rope’s
to_iovec()), concatenated on the wire as one length-prefixed frame.
-
virtual void start_receiving() override¶
Spawn the recv thread a
defer_recvDIAL construction held back (#1045).The second phase of the two-phase bring-up: the socket is connected and NOTHING has been read off it, so a sink installed before this call cannot have missed a frame. From here the link behaves exactly as a one-phase one.
IDEMPOTENT, and a no-op wherever there is nothing to arm — a one-phase DIAL link (its thread is already running), a LISTEN link (its accept loop started in the constructor), and a link whose dial failed (
ok()false, no socket to serve) — so an owner may call it unconditionally on every link it wires, which is whattransport_vertex_t::make_connectiondoes. Adefer_recvlink that is never armed never receives and never reports the link down; it is simply an open socket until it is destroyed.
-
inline virtual bool delivers_ropes() const override¶
True — this transport honors set_rope_receiver (ADR-0042): one frame = one refcounted segment from the injected backend, handed up owning; a span-only sink gets the same bytes borrowed.
-
inline bool ok() const noexcept¶
The came-up predicate (#1059) — DIAL: the connect succeeded; LISTEN: the listen socket is bound. Answered at construction and never reverting (this accessor used to read the live fd on a DIAL link, i.e. it doubled as liveness; that is link_up now).
-
inline virtual bool link_up() const noexcept override¶
Liveness (the transport_t::link_up contract): true while the ONE connection is live — DIAL: from the connect until the recv loop’s teardown; LISTEN: while an accepted peer is connected (false between peers). Derived from the connection fd, which the teardown path already resets under the write lock — state that is already atomic (relaxed; a hint, not a synchronisation point).
-
inline std::uint16_t local_port() const noexcept¶
LISTEN mode: the actual bound TCP port (resolves an ephemeral 0).
-
inline std::uint64_t dropped_rx() const noexcept¶
Frames dropped because the RX backend was exhausted (backpressure, ADR-0039 §4 / ADR-0042 §2) — drained off the stream, never an OOM.
-
inline std::uint64_t malformed_rx() const noexcept¶
Malformed length prefixes seen (announced length > kMaxFrame). Each one tears the connection down — the stream has lost framing sync.
-
inline std::uint64_t dropped_tx() const noexcept¶
Frames shed on the way OUT (#932): a record over kMaxFrame, a refused gather store, or no live peer to write to (dialing / torn down).
-
inline std::uint64_t stalled_tx() const noexcept¶
The subset of dropped_tx a STALLED peer caused (#838): records abandoned because their send bound expired.
kMaxConsecutiveStallsin a row — or one that half-reached the wire — closes the connection, so a non-zero count with link_up still true is a peer that is falling behind but recovering.
-
inline std::uint32_t liveness_window_ms() const noexcept¶
The peer liveness window this link bounds its sends by, ms, as constructed (
0⇒kDefaultLivenessWindowMs) (#838).
-
inline virtual transport_drop_stats_t drop_stats() const noexcept override¶
The interface-level snapshot (#932) — the concrete accessors above, as the one shape a generic
transport_t*holder reads.
Public Static Attributes
-
static constexpr std::size_t kMaxFrame = length_prefix_framer::kDefaultMaxFrame¶
The largest frame the length prefix may announce — the shared length_prefix_framer::kDefaultMaxFrame (16 MiB) unless
:settings max_frametightens it. A larger prefix is malformed — counted via malformed_rx and the connection is closed (a desynced stream cannot be trusted again).
-
tcp_transport_t(const std::string &peer_host, std::uint16_t peer_port, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0, std::size_t recv_stack = 0, bool defer_recv = false, std::uint32_t liveness_window_ms = 0)¶
-
class transport_tcp_server : public stream_server_base_t¶
A multi-peer TCP server transport_t — accepts many inbound peers on one listener and exposes them through the bus_link_t facet (ADR-0044).
The raw-stream sibling of transport_ws_server (#362): LITERALLY the same slot/poll machinery, since #871 shared as slot_server_t — ONE poll-based thread accepts clients and serves every open connection concurrently; peers occupy SLOTS recycled on departure, so steady-state memory is bounded by the maximum concurrent peers ever reached (or
max_peers, the RFC-0006 injected bound). What this class adds to that base is its FRAMING: the shared u32-LE length prefix (one chunk-fed length_prefix_framer per slot) where WS has RFC 6455 packaging. There is NO handshake phase: a peer is open (namedp<slot>, ADR-0073 §2 / #426) from the moment its connection is accepted. The board↔board listener shape: leaner than WS packaging (no HTTP upgrade, no frame masking) with the same per-peer return-route identity whenpeer_named.Public Functions
-
explicit transport_tcp_server(std::uint16_t bind_port, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0, std::size_t max_peers = 0, bool peer_named = false, std::size_t recv_stack = 0, std::uint32_t liveness_window_ms = 0)¶
Bind+listen on
bind_port(0 = ephemeral; see local_port()).Spawns the poll/serve thread immediately. Use ok() to confirm the listen socket bound; the bound port is observable via local_port().
- Parameters:
bind_port – TCP port to listen on (host byte order; 0 → ephemeral).
backend – The host-injected RX memory seam (ADR-0042 §2): each inbound frame reassembles into a fresh exactly-len-byte segment from it. Exhaustion is backpressure — the frame is drained in-framer, dropped, and dropped_rx() ticks; never an OOM. Must outlive the transport.
max_frame – Per-connection receive cap (0 → tcp_transport_t::kMaxFrame). TIGHTEN-ONLY: a value above kMaxFrame is clamped to it (
length_prefix_framer::configured_cap, #1035) — a config-writable key must not raise the ingress buffering bound. A frame inside this cap that the backend cannot hold (length_prefix_framer::effective_cap— the no-synthetic-limits doctrine) is shed as backpressure, NOT treated as malformed (#932); only a length above this cap closes the connection.max_peers – Concurrent-peer admission cap. A deployment-injected bound (RFC-0006) — a connection beyond it is accepted and immediately closed (a clean refusal, not a hung SYN).
0no longer means UNBOUNDED (#1295): it takes the liveness window’s own ceiling (window / kBoundedWaitMs), and a larger request is clamped to that ceiling, because the cap is the denominator every send bound divides by. Read the enforced value back fromslot_server_t::max_peers.peer_named – Expose the bus_link_t facet (see transport_t::bus) — the board↔board wiring choice, same contract as transport_ws_server’s.
recv_stack – Poll-thread stack size in bytes, 0 = platform default (
posix_endpoint_t::start). One thread serves every peer, so this is the whole server’s recv-stack knob.liveness_window_ms – The app-provided PEER LIVENESS WINDOW in ms,
0=kDefaultLivenessWindowMs(#838). One fan-out round is bounded by it (each peer gets window ÷ peers-in-the-round) and a DIRECTED send by window ÷max_peers(#1295), so a peer that stops reading can no longer freeze the sending thread — or the other peers’ frames — behind it, on either path; a session that stallskMaxConsecutiveStallsrecords in a row, or once mid-record, is closed. It also SIZES the peer cap: seemax_peers.
-
~transport_tcp_server() override¶
Stop the poll thread and close all sockets.
-
void send(std::span<const std::byte> frame) override¶
Send
frameas one length-prefixed record to EVERY open peer (the flat point-to-point surface).The prefix is encoded once; each peer gets one serialized gathered write. No-op until a peer is connected. Thread-safe (peers_m_ → write_m_, the header lock order). A directed single-peer send is
peer_link(name)->send(frame).- Parameters:
frame – A complete TLV’s bytes.
-
void send(std::span<const std::span<const std::byte>> iov) override¶
Zero-copy scatter-gather broadcast: the length prefix rides as the first iovec entry and the rope’s spans follow — ONE gathered record per open peer, no flatten copy.
Each peer writes from a fresh copy of the iovec array (the write consumes it). Thread-safe (peers_m_ → write_m_).
- Parameters:
iov – The spans to emit, in order, as a single record.
-
inline bool delivers_ropes() const override¶
True — every frame reassembles into ONE refcounted segment from the injected backend, handed up owning (ADR-0042); a span-only sink gets the same bytes borrowed. Covers both facets.
-
inline std::uint64_t dropped_rx() const noexcept¶
Frames dropped to RX-backend exhaustion (backpressure), summed over all peers.
-
inline std::uint64_t malformed_rx() const noexcept¶
Malformed length prefixes seen (announced length above the effective cap). Each one tears that peer’s connection down.
-
inline std::uint64_t dropped_tx() const noexcept¶
Frames shed on the way OUT (#932), summed over the whole link: a record over the frame cap, a refused gather store, or no open peer to fan to.
-
inline transport_drop_stats_t drop_stats() const noexcept override¶
The interface-level snapshot (#932) — what a generic
transport_t*reads.
-
explicit transport_tcp_server(std::uint16_t bind_port, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0, std::size_t max_peers = 0, bool peer_named = false, std::size_t recv_stack = 0, std::uint32_t liveness_window_ms = 0)¶
WebSocket¶
-
class transport_ws_client : public tr::net::transport_t, private tr::net::stream_endpoint_t¶
A WebSocket (RFC 6455) client transport_t — dials out to one peer.
The mirror of transport_ws_server: a board that DIALS OUT to a ws:// peer (device-to-device, or egress through a NAT). The constructor TCP-connects to
host:,runs the opening handshake from the client side (sends an HTTP GET Upgrade with a fresh Sec-WebSocket-Key, then verifies the 101 response’s Sec-WebSocket-Accept against ws::accept_key), and on success spawns a receive loop. Per RFC 6455 §5.1 every client→server frame is MASKED (ws::encode_client_frame); inbound server frames are unmasked and decode the same way the server’s do. ok() confirms the handshake completed.Public Functions
-
transport_ws_client(const std::string &host, std::uint16_t port, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0, std::size_t recv_stack = 0, bool defer_recv = false, std::uint32_t liveness_window_ms = 0, mem::block_source_t *egress_src = &mem::heap_source(), std::size_t max_handshake = 0)¶
Connect to
host: andrun the client opening handshake.TCP-connects, sends the HTTP Upgrade request, and verifies the server’s 101 Sec-WebSocket-Accept. On success the receive loop thread is spawned; confirm with ok(). On any failure the connection is closed and ok() is false.
- Parameters:
host – Dotted-quad IPv4 address of the peer (e.g. “127.0.0.1”).
port – TCP port of the peer (host byte order).
backend – The host-injected RX memory seam — see transport_ws_server’s constructor; a DIALLED peer is no more trusted than an accepted one, so the client takes the same seam in the same position as
tcp_transport_t’s DIAL form.max_frame – Per-connection receive cap (0 → transport_ws_server::kMaxFrame; a value above it is clamped — tighten-only,
length_prefix_framer::configured_cap, #1035), bounded by the backend’s real capacity — see transport_ws_server’s constructor.recv_stack – Recv-thread stack size in bytes, 0 = platform default (
posix_endpoint_t::start).defer_recv – Two-phase bring-up (#1025): with
truethe handshake still runs HERE (so ok() answers on return) but the recv thread is NOT spawned — nothing can be decoded, let alone delivered, until start_receiving is called. That is the only ordering in whichtransport_t::set_receiver’s “must be set before frames flow” is satisfiable on a DIAL socket: a server that pushes its state the instant the handshake completes has its first message in flight before this constructor returns, and the default (false, the historical shape) decodes it on the recv thread into whatever sink is installed by then — possibly none, in which case it is dropped with no counter moving.liveness_window_ms – The app-provided PEER LIVENESS WINDOW in ms,
0=kDefaultLivenessWindowMs(#838): it bounds every send (and the write-mutex hold it takes), so a server that stops reading cannot freeze the sending thread;kMaxConsecutiveStallsstalled records in a row, or one that half-reached the wire, close the connection.egress_src – The ADR-0079 EGRESS store (#873) this link’s masked-frame buffer (
tx_buf_) is drawn from. It is passed to the CONSTRUCTOR rather than wired afterwards becausemem::block_array_tbinds its source once, at construction: a later transport_t::set_egress_source moves the base’s gather temporary but can no longer reach this member. This constructor appliesegress_srcto both, so a link built here has ONE egress store.nullptr(and the default) means the process heap — today’s behaviour unchanged. Must outlive this transport.max_handshake – The DIAL half of the pre-auth handshake budget (#934), resolved through
transport_ws_server::handshake_capso both roles read one home (0 →transport_ws_server::kMaxHandshakeBytes; above it is clamped — tighten-only). It bounds the RESPONSE header block the dialled server may make this node accumulate: the recv is sized by what is left of the budget, so the accumulation never passes it, and a budget exhausted with no CRLFCRLF in hand ticks malformed_rx and fails the dial. It does NOT bound what the server pipelines BEHIND its101— those are frame bytes, bounded bymax_frame, and whatever does not fit the budget is simply left on the socket for the recv loop.
-
~transport_ws_client() override¶
Stop the recv thread and close the socket.
-
virtual void send(std::span<const std::byte> frame) override¶
Send
frameas one client→server MASKED BINARY WebSocket message.Encodes via ws::encode_client_frame(BINARY, frame, key) (FIN=1, MASK=1, fresh per-frame key) and writes the whole frame to the peer. No-op once the connection has been torn down. Thread-safe (the socket write is guarded).
- Parameters:
frame – A complete TLV’s bytes.
-
virtual void start_receiving() override¶
Spawn the recv thread a
defer_recvconstruction held back (#1025).The second phase of the two-phase bring-up: the socket is connected and handshaken, the bytes the server pipelined behind its
101are held, and NOTHING has been decoded yet — so a sink installed before this call cannot have missed a frame. From here the client behaves exactly as a one-phase one: the thread’s first act is to drain what the handshake carried over.Idempotent and safe on a one-phase client (the thread is already running → no-op) and on a failed handshake (
ok()false → no-op, nothing to serve). Adefer_recvclient that is never started never receives, never answers a PING and never reports the link down; it is simply an open socket until it is destroyed.
-
inline virtual bool delivers_ropes() const override¶
True — WS reassembles fragmented messages into ropes (ADR-0053 §5): each message crosses the seam as a
rope_t, one owning link per WS fragment (a single link for an unfragmented message), chained by reassembly, never memcpy’d flat.
-
inline bool ok() const noexcept¶
The came-up predicate (#1059): the dial and the client opening handshake succeeded. Answered at construction and never reverting — a link that came up and later died still answers true here; liveness is link_up.
-
inline virtual bool link_up() const noexcept override¶
Liveness (the transport_t::link_up contract): true from the completed handshake until the recv loop’s teardown — a peer CLOSE, a remote hangup, a fatal receive error or an RFC 6455 breach all clear it (relaxed atomic; the push twin is set_down_notifier). A
defer_recvclient that is never started never observes the wire and so never reports down (see start_receiving).
-
inline std::uint64_t dropped_rx() const noexcept¶
Messages dropped to RX-backend exhaustion (backpressure) — the server-side counter’s twin, same name, same meaning.
-
inline std::uint64_t malformed_rx() const noexcept¶
RFC 6455 violations seen — an over-cap declared length, an over-cap reassembled message, a §5.5 control breach, or an opening-handshake RESPONSE past its budget (#934). Each fails the connection.
-
inline std::uint64_t dropped_tx() const noexcept¶
Messages shed on the way OUT (#932) — the client frame could not be encoded (gather store refused) or there is no live connection to write to.
-
inline std::uint64_t stalled_tx() const noexcept¶
The subset of dropped_tx a STALLED server caused (#838): records abandoned because their send bound expired.
kMaxConsecutiveStallsin a row — or one that half-reached the wire — closes the connection.
-
inline std::uint32_t liveness_window_ms() const noexcept¶
The peer liveness window this link bounds its sends by, ms, as constructed (
0⇒kDefaultLivenessWindowMs) (#838).
-
inline virtual transport_drop_stats_t drop_stats() const noexcept override¶
The interface-level snapshot (#932) — what a generic
transport_t*reads.
-
inline std::size_t effective_max_frame() const noexcept¶
The cap actually honored:
min(max_frame, backend.max_segment_size()).
-
inline std::size_t effective_max_handshake() const noexcept¶
The pre-auth handshake budget actually honored — the server-side accessor’s twin, same name, same tighten-only resolution (#934).
-
transport_ws_client(const std::string &host, std::uint16_t port, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0, std::size_t recv_stack = 0, bool defer_recv = false, std::uint32_t liveness_window_ms = 0, mem::block_source_t *egress_src = &mem::heap_source(), std::size_t max_handshake = 0)¶
-
class transport_ws_server : public stream_server_base_t¶
A WebSocket (RFC 6455) server transport_t — accepts many inbound peers and exposes them through the bus_link_t facet (ADR-0044).
Binds and listens on a TCP port (localhost is fine for tests); one poll-based thread accepts clients and serves every open connection concurrently. Each inbound BINARY message is delivered tagged with its peer’s name (the routable
p<slot>fallback, ADR-0073 §2 / #426) when a peer-named sink is installed (the router’s bus wiring), or to the flat transport_t receiver otherwise — so a single-client deployment behaves exactly as the point-to-point server always did. The dial-out counterpart is transport_ws_client below.Peer lifecycle: peers occupy SLOTS. A departed peer’s slot is recycled for the next accept, so steady-state memory is bounded by the maximum number of CONCURRENT peers ever reached (or by
max_peerswhen set — the RFC-0006 injected bound), never by the number of connections ever served. That whole slot/poll layer is slot_server_t, shared verbatim with transport_tcp_server since #871; what this class adds is the RFC 6455 packaging — the opening handshake and the frame codec.Public Functions
-
explicit transport_ws_server(std::uint16_t bind_port, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0, std::size_t max_peers = 0, bool peer_named = false, std::size_t recv_stack = 0, std::uint32_t liveness_window_ms = 0, std::size_t max_handshake = 0)¶
Bind+listen on
bind_port(0 = ephemeral; see local_port()).Spawns the poll/serve thread immediately. Use ok() to confirm the listen socket bound. The bound port is observable via local_port().
- Parameters:
bind_port – TCP port to listen on (host byte order; 0 → ephemeral).
backend – The host-injected RX memory seam (ADR-0042 §2), the same parameter tcp/quic/webtransport take in the same position: every inbound message fragment is copied into a fresh segment drawn from it (default: the process heap; a bounded host passes its pool). Exhaustion is backpressure — the message is shed and dropped_rx() ticks; never an OOM. Must outlive the transport.
max_frame – Per-connection receive cap (0 → kMaxFrame). TIGHTEN-ONLY: a value above kMaxFrame is clamped to it (
length_prefix_framer::configured_cap, #1035) — a config-writable key must not raise the ingress buffering bound; the effective cap also honors the backend’s real capacity (length_prefix_framer::effective_cap— the no-synthetic-limits doctrine). It is checked against the DECLARED length in the WS frame header, so an oversize announcement is refused before one body byte is buffered.max_peers – Concurrent-peer admission cap. A deployment-injected bound (RFC-0006) — a connection beyond it is accepted and immediately closed (a clean refusal, not a hung SYN).
0no longer means UNBOUNDED (#1295): it takes the liveness window’s own ceiling (window / kBoundedWaitMs), and a larger request is clamped to that ceiling, because the cap is the denominator every send bound divides by. Read the enforced value back fromslot_server_t::max_peers.peer_named – Expose the bus_link_t facet (see transport_t::bus). A wiring-time deployment choice: the browser-SPA/tabs server sets it so each tab gets its own return route; a point-to-point link keeps the default (its registered child NAME stays the hop name, as tcp/quic).
recv_stack – Poll-thread stack size in bytes, 0 = platform default (
posix_endpoint_t::start). One thread multiplexes the listener and every peer, so this is the whole server’s recv-stack knob.liveness_window_ms – The app-provided PEER LIVENESS WINDOW in ms,
0=kDefaultLivenessWindowMs(#838). One fan-out round is bounded by it (each peer gets window ÷ peers-in-the-round) and a DIRECTED send by window ÷max_peers(#1295), so a browser tab that stops reading — a throttled background tab is the shipped case — can no longer freeze the sending thread or the other tabs’ frames behind it, on either path; a session that stallskMaxConsecutiveStallsrecords in a row, or once mid-record, is closed. It also SIZES the peer cap: seemax_peers.max_handshake – PRE-AUTH request-size budget for the opening handshake in bytes (0 → kMaxHandshakeBytes, today’s behaviour). TIGHTEN-ONLY: a value above the default is clamped to it (handshake_cap) — the peer on this path has authenticated nothing, so a config-writable key may narrow what it may cost the node and never widen it. The budget is a TOTAL-REQUEST one, not a per-read one, and it is enforced BEFORE the append: the byte that would exceed it is never copied into the slot. Over budget ⇒ malformed_rx ticks and the link is closed (#934).
-
~transport_ws_server() override¶
Stop the recv thread and close all sockets.
-
void send(std::span<const std::byte> frame) override¶
Send
frameas one server→client BINARY WebSocket message to EVERY open peer (the flat point-to-point surface).Encodes once via ws::encode_frame(BINARY, frame) (FIN=1, unmasked) and writes the whole frame to each connected client. No-op until a client is connected. Thread-safe (socket writes are guarded). A directed single-peer send is
peer_link(name)->send(frame).- Parameters:
frame – A complete TLV’s bytes.
-
void send(std::span<const std::span<const std::byte>> iov) override¶
Zero-copy scatter-gather broadcast: emit the gathered
iovspans as ONE server→client BINARY message to EVERY open peer, no flatten copy.Overrides the base flatten-then-encode default (
transport.hpp): server frames are UNMASKED (RFC 6455 §5.1), so the frame header rides as the first iovec entry and the payload spans follow it straight to the wire via one gathered scatter-gather write per peer — no allocation, no copy. Each peer writes from a fresh copy of the iovec array (the write consumes it). No-op until a client is connected. Thread-safe (peers_m_ → write_m_, the header lock order).- Parameters:
iov – The spans to emit, in order, as a single frame.
-
inline bool delivers_ropes() const override¶
True — WS reassembles fragmented messages into ropes (ADR-0053 §5): each message crosses the seam as a
rope_t, one owning link per WS fragment (a single link for an unfragmented message), chained by reassembly, never memcpy’d flat. Covers both the transport_t and bus_link_t facets (one override, same contract).
-
inline std::uint64_t dropped_rx() const noexcept¶
Messages dropped because the RX backend was exhausted (backpressure, ADR-0039 §4 / ADR-0042 §2) — shed mid-reassembly, never an OOM. Summed over every peer this server has served.
-
inline std::uint64_t malformed_rx() const noexcept¶
RFC 6455 violations seen: an over-cap declared length (see kMaxFrame), a reassembled message past the cap, a §5.5 control-frame breach, or an opening handshake past its pre-auth budget (see kMaxHandshakeBytes, #934). Each one fails its connection (§7.1.7). Summed over every peer.
-
inline std::uint64_t dropped_tx() const noexcept¶
Messages shed on the way OUT (#932): a refused gather store, or no open peer slot to write to — each one used to be a bare return no observer could see.
-
inline transport_drop_stats_t drop_stats() const noexcept override¶
The interface-level snapshot (#932) — what a generic
transport_t*reads.
-
inline std::size_t effective_max_frame() const noexcept¶
The cap actually honored:
min(max_frame, backend.max_segment_size())— what a declared frame length is compared against, resolved from the two injected resources rather than restated as a number.
-
inline std::size_t effective_max_handshake() const noexcept¶
The pre-auth handshake budget actually honored:
handshake_cap(max_handshake)as constructed (#934). Unlike effective_max_frame it names no backend — a handshake is accumulated in the slot’s own request buffer, not in an RX segment, so there is no second injected resource to take the min against.
Public Static Functions
-
static inline constexpr std::size_t handshake_cap(std::size_t max_handshake) noexcept¶
Resolve a
max_handshakerequest into the honored budget — TIGHTEN-ONLY against kMaxHandshakeBytes, exactly aslength_prefix_framer::configured_capis againstkDefaultMaxFrame.0(unset) keeps the default; a nonzero value yieldsmin(max_handshake, kMaxHandshakeBytes). The value arrives through a config-writable key (ws-privatemax_handshake), and a config-writable key must never RAISE a pre-auth bound — only narrow it.
Public Static Attributes
-
static constexpr std::size_t kMaxFrame = length_prefix_framer::kDefaultMaxFrame¶
The largest MESSAGE a peer may announce — the shared length_prefix_framer::kDefaultMaxFrame (16 MiB) unless
:settings max_frametightens it, and further bounded by the injected backend’s real capacity.One WS message is one libtracer frame, so this is the same per-connection receive cap tcp/quic/webtransport apply to their length prefix — it just reads off a WS frame header instead. A frame (or a reassembled message) claiming more is malformed: malformed_rx ticks and the connection is failed, RFC 6455 §7.1.7.
-
static constexpr std::size_t kMaxHandshakeBytes = 16u * 1024u¶
The largest OPENING HANDSHAKE a PRE-AUTH peer may make this node buffer (16 KiB) — the ceiling
max_handshaketightens against (#934).A different budget from kMaxFrame and deliberately so: a frame arrives on an established connection under whatever the deployment allowed, while an HTTP Upgrade request arrives from a host that has done nothing but complete a TCP connect — no ACL, no subscription, no router, nothing authenticated. Pre-auth work is REFUSED EARLY, not carefully allocated: an accumulation that would pass this budget is refused BEFORE the byte that would exceed it is copied, malformed_rx ticks, and the link is closed (the count-then-close disposition, #838’s shape).
-
explicit transport_ws_server(std::uint16_t bind_port, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0, std::size_t max_peers = 0, bool peer_named = false, std::size_t recv_stack = 0, std::uint32_t liveness_window_ms = 0, std::size_t max_handshake = 0)¶
The WebSocket wire layer itself — opcodes, frame decode, the accept-key
computation — is pure and lives in tr::net::ws:
-
enum class tr::net::ws::opcode_t : std::uint8_t¶
RFC 6455 frame opcodes (the subset libtracer cares about).
Values:
-
enumerator CONT¶
Continuation frame.
-
enumerator TEXT¶
Text (UTF-8) data frame.
-
enumerator BINARY¶
Binary data frame.
-
enumerator CLOSE¶
Connection close control frame.
-
enumerator PING¶
Ping control frame.
-
enumerator PONG¶
Pong control frame.
-
enumerator CONT¶
-
struct frame_t¶
One decoded RFC 6455 data/control frame (payload already unmasked).
-
inline std::optional<std::pair<frame_t, std::size_t>> tr::net::ws::decode_frame(std::span<const std::byte> buf)¶
Decode exactly one RFC 6455 frame from the front of
buf— the pure outcome-collapsing decoder, byte-for-byte the behaviour it has always had.Deliberately does NOT apply the §5.5 control-frame rules or the §5.2 reserved-opcode rule (a reserved opcode decodes here, carried through as-is in frame_t::op), and imposes no length cap (kNoPayloadCap): this is the function
tests/conformance/ws_diff_fuzz.pyholds against the TypeScriptdecodeFrame, and the two cores must answer identically on every input. All three rules are CONNECTION-FAILURE policy, not decode outcomes — they belong to whoever owns the socket and can shed it. Every transport in this repository therefore uses decode_frame_checked; only a caller that has no connection to fail should use this one. It is safe to leave uncapped precisely because it never buffers on the caller’s behalf: a declared length past the end ofbufanswers “need more” and allocates nothing.- Parameters:
buf – A byte stream that may contain a partial or whole frame, possibly followed by more frames.
- Returns:
nullopt if
bufdoes not yet hold a complete frame (need more bytes); otherwise the decoded frame paired with the bytes consumed from the front.
-
inline decode_result_t tr::net::ws::decode_frame_checked(std::span<const std::byte> buf, std::size_t max_payload)¶
Decode exactly one RFC 6455 frame from the front of
buf, distinguishing “need more bytes” from “the peer broke the protocol” — the form a TRANSPORT uses.Handles the FIN bit, opcode, the MASK bit with its 4-byte masking key (client→server frames are masked; the payload is unmasked in place), and the 7 / 16 / 64-bit extended length encodings. Both masked and unmasked frames decode.
RFC 6455 §5.5 is enforced here (#848): a CONTROL opcode (
0x8-0xF) carrying more than kMaxControlPayload bytes, or arriving withFINclear, isPROTOCOL_ERROR— and it is diagnosed from the frame HEADER, before the payload has to be buffered, so an absurd declared length costs nothing. Without this a peer could send a 1 MiB PING and have the node echo it straight back: an unauthenticated reflection/amplification primitive, and (because the echo was astd::vector) a peer-triggeredabort()on the-fno-exceptionsprofile.The DATA path is bounded the same way (#872): a frame whose declared length exceeds
max_payloadisPROTOCOL_ERRORoff the HEADER too. The two rules are separate — §5.5 is a fixed RFC constant about CONTROL frames,max_payloadis the deployment’s injected receive cap about every frame — and neither substitutes for the other.§5.2’s OPCODE half is enforced here too (#1060; the RSV-bit half of §5.2 is not — nothing here examines
b0 & 0x70): an opcode outside the six opcode_t names is RESERVED, and receiving one is a Fail the WebSocket Connection condition — so it isPROTOCOL_ERRORoff the first header byte, ahead of both length rules.TEXTandPONGare unaffected: they are DEFINED opcodes, they still decode, and what a transport does with them (ignore them) stays the transport’s policy.- Parameters:
buf – A byte stream that may contain a partial or whole frame, possibly followed by more frames.
max_payload – The transport’s effective receive cap:
min(max_frame, backend.max_segment_size())(length_prefix_framer::effective_cap— the no-synthetic-limits doctrine). Deliberately NOT defaulted: a transport that forgets to name its bound is exactly the defect this parameter closes, so omitting it must not compile.
- Returns:
NEED_MOREwhilebufis short of a whole frame,PROTOCOL_ERRORon an RFC 6455 violation or an over-cap declared length (the caller must fail the connection, RFC 6455 §7.1.7), elseOKwith the frame and the bytes consumed.
-
constexpr std::size_t tr::net::ws::kMaxControlPayload = 125¶
The largest payload an RFC 6455 CONTROL frame may carry (§5.5).
“All control frames MUST have a payload length of 125 bytes or less and MUST NOT be
fragmented.” Bounding the reply to a peer’s PING at this is what lets the PONG be built entirely on the stack — no allocation, hence no failure mode to have a drop policy about (#848). It also removes the reflection/amplification primitive an unbounded echo was.
-
constexpr std::size_t tr::net::ws::kNoPayloadCap = ~std::size_t{0}¶
The
max_payloadargument that imposes NO length bound — every representable length passes the DATA-frame check.Not a limit but the ABSENCE of one: the largest value a
std::size_tcan hold, solen > kNoPayloadCapis false for every decodable length. It exists for the pure decoder (decode_frame), which by contract collapses outcomes and owns no connection to fail; a TRANSPORT never passes it — it passes its effective receive cap, which comes from the injected backend and:settings max_frame, never from a literal.
-
inline std::vector<std::byte> tr::net::ws::encode_frame(opcode_t op, std::span<const std::byte> payload, bool fin = true)¶
Encode one server→client RFC 6455 frame: given opcode, UNMASKED.
Server frames MUST NOT be masked (RFC 6455 §5.1), so the MASK bit is always 0 and no masking key is emitted. The length uses the smallest legal encoding (7-bit, then the 126 + 2-byte marker, then the 127 + 8-byte marker) — encoded by the shared
encode_frame_header()helper, then the payload appended.- Parameters:
op – The frame opcode.
payload – The application payload to send.
fin – The FIN bit (default true — a complete, unfragmented message; pass false for a non-final fragment, RFC 6455 §5.4).
- Returns:
The fully serialized frame bytes, ready to write to the socket.
-
inline std::size_t tr::net::ws::encode_frame_header(std::array<std::byte, kMaxServerFrameHeader> &out, opcode_t op, std::size_t len, bool fin = true)¶
Encode ONLY a server→client RFC 6455 frame header into
out, UNMASKED.Writes byte0 =
(fin?0x80:0)|op, then the payload length in the smallest legal encoding (7-bit, then the 126 + 2-byte u16-BE marker, then the 127 + 8-byte u64-BE marker); the MASK bit is always 0 (server frames MUST NOT be masked, RFC 6455 §5.1). This is the one SERVER-side (unmasked) length-encoding implementation — encode_frame appends the payload after it, encode_server_control delegates to it, and both gather sites intransport_ws.cppride the payload spans behind it with no copy. The MASKED client side does NOT share it:detail::put_client_framecarries its own ladder, because the MASK bit rides in the same byte as the 7-bit length (0x80u | len) and the 4-byte key follows the length it just wrote.- Parameters:
out – The header buffer to fill (
kMaxServerFrameHeaderbytes suffice).op – The frame opcode.
len – The payload length in bytes.
fin – The FIN bit (default true — a complete, unfragmented message; pass false for a non-final fragment, RFC 6455 §5.4).
- Returns:
The number of header bytes written into
out(2, 4, or 10).
-
inline std::size_t tr::net::ws::encode_server_control(std::array<std::byte, kMaxServerControlFrame> &out, opcode_t op, std::span<const std::byte> payload) noexcept¶
Encode one whole server→client CONTROL frame (PONG/CLOSE) into
out— on the STACK, nothrow, UNMASKED.The reply to a peer’s PING must not be able to fail: dropping a PONG costs the link (RFC 6455 §5.5.2/§5.5.3 make it the required response, and a peer whose PINGs go unanswered may fail the connection), while closing the session lets whoever caused the heap pressure decide the topology. Since §5.5 bounds a control payload at kMaxControlPayload — enforced by decode_frame_checked — the whole frame fits a fixed stack buffer and there is no failure mode left to have a policy about (#848).
Length encoding is delegated to encode_frame_header, which stays the one SERVER-side (unmasked) length-encoding implementation: every unmasked frame this header emits — encode_frame, this function, and both gather sites in
transport_ws.cpp— goes through it. The masked client side encodes its own lengths (seedetail::put_client_frameand encode_client_control).- Parameters:
out – The frame buffer to fill.
op – The control opcode (PONG / CLOSE).
payload – The control payload to echo (at most kMaxControlPayload bytes).
- Return values:
0 –
payloadexceeds kMaxControlPayload — nothing was written.- Returns:
The number of frame bytes written into
out.
-
inline std::size_t tr::net::ws::encode_client_control(std::array<std::byte, kMaxClientControlFrame> &out, opcode_t op, std::span<const std::byte> payload, std::uint32_t mask_key) noexcept¶
Encode one whole client→server CONTROL frame into
out— on the STACK, nothrow, MASKED (RFC 6455 §5.1).The client twin of encode_server_control — same reasoning, plus the 4-byte masking key and the payload XOR. A control length is always < 126, so the header is the 2-byte form with
MASK=1.- Parameters:
out – The frame buffer to fill.
op – The control opcode (PONG / CLOSE).
payload – The control payload to echo (at most kMaxControlPayload bytes).
mask_key – The 32-bit masking key (its 4 bytes form the RFC 6455 key).
- Return values:
0 –
payloadexceeds kMaxControlPayload — nothing was written.- Returns:
The number of frame bytes written into
out.
-
inline std::vector<std::byte> tr::net::ws::encode_client_frame(opcode_t op, std::span<const std::byte> payload, std::uint32_t mask_key, bool fin = true)¶
Encode one client→server RFC 6455 frame: given opcode, MASKED.
Client frames MUST be masked (RFC 6455 §5.1): the MASK bit is set, a 4-byte masking key is emitted big-endian after the length, and every payload byte is XOR’d with
mask_key[i % 4]. The caller suppliesmask_keyper frame; it need not be cryptographically strong (libtracer is not defending against a same-process attacker), only varied — a counter-derived value is fine. The length uses the smallest legal encoding (7-bit, then 126 + 2 bytes, then 127 + 8 bytes). The server-side encode_frame() above is unaffected.- Parameters:
op – The frame opcode.
payload – The application payload to send.
mask_key – The 32-bit masking key (its 4 bytes form the RFC 6455 key).
fin – The FIN bit (default true — a complete, unfragmented message; pass false for a non-final fragment, RFC 6455 §5.4).
- Returns:
The fully serialized masked frame bytes, ready to write to the socket.
-
inline std::size_t tr::net::ws::try_encode_client_frame(mem::block_array_t<std::byte> &out, opcode_t op, std::span<const std::byte> payload, std::uint32_t mask_key, bool fin = true) noexcept¶
NOTHROW encode_client_frame — build the masked frame into
out, soft-failing on OOM instead of abad_allocabort()under-fno-exceptions(#848).The one WS egress encoder that survives as a nothrow twin rather than being deleted: a client frame MUST be masked (RFC 6455 §5.1), so the bytes on the wire are not the caller’s bytes and cannot be gathered by reference the way the UNMASKED server frames are. Reuse one
outbuffer across calls and the steady state allocates nothing. On0the caller DROPS the frame — the same answer the delivery path already gives under exhaustion.- Why a and not a +
On the profile this encoder ships to,
try_reservecannot express a refusal at all.std::vector::reservereports exhaustion by throwing, and under-fno-exceptionsthat is a bareabort()insidereservethat no wrapper can intercept — so theretry_reservestill has to guess ahead with a nothrow probe and hope nothing takes the block in between (#923; on a hosted build it catches instead, which is sound but is not the MCU profile). That is the very outcome #848 exists to remove, so this path draws from the failable seam instead (ADR-0065): growth is ONEblock_source_t::try_allocthat answersnullptr, on both profiles, with no unguardable second step.
- Parameters:
out – The reusable frame buffer; its capacity is retained across calls.
op – The frame opcode.
payload – The application payload to send.
mask_key – The 32-bit masking key (its 4 bytes form the RFC 6455 key).
fin – The FIN bit (default true — a complete, unfragmented message).
- Return values:
0 – The frame buffer could not be grown — nothing was written, drop the frame.
- Returns:
The number of frame bytes written to
out.data()(never 0 on success: a client frame is at least a 2-byte header plus the 4-byte masking key).
-
inline std::string tr::net::ws::accept_key(std::string_view client_key)¶
Compute the RFC 6455 Sec-WebSocket-Accept value for a client key.
accept = base64(sha1(client_key + GUID)) where GUID is the fixed magic “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”. The server returns this in the 101 Switching Protocols response to prove it spoke RFC 6455.
- Parameters:
client_key – The raw Sec-WebSocket-Key header value sent by the client.
- Returns:
The Sec-WebSocket-Accept text.
QUIC and WebTransport (the optional module)¶
-
class quic_transport_t : public tr::net::transport_t¶
The msquic QUIC transport_t (ADR-0043 Phase A) — length-prefix framing over ONE bidirectional stream on one connection.
Every frame is sent as
u32-LE length ++ frame bytes(identical to tcp_transport_t, so the two wire framings are interchangeable above the seam). msquic delivers received stream data in callback chunks; the transport reassembles the prefix and exactly-lenbody bytes into ONE refcounted segment drawn from the injectedmem_backend_t(ADR-0042 §2), handed up OWNING when a view receiver is installed. TX copies each frame ONCE into a heap buffer that msquic owns until its SEND_COMPLETE event (the msquic buffer-lifetime contract) — the only library-held buffer, and only for the duration of the in-flight send.Public Functions
-
quic_transport_t(const std::string &peer_host, std::uint16_t peer_port, quic_dial_tls_t tls = {}, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0)¶
DIAL mode: connect to
peer_host: andopen the frame stream (synchronous — the constructor waits for the QUIC handshake, the tcp_transport_t dial shape).Confirm with ok(); on failure no connection is live and the object is inert. On success the bidirectional frame stream is started and frames may flow immediately, so receivers must be installed before the peer sends (the set_receiver contract).
- Parameters:
peer_host – Peer hostname or dotted-quad IPv4 (e.g. “127.0.0.1”).
peer_port – Peer UDP port (host byte order).
tls – Server-certificate trust: a CA bundle, or the DEV-ONLY no-verify flag (see quic_dial_tls_t).
backend – The host-injected RX memory seam (ADR-0042 §2): each inbound frame is reassembled into a fresh exactly-
len-byte segment from it (default: the process heap). Exhaustion is backpressure — the frame is drained off the stream, dropped, and dropped_rx() ticks; never an OOM. Must outlive the transport.
-
quic_transport_t(std::uint16_t bind_port, const std::string &cert_file, const std::string &key_file, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0)¶
LISTEN mode: serve QUIC on
bind_portwith the PEM certificate atcert_file/ private key atkey_file, accepting ONE inbound peer at a time (the tcp_transport_t / transport_ws_server one-peer model; re-accepts after a peer departs).Use ok() to confirm the listener started (bad cert paths fail here); the bound port (an ephemeral 0 request resolved) is observable via local_port(). The peer opens the frame stream.
- Parameters:
bind_port – UDP port to listen on (host byte order; 0 → ephemeral).
cert_file – PEM server-certificate path (tools/gen-dev-cert.sh emits a self-signed dev pair).
key_file – PEM private-key path matching
cert_file.backend – The RX memory seam — see the DIAL constructor.
-
~quic_transport_t() override¶
Shut the connection down, drain msquic callbacks, and release the msquic API (listener → stream → connection → registration order).
-
virtual void send(std::span<const std::byte> frame) override¶
Send
frameas one length-prefixed record on the frame stream.The prefix and frame bytes are copied ONCE into a single heap buffer handed to msquic, which owns it until SEND_COMPLETE (the msquic buffer-lifetime contract; the seam’s spans are only borrowed for this call, so the copy is unavoidable and minimal). No-op until a peer’s stream is up (and after teardown). Thread-safe.
- Parameters:
frame – A complete TLV’s bytes.
-
virtual void send(std::span<const std::span<const std::byte>> iov) override¶
Scatter-gather send: the prefix + every span as ONE record.
ONE gather copy: msquic’s StreamSend does take multiple QUIC_BUFFERs, but it requires every buffer to stay alive until SEND_COMPLETE while the seam’s spans are only borrowed for this call — so the spans are gathered once into the single owned send buffer (prefix first), exactly the copy the single-span overload makes.
- Parameters:
iov – The frame’s spans (a rope’s
to_iovec()), concatenated on the wire as one length-prefixed frame.
-
inline virtual bool delivers_ropes() const override¶
True — this transport honors set_rope_receiver (ADR-0042).
-
bool ok() const noexcept¶
The came-up predicate (#1059) — DIAL: the handshake completed and the frame stream started; LISTEN: the listener is up on its port. Answered at construction and never reverting; liveness is link_up.
-
std::uint16_t local_port() const noexcept¶
LISTEN mode: the actual bound UDP port (resolves an ephemeral 0).
-
virtual bool link_up() const noexcept override¶
Liveness (the transport_t::link_up contract), from the QUIC connection events: true from CONNECTED until the connection shuts down (peer/transport/idle). Relaxed atomic.
-
std::uint64_t dropped_rx() const noexcept¶
Frames dropped because the RX backend was exhausted (backpressure, ADR-0039 §4 / ADR-0042 §2) — drained off the stream, never an OOM.
-
std::uint64_t malformed_rx() const noexcept¶
Malformed length prefixes seen (announced length > kMaxFrame). Each one shuts the connection down — the stream has lost framing sync.
-
std::uint64_t dropped_tx() const noexcept¶
Frames shed on the way OUT (#932): a record over THIS CONNECTION’s cap (
:settings max_frame, resolved tighten-only against kMaxFrame — it is the same number the peer measures the arriving prefix by, #1409), no live peer stream to write to (dialing / torn down), or aStreamSendmsquic refused. H3 handshake material is not counted — it is not a frame.
-
inline virtual transport_drop_stats_t drop_stats() const noexcept override¶
The interface-level snapshot (#932) — the concrete accessors above, as the one shape a generic
transport_t*holder reads.
Public Static Attributes
-
static constexpr std::size_t kMaxFrame = length_prefix_framer::kDefaultMaxFrame¶
The largest frame the length prefix may announce — the shared length_prefix_framer::kDefaultMaxFrame (16 MiB) unless
:settings max_frametightens it. A larger prefix is malformed: counted via malformed_rx and the connection is shut down (a desynced stream cannot be trusted again).
-
quic_transport_t(const std::string &peer_host, std::uint16_t peer_port, quic_dial_tls_t tls = {}, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0)¶
-
struct quic_dial_tls_t¶
DIAL-side TLS trust options for quic_transport_t (ADR-0043 Phase A).
QUIC is TLS 1.3 by construction, so the dialer must decide how to trust the server certificate. Exactly one of the two knobs is used: a CA bundle to verify against, or the DEV-ONLY escape hatch that skips verification (the only way to reach a self-signed dev cert, which cannot chain to any CA).
-
class webtransport_transport_t : public tr::net::transport_t¶
The WebTransport transport_t (ADR-0043 Phase B): an HTTP/3 extended CONNECT session whose ONE bidirectional WebTransport stream carries the 4-byte u32-LE length-prefix framing.
LISTEN mode is the #92 deliverable: a browser (the TS
@avatarsd-llc/libtracer-webtransportpackage) or the DIAL mode of this class connects withnew WebTransport(url)semantics — H3 SETTINGS both ways, extended CONNECT, 200 — and then opens one bidirectional stream that becomes the frame channel. RX frames are reassembled into ONE refcounted segment each from the injectedmem_backend_t(ADR-0042 §2, owning delivery); TX copies each frame once into the buffer msquic owns until SEND_COMPLETE — exactly the quic_transport_t contracts.Public Functions
-
webtransport_transport_t(const std::string &peer_host, std::uint16_t peer_port, const std::string &path = "/", webtransport_dial_tls_t tls = {}, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0, bool defer_rx = false, std::size_t max_handshake = 0)¶
DIAL mode: establish a WebTransport session to
and open the frame stream (synchronous — the constructor waits for the QUIC handshake, the H3 SETTINGS/CONNECT exchange, and the 200).Confirm with ok(); on failure the object is inert. On success frames may flow immediately, so receivers must be installed before the peer sends (the set_receiver contract) — or the link is constructed with
defer_rxand armed with start_receiving once they are.- Parameters:
peer_host – Server hostname or dotted-quad IPv4 (the CONNECT
:authorityhost part).peer_port – Server UDP port (host byte order).
path – The CONNECT
:path(a server-side namespace knob; this server accepts any path — default “/”). Empty is normalised to “/”. A SPEC-created dialer reaches this through the kind-privatepathconfig key (#1023).tls – Server-certificate trust (see webtransport_dial_tls_t).
backend – The host-injected RX memory seam (ADR-0042 §2); each inbound frame lands in a fresh exactly-sized segment from it. Exhaustion is backpressure (dropped_rx()), never an OOM. Must outlive the transport.
max_frame – Per-link RX cap (
:settings max_frame); 0 = the default.defer_rx – Hold inbound FRAMES until start_receiving (#1101, ADR-0081 §2). The session is established here as always — the H3 handshake keeps consuming — but the frame channel’s bytes are left in msquic’s per-stream flow-control window, so a server that pushes the instant the session comes up cannot be decoded into a sink the owner has not installed yet. Nothing is buffered library-side. Default false (the historical one-phase shape);
transport_vertex_tbuilds a SPEC-created dialer with it set.max_handshake – Pre-auth H3 handshake budget (
max_handshake); 0 = the kMaxHandshakeBytes default. TIGHTEN-ONLY — see handshake_cap. On the DIAL side it bounds the CONNECT RESPONSE’s field section, declared length included.
-
webtransport_transport_t(std::uint16_t bind_port, const std::string &cert_file, const std::string &key_file, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0, std::size_t max_handshake = 0)¶
LISTEN mode: serve WebTransport (ALPN
h3) onbind_portwith the PEM certificate atcert_file/ key atkey_file, accepting ONE session at a time (the quic_transport_t one-peer model; re-accepts after a peer departs).Use ok() to confirm the listener started; the bound port is observable via local_port(). The session peer opens the frame stream. Browser dev trust:
serverCertificateHashesneeds an ECDSA cert valid <= 14 days — see the TS package README; the C++ DIAL side accepts any cert under its DEV-ONLY no-verify mode.- Parameters:
bind_port – UDP port to listen on (host byte order; 0 → ephemeral).
cert_file – PEM server-certificate path.
key_file – PEM private-key path matching
cert_file.backend – The RX memory seam — see the DIAL constructor.
max_frame – Per-link RX cap (
:settings max_frame); 0 = the default.max_handshake – Pre-auth H3 handshake budget (
max_handshake); 0 = the kMaxHandshakeBytes default. TIGHTEN-ONLY — see handshake_cap. On this LISTEN side it bounds per-stream classification/HEADERS accumulation, and the DECLARED length of a HEADERS or unknown/GREASE frame, so an over-declaration is refused before one body byte is buffered.
-
~webtransport_transport_t() override¶
Shut the session down, drain msquic callbacks, and release the msquic API (listener → streams → connection → registration order).
-
virtual void send(std::span<const std::byte> frame) override¶
Send
frameas one length-prefixed record on the WebTransport frame stream.One copy into the buffer msquic owns until SEND_COMPLETE (the quic_transport_t TX contract). No-op until the session’s frame stream is up (and after teardown). Thread-safe.
- Parameters:
frame – A complete TLV’s bytes.
-
virtual void send(std::span<const std::span<const std::byte>> iov) override¶
Scatter-gather send: the prefix + every span as ONE record (one gather copy — the quic_transport_t rationale).
- Parameters:
iov – The frame’s spans (a rope’s
to_iovec()), concatenated on the wire as one length-prefixed frame.
-
virtual void start_receiving() override¶
Open this link’s delivery gate — the second phase of a
defer_rxDIAL bring-up (#1101, ADR-0081 §2).Re-enables msquic’s receive on the WebTransport frame stream, so everything the peer pushed while the owner was installing its sinks is re-indicated and delivered rather than dropped. IDEMPOTENT and inert on every other link — a one-phase dialer, a listener, and a dial that never came up all have nothing to arm — because
transport_vertex_t::make_connectioncalls it on every link it wires.
-
inline virtual bool delivers_ropes() const override¶
True — this transport honors set_rope_receiver (ADR-0042).
-
bool ok() const noexcept¶
The came-up predicate (#1059) — DIAL: the WebTransport session is established (200 received) and the frame stream started; LISTEN: the listener is up on its port. Answered at construction and never reverting; liveness is link_up.
-
std::uint16_t local_port() const noexcept¶
LISTEN mode: the actual bound UDP port (resolves an ephemeral 0).
-
virtual bool link_up() const noexcept override¶
Liveness (the transport_t::link_up contract): true from the QUIC CONNECTED event until the connection (and with it the session) shuts down. Relaxed atomic.
-
bool session_up() const noexcept¶
True once the WebTransport session is established — the extended CONNECT was accepted (LISTEN: request validated + 200 sent; DIAL: 200 received).
-
std::string session_path() const¶
The extended CONNECT
:pathof this endpoint’s session — DIAL: the path this endpoint requests (known from construction); LISTEN: the path the peer’s ACCEPTED CONNECT named, empty until one is accepted.The listener serves every path (it validates
:method/:protocol, never the resource), so this is an observation, not an admission decision: it is how a host sees which resource a session asked for. Returns a copy — thread-safe, and not on any frame path.STABLE for the life of a session (#1410): a second extended CONNECT on a live session is refused at stream scope, so a peer that has already been answered cannot rewrite what a host observes here. It changes only when the session itself does — connection teardown, or the one-peer replacement path accepting a new peer.
-
std::uint64_t dropped_rx() const noexcept¶
Frames dropped because the RX backend was exhausted (backpressure, ADR-0042 §2) — drained off the stream, never an OOM.
-
std::uint64_t malformed_rx() const noexcept¶
Malformed length prefixes seen (announced length > kMaxFrame). Each one shuts the connection down (framing sync is lost).
-
std::uint64_t dropped_tx() const noexcept¶
Frames shed on the way OUT (#932): a record over THIS CONNECTION’s cap (
:settings max_frame, resolved tighten-only against kMaxFrame — it is the same number the peer measures the arriving prefix by, #1409), no live peer stream to write to (dialing / torn down), or aStreamSendmsquic refused. H3 handshake material is not counted — it is not a frame.
-
std::uint64_t refused_sessions() const noexcept¶
Extended CONNECT handshakes REFUSED because the node could not afford to answer them (#934).
The LISTEN side reaches two allocations on the strength of one unauthenticated peer’s HEADERS frame: recording the requested
:path, and the one owned copy of the 200 response msquic borrows until SEND_COMPLETE. Both are nothrow, and a refusal is COUNT-THEN-CLOSE — this counter, then the connection is shut down with the bad-request code, so the peer’s memory is freed at once and nothing is left half-established. Never moves on a healthy node; a rising value means the node is shedding pre-auth work under memory pressure, which is the event the standing “no peer-provoked path may abort the node” commitment (docs/reference/07-host-embedding.md) makes observable rather than fatal.Distinct from dropped_rx (a FRAME shed for backpressure on an established session) and from the stream-scoped handshake-buffer refusal, which aborts one stream and leaves an already-established session alone (#919).
-
inline virtual transport_drop_stats_t drop_stats() const noexcept override¶
The interface-level snapshot (#932) — the concrete accessors above, as the one shape a generic
transport_t*holder reads.
-
std::size_t live_streams() const noexcept¶
Stream contexts the live session currently holds — the leak observable (#1163).
A peer opens streams and this endpoint keeps one context per stream until the stream finishes. The count is therefore bounded by what the peer has open at once (
PeerBidiStreamCount+PeerUnidiStreamCount+ this endpoint’s own H3 streams), and NOT by how many the peer has ever opened. Before #1163 the second bound was the real one: nothing reclaimed a finished stream, so open/close cycling grew this without limit.Exposed because a count that only ever rises is the signature of that class of bug and a deployment cannot see it otherwise — the same reason dropped_rx and malformed_rx are public. It is a live gauge, not a monotonic counter: it falls.
-
std::size_t effective_max_handshake() const noexcept¶
The pre-auth handshake budget actually honored:
handshake_cap(max_handshake)as constructed (#1408). It names no backend — H3 handshake bytes accumulate in the stream context’s own buffer, not in an RX segment, so unlike the frame cap there is no second injected resource to take the min against. Answered on every link, including one whose dial never came up.
Public Static Functions
-
static inline constexpr std::size_t handshake_cap(std::size_t max_handshake) noexcept¶
Resolve a
max_handshakerequest into the honored budget — TIGHTEN-ONLY against kMaxHandshakeBytes, exactly aslength_prefix_framer::configured_capis againstkDefaultMaxFrame.0(unset) keeps the default; a nonzero value yieldsmin(max_handshake, kMaxHandshakeBytes). The value arrives through a config-writable key (webtransport-privatemax_handshake), and a config-writable key must never RAISE a pre-auth bound — only narrow it.Note
The same shape
transport_ws_server::handshake_capcarries for the WS plane (#1407), deliberately spelled rather than shared:sits behindtransport_ws.hppLIBTRACER_TRANSPORT_WSand can be configured OFF, so consuming its symbol here would make an optional core module a hard dependency of this optional transport module.
Public Static Attributes
-
static constexpr std::size_t kMaxFrame = length_prefix_framer::kDefaultMaxFrame¶
The largest frame the length prefix may announce — the shared length_prefix_framer::kDefaultMaxFrame (16 MiB) unless
:settings max_frametightens it. A larger prefix is malformed: counted via malformed_rx and the session’s connection is shut down.
-
static constexpr std::size_t kMaxHandshakeBytes = 16u * 1024u¶
The largest H3 HANDSHAKE a PRE-AUTH peer may make this node buffer (16 KiB) — the ceiling
max_handshaketightens against (#1408).A different budget from kMaxFrame and deliberately so: a frame arrives on an established session under whatever the deployment allowed, while H3 classification bytes arrive from a host that has done nothing but complete a QUIC handshake and open one stream — no session, no ACL, no subscription, no router, nothing authenticated. Pre-auth work is REFUSED EARLY, not carefully allocated: an accumulation that would pass this budget is refused BEFORE the byte that would exceed it is copied, and a HEADERS or unknown/GREASE frame DECLARING more than it is refused before one body byte is buffered.
The two dispositions this module already distinguishes are unchanged and must not be merged: over-budget is a statement about the PEER, so it shuts the connection down with the bad-request code; running out of memory is a statement about US, so it is stream-scoped (#919) or count-then-close (refused_sessions, #934).
-
webtransport_transport_t(const std::string &peer_host, std::uint16_t peer_port, const std::string &path = "/", webtransport_dial_tls_t tls = {}, mem::mem_backend_t *backend = &mem::heap_backend(), std::size_t max_frame = 0, bool defer_rx = false, std::size_t max_handshake = 0)¶
-
struct webtransport_dial_tls_t¶
DIAL-side TLS trust options for webtransport_transport_t (the quic_dial_tls_t shape for the HTTP/3 dial).
A browser trusts the server via WebCrypto
serverCertificateHashes(dev) or a real certificate; this C++ dial side — used by the self-contained e2e tests and native clients — trusts a CA bundle, or skips validation in the DEV-ONLY mode a self-signed dev cert requires.
-
transport_vertex_t::transport_factory_t tr::net::quic_transport_factory(mem::mem_backend_t *rx_backend = &mem::heap_backend())¶
The ready-to-register
quictransport factory — how this module plugs into the transport catalog (the register_transport_type extension seam; the core has noquicbuiltin).Register at setup:
net.register_transport_type("quic", quic_transport_factory()). A subsequent:children[]SPEC whose config carrieskind = quicthen constructs a quic_transport_t from the parsed settings — DIAL:addr+portplus the OPTIONAL trust keys below; LISTEN:portplus the REQUIREDcert/keyPEM-path config keys (QUIC is TLS 1.3 by construction; tools/gen-dev-cert.sh emits a dev pair). All four are quic-PRIVATE config keys: the factory parses them itself from the raw config SETTINGS TLV it receives — they never appear in the sharedconn_settings_t, which stays lean with only the universal keys (the ADR-0043 §5 leanness ruling). Missing fields fail creation withTYPE_MISMATCH; a socket that failed to come up fails withTRANSPORT_DOWN— the TRANSIENT status, because the address resolved and it was the link that did not come up (#929).keepaliveis ignored (#66 owns link lifecycle).A SPEC-created dialer verifies the server certificate (#918). The trust mode is whatever quic_dial_tls_t defaults to, so with neither DIAL key present the handshake validates against the system trust store and a certificate that does not chain to it is REFUSED (creation answers
TRANSPORT_DOWN). Two DIAL-side keys move it:ca(NAME, a filesystem path) — verify against this PEM CA bundle instead of the system trust store; the way to reach a privately-issued or self-signed peer while still authenticating it.insecure(VALUE, u8; default 0) —1skips server-certificate validation entirely. DEV ONLY, and deliberately explicit: a deployment that wants an unauthenticated dialer has to write it down. Never set it outside dev.
- Parameters:
rx_backend – The ADR-0042 §2 receive-segment seam every constructed socket draws its inbound frame segments from (default: the process heap). Must outlive the constructed transports.
- Returns:
The factory functor for transport_vertex_t::register_transport_type.
-
transport_vertex_t::transport_factory_t tr::net::webtransport_transport_factory(mem::mem_backend_t *rx_backend = &mem::heap_backend())¶
The ready-to-register
webtransporttransport factory — how the module plugs this kind into the transport catalog (the register_transport_type extension seam; no core builtin).Register at setup:
net.register_transport_type("webtransport", webtransport_transport_factory()). A:children[]SPEC whose config carrieskind = webtransportthen constructs a webtransport_transport_t — DIAL:addr+portplus the OPTIONALpathand trust keys below; LISTEN:portplus the REQUIREDcert/keyPEM-path config keys. Both roles additionally read the OPTIONALmax_handshakebudget (#1408). All six are kind-PRIVATE config keys parsed by this factory from the raw SPEC config TLV — they never appear on the sharedconn_settings_t(the ADR-0043 §5 leanness ruling). Missing fields fail withTYPE_MISMATCH; a session that failed to come up fails withTRANSPORT_DOWN— the TRANSIENT status, because the address resolved and it was the link that did not come up (#929).The DIAL key (#1023) carries the extended CONNECT
:path— the resource the WebTransport session is opened on. NAME, default/, so a SPEC that omits it dials the same/this factory used to hard-code. Reaching a server that serves its session elsewhere needs it: this DIAL side treats any non-200answer to the extended CONNECT as a failed session, so a wrong resource surfaces asTRANSPORT_DOWNfrom creation — the same status a rejected certificate gives. The key is kind-private, so it does not collide with thecankind’s unrelatedpathkey (an advertised group path).The key (#1408) carries the pre-auth H3 handshake budget in bytes — VALUE u32, default
0= webtransport_transport_t::kMaxHandshakeBytes (16 KiB), read on BOTH roles and TIGHTEN-ONLY (webtransport_transport_t::handshake_cap clamps a larger request). It is the injected spelling of a bound that used to be a file-local literal, so the deployment sets the ceiling rather than the compiler.A SPEC-created dialer verifies the server certificate (#918) — the trust mode is whatever webtransport_dial_tls_t defaults to, so with neither DIAL key present a certificate that does not chain to the system trust store is REFUSED (creation answers
TRANSPORT_DOWN). The same two DIAL-side keys as thequickind move it:ca(NAME, a PEM CA-bundle path) verifies against that bundle instead, andinsecure(VALUE u8, default 0) set to1skips validation entirely — DEV ONLY, and explicit on purpose.- Parameters:
rx_backend – The ADR-0042 §2 receive-segment seam every constructed endpoint draws inbound frame segments from (default: the process heap). Must outlive the constructed transports.
- Returns:
The factory functor for transport_vertex_t::register_transport_type.
In-process loopback (development and test)¶
-
class loopback_channel_t¶
An in-process loopback channel: two endpoints, each delivering to the other.
A frame sent on a is delivered to b’s receiver (and vice-versa), on that endpoint’s receive thread — modeling async cross-“wire” delivery so forwarding never recurses on the sender’s stack. No sockets; deterministic. The vehicle for exercising FWD forward/reply routing end to end (RFC-0004, ADR-0040). Non-copyable. Call shutdown (or destroy the channel) before the registered receivers are destroyed.
Public Functions
-
loopback_channel_t()¶
Construct a channel and start both endpoints’ receive threads.
-
~loopback_channel_t()¶
Destroy the channel, joining both receive threads first.
-
inline loopback_endpoint_t &a() noexcept¶
The first endpoint; a frame sent here is delivered to b.
-
inline loopback_endpoint_t &b() noexcept¶
The second endpoint; a frame sent here is delivered to a.
-
void shutdown()¶
Join both receive threads so no frame reaches a dead receiver (idempotent).
-
loopback_channel_t()¶
-
class loopback_endpoint_t : public tr::net::transport_t¶
One end of an in-process loopback link (dev/test only).
A transport_t whose send hands the frame to the PEER endpoint’s receiver, on that peer’s receive thread. Constructed and owned by a loopback_channel_t.
Public Functions
-
virtual void send(std::span<const std::byte> frame) override¶
Send one frame — delivered to the peer endpoint’s receiver on its recv thread.
-
virtual void send(std::span<const std::byte> frame) override¶
Catalog registration¶
-
void tr::net::register_builtin_transports(transport_vertex_t &vertex, mem::mem_backend_t *rx_backend, mem::block_source_t *egress_src = &mem::heap_source())¶
Register every built-in transport factory compiled into this build.
Called once from the transport_vertex_t constructor. The definition is build-specific: src/builtin_transports.cpp provides the full-node form (udp + tcp + ws), while a core build that drops a transport compiles a CMake-generated variant (from src/builtin_transports.cpp.in) that calls only the enabled register_*_transport.
- Parameters:
vertex – The transport vertex to register the catalog entries on.
rx_backend – The ADR-0042 §2 receive-segment seam threaded to owning transports.
egress_src – The ADR-0079 net-plane EGRESS store threaded to every socket these factories construct — see
with_egress_source. Default: the process heap (today’s behaviour, unchanged).
-
void tr::net::register_udp_transport(transport_vertex_t &vertex, mem::mem_backend_t *rx_backend, mem::block_source_t *egress_src = &mem::heap_source())¶
Register the built-in
udptransport factory onvertex(needs transport_udp).egress_srcis the ADR-0079 egress store — seewith_egress_source.
-
void tr::net::register_tcp_transport(transport_vertex_t &vertex, mem::mem_backend_t *rx_backend, mem::block_source_t *egress_src = &mem::heap_source())¶
Register the built-in
tcptransport factory onvertex(needs transport_tcp).egress_srcis the ADR-0079 egress store — seewith_egress_source.
-
void tr::net::register_ws_transport(transport_vertex_t &vertex, mem::mem_backend_t *rx_backend, mem::block_source_t *egress_src = &mem::heap_source())¶
Register the built-in
wstransport factory onvertex(needs transport_ws).egress_srcis the ADR-0079 egress store — seewith_egress_source.
CAN is a stack of its own — the ID codec, the advertise stream, the splitter and the reassembler as well as the binding — and has its own page.
See: can, fwd-router, interface map, reference §communication flows, bench suite.