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

set_receiver(fn, ctx)

valid only for the callback

the default

Owning rope

set_rope_receiver(fn, ctx)

refcounted; may be kept, subroped, forwarded

delivers_ropes() returns true

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.

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

LIBTRACER_TRANSPORT_CAN=ON

a static_assert in transport_can.cpp — CAN is a bus by construction, so a bus-less CAN build is broken, not smaller

SPEC{listener, kind=tcp|ws, peer_named=1}

the factory answers TYPE_MISMATCH — permanent, because no retry grows this build a bus facet — and creates no connection

a directly constructed peer-named slot_server_t

ok() is false, the came-up predicate every caller already checks

httpd_ws_link_t (ESP-IDF)

ok() is false — it is peer-named by construction and has no flat mode

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 TypeScript transport-webtransport package (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_t built over it, hands the router a dangling window on the next receive. A receiver that keeps the frame installs the rope sink and requires delivers_ropes().

  • ctx must 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 before shutdown() — 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_client and tcp_transport_t honor it when constructed with defer_recv (#1045), which is how transport_vertex_t builds a SPEC-created ws or tcp dialer. The ESP-IDF-native WS client honors it too, in its own shape: its recv thread does the dialling, so defer_recv holds the first dial behind the start_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 no ws factory on a chip target. webtransport’s DIAL side honors it as well, with defer_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 and start_receiving() re-enables them, while the H3/QPACK state machine keeps consuming its own streams throughout. Nothing is buffered library-side. quic still takes the no-op default, with the LISTEN-side gates its own follow-up (#1114). udp has 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. can has 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 tick transport_can::dropped_presink() (#1103).

  • The callable sugar binds by address. set_receiver(F& sink) and set_rope_receiver(F& sink) take an lvalue; a temporary lambda does not compile, and a callable destroyed early dangles exactly like a stale ctx.

  • 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 native sendmsg/writev that 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_t overflow block the socket transports’ ::iovec tables grow into — comes from transport_t::egress_source(), a mem::block_source_t the transport factory wires per link (register_builtin_transportsegress_src argument, fed by transport_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.

Shared scaffolding

Three pieces are shared by every transport rather than reimplemented in each, and they are the reason a new binding is small.

  • receiver_slot_t is the one home of the delivery-tier mechanism: the guarded storage for a receiver pair, the per-frame snapshot, and the owning-rope-versus-borrowed-span tier select. Its callbacks are plain {function pointer, context} pairs, so taking a snapshot on the receive path is a trivial copy under an uncontended lock rather than a heap allocation. Its Tag... pack is how a bus link prepends the sending peer’s HANDLE to both sinks.

  • posix_endpoint_t is the receive-thread scaffold every POSIX socket transport shares: the stop flag, the thread lifecycle, and the bounded poll-and-recheck idioms that let a blocking socket loop notice a clean shutdown. stream_endpoint_t adds what only the stream transports need — the peer-fd atomic, the write mutex, and the teardown-under-write-lock ordering that keeps a concurrent send from writing to a reused descriptor. UDP keeps its datagram shape and uses only the base. slot_server_t is one tier further up, for the MULTI-peer stream servers: it owns the slot vector, the accept/poll/ teardown machinery and the peer query trio, so transport_tcp_server and transport_ws_server differ only in their framing and handshake — the two hooks it dispatches into them. The ADR-0044 bus FACET is a tier below that again — bus_slot_server_t carries it, flat_slot_server_t does not, and stream_server_base_t picks between them by tr::net::kBusLinks — so a bus-less build’s listener does not contain one.

  • register_builtin_transports is how a node’s transport catalog gets populated. Each register_*_transport lives in its own translation unit, compiled only when that transport is enabled, so a build that drops a transport leaves neither a compiled factory nor a dangling call to it. No preprocessor macro selects a transport: selection is which translation units get compiled.

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_t of 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_t overflow block of the socket transports that build a gather table — is drawn from HERE rather than from the process-wide mem::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 never abort().

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_src argument of register_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. src must outlive this transport.

Warning

A link’s egress store is touched by EVERY thread that sends on that link, so src must declare a concurrency contract covering them (block_source_t §”each source declares its own”). heap_source_t does; a pool_source_t<sync_none_t> or a bump_source_t does 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 locking src introduces 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_t member 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; ctx is 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: sink is 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_t whose links are refcounted views over segments drawn from a host-injected mem_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_child installs the receiver matching the link’s capability.

Parameters:
  • fn – The owning frame sink; ctx is 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: sink is 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_connection does exactly that, once the link is registered and fwd_router_t::add_child has 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.

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 (the make_checked gate), 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=false or 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 peer names — 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_t a HANDLER sees. It is deliberately NOT bus_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 same p<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 kPeerNameChars bytes. The returned view points either into scratch or at storage that outlives the call.

Return values:

{}peer is 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_child installs 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; ctx is 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 off rx_.has_rope().

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 mints kSolePeerHandle

once 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; 0 is 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.

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_t falls back to when a FWD’s next dst segment 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 src names 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 node field). 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 dst segment 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

Visitor invoked once per currently-audible peer name.

The peer-named inbound sink fn: (ctx, sending peer’s HANDLE, frame bytes).

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).

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.

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

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.

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_t names a peer p<slot> for the slot it landed in, and transport_can names one n<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 into scratch or into storage the link owns for its lifetime, so it is valid for as long as BOTH survive.

Return values:

{}peer is not peer_handle_t::valid, or names no peer of this kind.

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_t resolves 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:

nullptrpeer names no currently-known bus peer.

Close one peer’s connection by NAME, freeing its slot for reuse.

Tears down exactly the peer peer names, 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:
  • truepeer named an open connection and its teardown was initiated.

  • falsepeer names no open peer, or this kind cannot close one peer.

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, constructed peer_named or FLAT — reports that choice here, and its transport_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, and send() 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 protected peer_rx_ directly; the gate governs this interface’s own doors.) It is a query, not a knob: bus_link_t is a PUBLIC base, so a flat link’s set_peer_receiver is reachable by an explicit upcast past the null bus(), and before this gate that call silently flipped the link into peer-named delivery the bus() == nullptr contract 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.

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_child installs 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; ctx is passed back as its first argument.

  • ctx – Caller-owned context; must outlive every possible notification.

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_t that is accept() for a raw stream peer and the 101 Switching Protocols publish 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_child installs 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; ctx is passed back as its first argument.

  • ctx – Caller-owned context; must outlive every possible notification.

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; ctx is passed back as its first argument.

  • ctx – Caller-owned context; must outlive every possible delivery.

Register the peer-named inbound sink from a caller-owned callable.

Zero-erasure sugar over the {fn, ctx} form: sink is 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.

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; ctx is passed back as its first argument.

  • ctx – Caller-owned context; must outlive every possible delivery.

Register the OWNING peer-named sink from a caller-owned callable.

Zero-erasure sugar over the {fn, ctx} form: sink is 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.

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

using span_fn_t = void (*)(void *ctx, Tag..., std::span<const std::byte>)

The borrowed-span sink: the frame is valid only for the call.

using rope_fn_t = void (*)(void *ctx, Tag..., view::rope_t)

The owning sink: refcounted rope links the sink may keep or forward.

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; ctx is 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; ctx is 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_t slot): 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.

The connection vertex’s link-liveness value (RFC-0014 §4).

The 1-byte VALUE a connection vertex stores — await-able and subscribable, so a subscribe /net/<module>/<name> streams every transition (assign-then-deliver under RFC-0008 §D). Supersedes the binary up/down set_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). DORMANT keeps the old “down” 0x00 so a resting link stays the falsy default.

DIAL links move through DORMANT/DIALING/RECONNECTING/UP; LISTEN links report listen-socket reachability as LISTENING/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 with transport_kind_traits_t::self_heal_dial; everywhere else the value is still set manually (eagerly-constructed sockets report UP/LISTENING at creation; provided links report via transport_vertex_t::set_link_state).

Values:

DIAL: vertex exists; no socket (refcount 0).

DIAL: a connect attempt is in flight.

DIAL: retrying toward UP between backoff waits.

DIAL: socket connected, bidirectional.

LISTEN: listen socket bound and accepting.

LISTEN: the listen socket could not bind.

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 DORMANT and the engine dials on demand (any op auto-wakes it, bounded by connect_timeout), self-heals with backoff while 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_of at 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 for fwd_router_t::add_child BEFORE any socket exists. Ignored unless self_heal_dial is set.

The RFC-0014 §4 S5 liveness engine over one owned DIAL connection — a transport_t whose inner socket is constructed, healed, and closed by the engine itself (#492).

transport_vertex_t mints one of these instead of running the kind’s factory when the kind was registered with transport_kind_traits_t::self_heal_dial and the connection’s role is DIAL. 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 minted DORMANT, 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):

  • DORMANTDIALING: any op auto-wakes the link (send blocks for ONE connect attempt, bounded by connect_timeout, then serves or drops — the §4 stall-on-dial), and acquire kicks the same wake without blocking.

  • DIALINGUP on a successful construct; → RECONNECTING (a standing binding holds) or back to DORMANT (none does — a lone one-shot’s failed dial triggers NO background retry, §4’s transient-hold rule) on a failed one.

  • UPRECONNECTING on socket loss with refcount > 0: the self-heal retry loop — an attempt per backoff interval, FOREVER (no give-up bound and no terminal state, the §4 no-synthetic-limits ruling). Ops on a RECONNECTING link fail fast (dropped and counted), never block on a dead peer.

  • UPDORMANT on 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_t or 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

The liveness sink the engine publishes every transition through — installed once by the owner (a write of the 1-byte link_state_t VALUE to the connection vertex), before the link is wired into the router.

Public Functions

Bind the engine over factory with 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_ms of 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 config SETTINGS 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, because fwd_router_t::add_child installs the matching receiver on the ENGINE exactly once, at registration.

Stops the engine (see stop) and destroys any remaining socket.

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).

A STANDING binding takes its hold (RFC-0014 §4: a routed subscription or await that needs the peer reachable).

Non-blocking: a dormant link is kicked toward UP (the worker dials); the caller that must WAIT for UP uses await on the connection vertex (S6’s verb). While refcount > 0 the engine self-heals on loss, forever.

The standing binding releases its hold.

The LAST release closes an UP socket 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.

Stop the engine: join the worker, tear down every socket. Idempotent.

transport_vertex_t::remove_connection calls 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).

The engine’s current liveness state (the owner/test introspection door).

Emit one frame — the §4 op door. UP sends on the inner socket; DORMANT auto-wakes (blocks for ONE attempt, bounded by connect_timeout) then sends or drops; RECONNECTING fails fast. Every drop counts in drop_stats.

Scatter-gather twin of send(std::span<const std::byte>) — same gate.

The kind’s delivery capability, answered for the router at registration time (see the constructor’s inner_delivers_ropes).

Runtime liveness (#1059): true iff the engine is UP.

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 or poll(2)), after which the loop re-checks stop_.

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() = default

Constructs with no thread running and stop_ clear.

~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 body touches is initialized. body must 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 (not std::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_create returns 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 inflating CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT for 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/recvfrom loop shutdown- responsive: each blocked read wakes within 100 ms so the loop can re-check stop_ 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) on fd (#838).

The egress twin of set_rcv_timeout, and the syscall-level half of the #838 fix: without it a send/sendmsg into 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 on fd.

The poll-flavored twin of set_rcv_timeout for loops that wait before reading. Returns the raw poll(2) result — > 0 readable, 0 timeout (re-check stop_ and continue), < 0 error.

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_fd to 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.

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).

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 kMaxConsecutiveStalls streak — 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.

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() = default

Constructs with no peer connected (conn_fd_ = -1).

~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.

  • streak reaching kMaxConsecutiveStalls: 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 bytes to 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 concurrent send() 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 run on_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_, run serve_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::mutex write_m_

Serializes writes to conn_fd_ (see the write-serialization invariant).

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.

std::uint8_t tx_stall_streak_ = 0

The ONE peer’s consecutive-stall streak (guarded by write_m_) — the multi-peer servers keep one per slot instead.

Protected Static Functions

static write_result_t write_all(int fd, std::span<const std::byte> bytes, std::uint32_t bound_ms = 0)

Write bytes to fd completely, 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 in write_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_SNDTIMEO armed — 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 vec entries to fd completely 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. vec is 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_ms deadline 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.

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_peers refusal and p<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 (the msquic_endpoint_t shape: 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 from tr::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 is relaxed: 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 open value — 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 holds open until 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 len bytes just read off s ‘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_up and teardown_slot run HERE, in the tier that owns the slot table, but the notifiers they end in are bus_link_t’s protected members and this tier is no longer a bus_link_t. These two hooks are the join: inert in the base, overridden by bus_slot_server_t to fire notify_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_checked asks (#1059), so the second limb is what turns a kBusLinks = false build’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 answers ok() would hide it.

At the default binding the if constexpr is discarded and this is listen_fd_ >= 0, the predicate it always was — same instructions, verified by object-file cmp.

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. kMaxConsecutiveStalls of 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, 0 meaning kDefaultLivenessWindowMs (#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_named this 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), and bus_link_t refuses 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 override here 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 scratch with 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 peerp<slot>, this kind’s session identity.

The same string peer_name answers with, reachable without the bus_link_t facet; 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:

nullptrpeer names 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:
  • truepeer named an open connection and its socket was shut down.

  • falsepeer names 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, so 0 no 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_peers deployment 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/fd under write_m_ BEFORE close(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_iov reads its gather without consuming it (#932), so every peer writes straight from rec — 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 s as a live, named session — the arrival half of the seam whose departure half is teardown_slot’s notify_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 101 is on the wire — the same two transitions open itself 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 constant false — so the per-frame tier select, the departure seam, the arrival seam and bus() 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 IS peer_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_peers at 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.

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_t base subobject, its peer_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 identity transport_t::bus() hands out, the peer-named receiver slot, and the arrival/departure notifiers — so the four bus_link_t pure 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 unqualified server.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, else nullptr. With the facet the router tags inbound frames per peer (each peer gets its own return-route identity and a dst segment routes back to that one session); without it the link keeps point-to-point hop naming — inbound frames carry the registered child NAME and send() 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, so 0 no 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).

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 constant false.

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, so 0 no 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).

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_frame key (effective_max_frame), the same key tcp_transport_t, the ws transports, quic and webtransport accept. 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 furnish max_frame + 1 bytes — 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_port and target peer_host : peer_port.

bind_port 0 = ephemeral (see local_port). peer_host is an IPv4 dotted-quad (e.g. “127.0.0.1”). Listener mode: with an unresolved peer (peer_host empty or peer_port 0) the transport LEARNS its peer from each inbound datagram’s source address — the single-peer UDP-server shape that lets a config-created listener connection (#83) reply to a dialing client whose ephemeral port is unknowable in advance; until the first datagram arrives, send is a no-op.

backend is 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, sized min(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_frame receive 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 — provided backend can furnish max_frame + 1 bytes, 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_port of 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’s max_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_frame above it is inert rather than loosening.

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 exactly len bytes straight into a refcounted segment drawn from the injected mem_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_recv is 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 true the 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 every send (and the write-mutex hold it takes), so no peer can freeze the sending thread; kMaxConsecutiveStalls records in a row that hit it — or one that half-reached the wire — close the connection. The same contract CAN’s peer_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 frame as 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_recv DIAL 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 what transport_vertex_t::make_connection does. A defer_recv link 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).

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. kMaxConsecutiveStalls in 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 (0kDefaultLivenessWindowMs) (#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_frame tightens it. A larger prefix is malformed — counted via malformed_rx and the connection is closed (a desynced stream cannot be trusted again).

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 (named p<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 when peer_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). 0 no 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 from slot_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 stalls kMaxConsecutiveStalls records in a row, or once mid-record, is closed. It also SIZES the peer cap: see max_peers.

~transport_tcp_server() override

Stop the poll thread and close all sockets.

void send(std::span<const std::byte> frame) override

Send frame as 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.

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: and run 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 true the 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 which transport_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; kMaxConsecutiveStalls stalled 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 because mem::block_array_t binds 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 applies egress_src to 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_cap so 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 its 101 — those are frame bytes, bounded by max_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 frame as 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_recv construction 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 101 are 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). A defer_recv client 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.

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_recv client 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. kMaxConsecutiveStalls in 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 (0kDefaultLivenessWindowMs) (#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).

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_peers when 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). 0 no 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 from slot_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 stalls kMaxConsecutiveStalls records in a row, or once mid-record, is closed. It also SIZES the peer cap: see max_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 frame as 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 iov spans 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_handshake request into the honored budget — TIGHTEN-ONLY against kMaxHandshakeBytes, exactly as length_prefix_framer::configured_cap is against kDefaultMaxFrame.

0 (unset) keeps the default; a nonzero value yields min(max_handshake, kMaxHandshakeBytes). The value arrives through a config-writable key (ws-private max_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_frame tightens 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_handshake tightens 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).

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.

struct frame_t

One decoded RFC 6455 data/control frame (payload already unmasked).

Public Members

opcode_t op

Frame opcode.

bool fin

FIN bit (true = final fragment).

std::vector<std::byte> payload

Unmasked application payload.

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.py holds against the TypeScript decodeFrame, 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 of buf answers “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 buf does 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 with FIN clear, is PROTOCOL_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 a std::vector) a peer-triggered abort() on the -fno-exceptions profile.

The DATA path is bounded the same way (#872): a frame whose declared length exceeds max_payload is PROTOCOL_ERROR off the HEADER too. The two rules are separate — §5.5 is a fixed RFC constant about CONTROL frames, max_payload is 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 is PROTOCOL_ERROR off the first header byte, ahead of both length rules. TEXT and PONG are 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_MORE while buf is short of a whole frame, PROTOCOL_ERROR on an RFC 6455 violation or an over-cap declared length (the caller must fail the connection, RFC 6455 §7.1.7), else OK with 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_payload argument 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_t can hold, so len > kNoPayloadCap is 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 in transport_ws.cpp ride the payload spans behind it with no copy. The MASKED client side does NOT share it: detail::put_client_frame carries 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 (kMaxServerFrameHeader bytes 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 (see detail::put_client_frame and 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:

0payload exceeds 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:

0payload exceeds 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 supplies mask_key per 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 a bad_alloc abort() 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 out buffer across calls and the steady state allocates nothing. On 0 the 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_reserve cannot express a refusal at all. std::vector::reserve reports exhaustion by throwing, and under -fno-exceptions that is a bare abort() inside reserve that no wrapper can intercept — so there try_reserve still 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 ONE block_source_t::try_alloc that answers nullptr, 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-len body bytes into ONE refcounted segment drawn from the injected mem_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: and open 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_port with the PEM certificate at cert_file / private key at key_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 frame as 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).

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 a StreamSend msquic 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_frame tightens it. A larger prefix is malformed: counted via malformed_rx and the connection is shut down (a desynced stream cannot be trusted again).

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).

Public Members

std::string ca_file

PEM CA bundle path to verify the server certificate against (empty = the system trust store).

bool insecure_no_verify = false

DEV ONLY: skip server certificate validation entirely (self-signed dev certs — tools/gen-dev-cert.sh). Never enable in deployment.

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-webtransport package) or the DIAL mode of this class connects with new 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 injected mem_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_rx and armed with start_receiving once they are.

Parameters:
  • peer_host – Server hostname or dotted-quad IPv4 (the CONNECT :authority host 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-private path config 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_t builds 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) on bind_port with the PEM certificate at cert_file / key at key_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: serverCertificateHashes needs 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 frame as 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_rx DIAL 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_connection calls 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).

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 :path of 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 a StreamSend msquic 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_handshake request into the honored budget — TIGHTEN-ONLY against kMaxHandshakeBytes, exactly as length_prefix_framer::configured_cap is against kDefaultMaxFrame.

0 (unset) keeps the default; a nonzero value yields min(max_handshake, kMaxHandshakeBytes). The value arrives through a config-writable key (webtransport-private max_handshake), and a config-writable key must never RAISE a pre-auth bound — only narrow it.

Note

The same shape transport_ws_server::handshake_cap carries for the WS plane (#1407), deliberately spelled rather than shared: transport_ws.hpp sits behind LIBTRACER_TRANSPORT_WS and 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_frame tightens 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_handshake tightens 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).

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.

Public Members

std::string ca_file

PEM CA bundle path to verify the server certificate against (empty = the system trust store).

bool insecure_no_verify = false

DEV ONLY: skip server certificate validation entirely (self-signed dev certs — tools/gen-dev-cert.sh). Never enable in deployment.

transport_vertex_t::transport_factory_t tr::net::quic_transport_factory(mem::mem_backend_t *rx_backend = &mem::heap_backend())

The ready-to-register quic transport factory — how this module plugs into the transport catalog (the register_transport_type extension seam; the core has no quic builtin).

Register at setup: net.register_transport_type("quic", quic_transport_factory()). A subsequent :children[] SPEC whose config carries kind = quic then constructs a quic_transport_t from the parsed settings — DIAL: addr + port plus the OPTIONAL trust keys below; LISTEN: port plus the REQUIRED cert/key PEM-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 shared conn_settings_t, which stays lean with only the universal keys (the ADR-0043 §5 leanness ruling). Missing fields fail creation with TYPE_MISMATCH; a socket that failed to come up fails with TRANSPORT_DOWN — the TRANSIENT status, because the address resolved and it was the link that did not come up (#929). keepalive is 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) — 1 skips 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 webtransport transport 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 carries kind = webtransport then constructs a webtransport_transport_t — DIAL: addr + port plus the OPTIONAL path and trust keys below; LISTEN: port plus the REQUIRED cert/key PEM-path config keys. Both roles additionally read the OPTIONAL max_handshake budget (#1408). All six are kind-PRIVATE config keys parsed by this factory from the raw SPEC config TLV — they never appear on the shared conn_settings_t (the ADR-0043 §5 leanness ruling). Missing fields fail with TYPE_MISMATCH; a session that failed to come up fails with TRANSPORT_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-200 answer to the extended CONNECT as a failed session, so a wrong resource surfaces as TRANSPORT_DOWN from creation — the same status a rejected certificate gives. The key is kind-private, so it does not collide with the can kind’s unrelated path key (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 the quic kind move it: ca (NAME, a PEM CA-bundle path) verifies against that bundle instead, and insecure (VALUE u8, default 0) set to 1 skips 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).

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.

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 udp transport factory on vertex (needs transport_udp). egress_src is the ADR-0079 egress store — see with_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 tcp transport factory on vertex (needs transport_tcp). egress_src is the ADR-0079 egress store — see with_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 ws transport factory on vertex (needs transport_ws). egress_src is the ADR-0079 egress store — see with_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.