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 / tcp_server_transport_t (reliable TCP stream, 4-byte u32-LE length-prefix framing — the prefix is transport framing, not part of the TLV), ws_client_transport_t / ws_server_transport_t (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:transport_t::delivers_ropes) 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:fwd_router_t::add_child) 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:link.set_rope_receiver(, fwd_router.cpp:link.set_receiver(, and fwd_router.cpp:bus->set_peer_rope_receiver(, fwd_router.cpp:bus->set_peer_receiver( for the peer-named bus equivalent).

Every socket transport in the tree declares the owning tier: UDP (transport_udp.hpp:udp_transport_t::delivers_ropes), TCP client and server (transport_tcp.hpp:tcp_transport_t::delivers_ropes, transport_tcp.hpp:tcp_server_transport_t::delivers_ropes), WebSocket server and client (transport_ws.hpp:ws_server_transport_t::delivers_ropes, transport_ws.hpp:ws_client_transport_t::delivers_ropes), CAN (transport_can.hpp:can_transport_t::delivers_ropes), QUIC (transport_quic.hpp:quic_transport_t::delivers_ropes) and WebTransport (transport_webtransport.hpp:webtransport_transport_t::delivers_ropes). The borrowed-span path is the base-class default and the tier an out-of-tree transport gets for free.

A scattered frame crosses the owning seam as the rope it already is; trailing transport padding is trimmed by shortening the last link, never by flattening.

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:default_config_t::kBusLinks) is the knob, and since v0.17.0 its default is false — the lean choice (#1670). A node that needs the tier — a peer_named listener, CAN, the ESP-IDF WS server — opts in with an ADR-0068 override fragment, not a -D (ESP-IDF: CONFIG_LIBTRACER_BUS_LINKS=y; PlatformIO: custom_libtracer_bus_links = yes):

// libtracer/config_override.hpp
namespace tr::graph {
struct bus_node_config_t : default_config_t {
    static constexpr bool kBusLinks = true;
};
using config_t = bus_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:bus_of), 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 tcp_server_transport_t shrinks 208 B → 168 B at rest, −40 B per listener, with a further −568 B of image .text and ±0 B of .bss. With the module carried 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{name, config{kind=tcp|ws, peer_named=1}} to a LISTEN module’s conn

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)

a static_assert in httpd_ws_link.cpp — it is peer-named by construction; the component’s CONFIG_LIBTRACER_WS_SERVER selects CONFIG_LIBTRACER_BUS_LINKS, so the component never reaches it

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:option(LIBTRACER_WITH_QUIC "Configure the, 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.

Their TLS material is app-owned: the certificate, key and CA bundle come from the table of tr::net::tls_profile_t the application hands the factory at registration, never from a creation SPEC. A SPEC can at most name one of those profiles (the kind-private tls key) and set the DIAL-side dev opt-out insecure. A SPEC-created dialer verifies its peer’s certificate by default; the key-by-key reference is connection config.

Interface

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; ws_client_transport_t 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 can_transport_t::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:transport_t::send(std::span<const std::span<const std::byte>> iov)). 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_transports’ egress_src argument, fed by transport_vertex_t’s), defaulting to the process net sub-pool (#1777). 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 tcp_server_transport_t and ws_server_transport_t 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.

  • tx_handoff_t is enqueue-then-write for a link that must put one record on the wire at a time. The link’s lock guards a small bounded queue and a writer-in-flight flag, never the write itself. The first sender becomes the writer and writes its own record; a sender that finds a write in flight copies its record into a queue slot and returns; a sender that finds every slot taken drops the record and the link counts it. The writer drains the queue in admission order. It still waits on I/O, bounded per record by the link’s write budget, and a stream link gives the peer up after three stalled writes in a row; there is no transmit task, so the queue does not make the write asynchronous.

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::can_transport_t, 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::udp_transport_t, tr::net::webtransport_transport_t, tr::net::ws_client_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.

  • **void is deliberate, and stays.** A returned status here would be a promise the plane cannot keep: on every real link the frame is queued and written later, on another task, so “sent” at this call site can only ever mean “accepted”, which is what a void already says. Callers MUST NOT read a successful return as delivery.

  • A link that drops a frame MUST count it in drop_stats — dropped_tx for a frame the caller believed sent, dropped_rx / malformed_rx on the ingress side. That counter is the ONLY feedback this plane offers, which is exactly why it is mandatory rather than a courtesy: an uncounted drop is invisible to a deployment, and core/STYLE.md §Introspection forbids the silent loss it hides.

  • The counter is how a monitor closes the loop. The tally is wire-readable as read <any-vertex>:stats.link.<child> (RFC-0010 Amendment 2), so a supervisor polls it and reacts, in place of the per-frame refusal v1 has no room for.

The transport plane is BEST-EFFORT by contract, and a drop MUST be counted.

This is the one place in libtracer where the refuse-by-value law (docs/reference/22-backpressure-and-sizing.md §1, the third reading rule) does not apply, and the reason is not an oversight: there is no wire carrier for backpressure in v1 (the per-edge credit window is parked as the v2 escalation, RFC-0025 §4.6.1 clause 7). A link that could refuse by value would have nobody to refuse to — the caller is a forwarding hop with a frame it cannot un-receive, and the peer cannot be told to slow down. So the contract is:

Note

Re-opening this is scoped to M5

(a real socket transport), where a link that owns its own socket buffer has something to push back WITH. Until then, “counted

shed” IS the transport contract.

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.

**The same best-effort contract as send(std::span<const std::byte>)**: void is deliberate, and a gather this overload cannot complete is DROPPED and COUNTED in drop_stats (dropped_tx) — never truncated, and never silently lost. The default body below is where that rule is enforced for every transport that does not override it.

Parameters:

iov – The spans to emit, in order, as a single frame.

inline virtual void send(std::span<const std::span<const std::byte>> head, const graph::value_t &value)

Retained send (RFC-0028 §6.9, §8.2): emit head followed by value's bytes as ONE frame, where a link that writes LATER keeps the value by reference instead of gathering a copy of it.

The egress half of the lean value path. A remote delivery is a small head (the FWD header, the op, the return route) in front of a published value; the head is the caller’s and short-lived, the value is a refcounted block. A link that writes in-call (writev under its write lock) needs neither kept, and the DEFAULT below is exactly that: it lowers to send(std::span<const std::span<const std::byte>>) over head ++ value.links(), through an iov table on the stack. A link that QUEUES the frame and writes it from another thread overrides this: it copies the head (header-sized) into its queue slot and RETAINS the value (value_ref_t::keep — one refcount for a published block), so the payload is written from the block it was published in and never gathered. That is the difference between a queued fan-out to K peers costing K payload copies and costing K refcounts.

Same best-effort contract as send(std::span<const std::byte>): void, and a frame the link cannot carry (the iov table’s overflow refused, the value could not be kept, the queue is full) is dropped, never truncated — and counted in drop_stats by every link that counts, which is every link that overrides this.

Warning

An overriding link owns the one hazard the queued form adds (RFC-0028 §9 item 6): the frame’s bytes now leave in several writes from a queue shared with every other frame on the socket, so a partial write MUST end the frame’s stream (close the peer), never let another frame’s bytes follow it.

Parameters:
  • head – The frame’s leading bytes, in order. Borrowed for the call only.

  • value – The payload that completes the frame. Borrowed for the call; a link that writes later keeps it through value_ref_t::keep.

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 net sub-pool (#1777): unbounded, as a link nothing was wired into always was.

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. ws_client_transport_t::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::can_transport_t

Public Types

Visitor invoked once per currently-audible peer name — a synchronous, non-owning callable reference (ADR-0083 Q10): passing a lambda costs nothing.

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 can_transport_t 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 — can_transport_t names a peer n<node-id> for its own bus node id, so the name, the table key and the endpoint are one identity that no other peer can inherit.

  • POSITIONAL names are exposed — slot_server_t names a peer p<slot> for the slot index it landed in, and slots are recycled in place.

Return values:

nullptr – peer 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:
  • true – peer named an open connection and its teardown was initiated.

  • false – peer 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, and the byte encoding is normative: the conn/liveness-enum conformance vector plus its bound host test pinned it, and RFC-0014 Amendment 4 (S7) promoted the clause out of proposed pending. 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 — which since #1548 is every built-in point-to-point kind (udp, tcp, ws), so a stock DIAL connection is engine-managed. Everywhere else the value is still set manually (eagerly-constructed sockets — every LISTEN link, every bus kind — 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. The built-in point-to-point kinds do NOT take the defaults since #1548 — see kBuiltinPointToPointTraits in builtin_transports.hpp for the row they share and why self_heal_dial there is conditioned on the kSelfHealLinks build knob.

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, and each states why in its own registration comment). 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):

  • DORMANT → DIALING: 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.

  • DIALING → UP 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.

  • UP → RECONNECTING 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.

  • UP → DORMANT 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 as contiguous bytes (empty = the SPEC carried none): the kind-private keys the factory re-parses on each dial. BORROWED: the owner keeps them, and the text views in settings, alive until this engine is destroyed — transport_vertex_t holds them as the connection’s config copy and drops that copy only after the engine (#1780).

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

  • src – The store each dial’s socket, and the engine’s record of it, is drawn from — the factory’s third argument (receiver pays).

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.

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 Types

using thread_body_t = inline_fn_t<void()>

The receive thread’s body: a stored callable with inline storage (ADR-0083 Q10) — every transport’s body captures this and at most a descriptor.

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(thread_body_t 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. Only the sender holding the enqueue-then-write writer role (handoff_send) ever takes it to write, so no publisher queues on it behind another publisher’s write (RFC 0028 §4.7).

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::ws_client_transport_t

Protected Functions

stream_endpoint_t() = default

Constructs with no peer connected (conn_fd_ = -1); the queue’s slots and the gather overflow draw from the process net sub-pool.

inline explicit stream_endpoint_t(mem::block_source_t &tx_src)

Constructs with no peer connected, the queue’s slots drawing from tx_src.

For a link whose queued records are its own copies rather than retained references (the WebSocket client, whose frames are masked in place, #1661): the slots are then part of the link’s egress store, which the application names.

Parameters:

tx_src – Where the enqueue-then-write queue’s slot storage comes from.

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

template<class Own, class Fill>
inline std::uint64_t handoff_send(Own &&own, Fill &&fill, const graph::value_t *retain = nullptr)

Put one record on the wire through the enqueue-then-write queue (RFC 0028 §4.7).

The single-peer senders’ one door. A publisher that finds no write in flight becomes the writer: it takes write_m_, reads conn_fd_ inside it, and runs own on the live fd, then drains whatever other publishers queued meanwhile, one record per write_m_ hold. A publisher that finds a write in flight copies its record into a slot through fill and returns at once, and one that finds every slot taken drops it. So no publisher waits on another publisher’s write to a stalled peer (#1619); the writer’s own wait stays bounded per record by the liveness window (#838).

write_m_ keeps its two other jobs: it still orders a write against teardown_peer, and it still serializes records on the stream, because only the writer ever takes it to write.

A RETAINED record (RFC-0028 §6.9) passes retain: fill then copies only the record’s head (the link’s framing plus the caller’s head spans), the queue keeps one reference to retain, and the writer that drains it puts head and value on the wire as ONE gathered record (write_record) — the payload is never copied into a slot. The record still leaves under one write_m_ hold and one bound, so it cannot interleave with another record, and a partial write condemns the peer exactly as a copied record’s does.

Template Parameters:
  • Own – Callable bool(int fd): write the caller’s own record to fd and return true when it was shed (the caller’s dropped_tx_ then counts it).

  • Fill – Callable std::size_t(mem::block_array_t<std::byte>&): copy the record (or, with retain, its head) into a queue slot, returning its byte count, or 0 to refuse.

Parameters:

retain – The value a retained record’s bytes end with, or null for a copied one.

Returns:

Records shed by this call: a refused enqueue, a record written into no peer, or one the bound shed — each one the caller’s dropped_tx_ counts.

write_result_t write_record(int fd, const tx_handoff_t::record_t &rec, std::uint32_t bound_ms)

Write one queued record to fd as ONE record: its copied bytes, then — for a retained record — its value’s links, gathered (RFC-0028 §6.9).

A copied record is write_all over its bytes. A retained one is write_all_iov over [bytes, link0, link1, ...], through an inline table (the overflow comes from the heap and is refused rather than thrown); a refused table writes NOTHING and is reported as write_result_t::failed with no byte on the wire, which the caller’s stall policy counts as a shed record and never as a desync. The caller holds write_m_.

Parameters:
  • fd – The destination fd.

  • rec – The record, as tx_handoff_t::next handed it out.

  • bound_ms – The record’s send bound (see write_all).

Returns:

How the write ended.

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, function_ref_t<bool(int)> on_accept, function_ref_t<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::size_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. Word-wide, not 64-bit (core/STYLE.md §Introspection clause 5, #1697): a 64-bit atomic is a libatomic call on every rv32; a 32-bit target wraps after 2^32.

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.

mem::block_source_t *io_src_ = &mem::net_source()

This link’s egress store: the queue’s records, a retained record’s gather overflow (write_record), and a derived class’s own link buffers (#1780).

tx_handoff_t tx_ = {kTxQueueDepth, *io_src_}

The enqueue-then-write queue handoff_send drives (RFC 0028 §4.7).

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.

Protected Static Attributes

static constexpr std::size_t kTxQueueDepth = 8

Records a single-peer sender may queue behind the write in flight (handoff_send). Past it a publisher drops and counts rather than waits.

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 (tcp_server_transport_t, ws_server_transport_t) — 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 mem::poly_ptr_t<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 peer — p<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:

nullptr – peer 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:
  • true – peer named an open connection and its socket was shut down.

  • false – peer 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, mem::block_source_t &src = mem::net_source(), mem::block_source_t &state_src = mem::net_source())

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

  • src – The store the TX hand-off ring and the egress gathers are drawn from (the link’s memory.io). Must outlive the server.

  • state_src – The store the slot table, every session and each poll pass’s tables are drawn from (the link’s memory.state, #1780). Must outlive the server.

  • 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_.

mem::block_array_t<mem::poly_ptr_t<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.

Protected Static Attributes

static constexpr std::size_t kPeerNameChars = 24

Bytes a session’s p<slot> name holds, terminator included: p and the widest slot index.

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.

inline std::string_view name_view() const noexcept

name as a view; empty while the slot is unnamed.

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.

char name[kPeerNameChars] = {}

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.

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, mem::block_source_t &src = mem::net_source(), mem::block_source_t &state_src = mem::net_source())

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

  • src – The store the TX hand-off ring and the egress gathers are drawn from (the link’s memory.io). Must outlive the server.

  • state_src – The store the slot table, every session and each poll pass’s tables are drawn from (the link’s memory.state, #1780). Must outlive the server.

  • 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, mem::block_source_t &src = mem::net_source(), mem::block_source_t &state_src = mem::net_source())

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

  • src – The store the TX hand-off ring and the egress gathers are drawn from (the link’s memory.io). Must outlive the server.

  • state_src – The store the slot table, every session and each poll pass’s tables are drawn from (the link’s memory.state, #1780). Must outlive the server.

  • 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

The memory a link draws from, as the one nested member every per-kind link config carries (#1593, RFC-0028 §8.2).

Shared, and deliberately tiny: the transport-vertex lean rule keeps kind-private knobs in the kind’s own config struct, and this holds only the two memory planes every link kind has. A deployer that bounds a node points both at its slab; since RFC-0028 slice 10 a mem_backend_t IS a block_source_t, so one synchronized pool can serve both.

Each must outlive the link, and each must be thread-safe on a target where the link’s receive thread and a sender run concurrently.

Public Functions

io, or the process net sub-pool when a config left it null.

state, or the process net sub-pool when a config left it null.

Public Members

The RX memory seam (ADR-0042 §2): each inbound frame is read into a segment drawn from it. Exhaustion is backpressure — the frame is dropped and counted, never an OOM. Default: the process net sub-pool (#1777). A kind whose zero-copy delivery is opt-in (the ESP-IDF httpd_ws_link_t

) defaults it to null, meaning “borrowed

delivery”.

The link’s EGRESS store (ADR-0079, #873): the per-frame scratch a kind draws while one outbound frame is in flight — ws_client_transport_t’s masked-frame copy and the base class’s gather temporary. A kind with no such draw ignores it. Default: the process net sub-pool (#1777).

The link’s connection-STATE store (#1780): what a kind holds for as long as a connection or the link lives — the link’s own endpoint object, a server’s slot table and per-peer sessions, handshake and receive-accumulation buffers, a QUIC stream-context table. Peer-provoked, so the receiving link pays: a refusal at runtime sheds that one peer or frame, counted; only a refusal while the link is being built leaves it inert (ok() false). Default: the process net sub-pool. Kept apart from io so a bound on in-flight egress never caps how many peers can connect, and the reverse.

struct udp_config_t

udp_transport_t’s knobs as one aggregate (#1593), after the local port and peer.

Public Members

link_memory_t memory = {}

The link’s memory (link_memory_t). rx: when a rope receiver is installed, each datagram is recvfrom’d straight into a fresh segment drawn from it, sized min(kMaxDatagram, rx->max_segment_size()) — so the backend BOUNDS the datagram a node accepts. Exhaustion is backpressure — the datagram is dropped and dropped_rx ticks, never an OOM.

std::size_t max_frame = 0

The universal :settings max_frame receive cap, bytes — the largest datagram accepted (0 → udp_transport_t::kMaxDatagram). A longer datagram is refused (malformed_rx ticks), provided the backend can furnish max_frame + 1 bytes (#1074). It also sizes the RX segment, so a tight cap is a RAM lever.

std::size_t recv_stack = 0

Recv-thread stack size in bytes, 0 = platform default.

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, std::string_view peer_host, std::uint16_t peer_port, const udp_config_t &config = {})

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 LISTEN-module connection (#83) reply to a dialing client whose ephemeral port is unknowable in advance; until the first datagram arrives, send is a no-op.

Parameters:

config – The link’s knobs (udp_config_t): memory (the RX seam that bounds the datagram), receive cap, recv-thread stack.

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

Emit one frame (a complete TLV’s bytes) onto the wire.

  • **void is deliberate, and stays.** A returned status here would be a promise the plane cannot keep: on every real link the frame is queued and written later, on another task, so “sent” at this call site can only ever mean “accepted”, which is what a void already says. Callers MUST NOT read a successful return as delivery.

  • A link that drops a frame MUST count it in drop_stats — dropped_tx for a frame the caller believed sent, dropped_rx / malformed_rx on the ingress side. That counter is the ONLY feedback this plane offers, which is exactly why it is mandatory rather than a courtesy: an uncounted drop is invisible to a deployment, and core/STYLE.md §Introspection forbids the silent loss it hides.

  • The counter is how a monitor closes the loop. The tally is wire-readable as read <any-vertex>:stats.link.<child> (RFC-0010 Amendment 2), so a supervisor polls it and reacts, in place of the per-frame refusal v1 has no room for.

The transport plane is BEST-EFFORT by contract, and a drop MUST be counted.

This is the one place in libtracer where the refuse-by-value law (docs/reference/22-backpressure-and-sizing.md §1, the third reading rule) does not apply, and the reason is not an oversight: there is no wire carrier for backpressure in v1 (the per-edge credit window is parked as the v2 escalation, RFC-0025 §4.6.1 clause 7). A link that could refuse by value would have nobody to refuse to — the caller is a forwarding hop with a frame it cannot un-receive, and the peer cannot be told to slow down. So the contract is:

Note

Re-opening this is scoped to M5

(a real socket transport), where a link that owns its own socket buffer has something to push back WITH. Until then, “counted

shed” IS the transport contract.

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.

**The same best-effort contract as send(std::span<const std::byte>)**: void is deliberate, and a gather this overload cannot complete is DROPPED and COUNTED in drop_stats (dropped_tx) — never truncated, and never silently lost. The default body below is where that rule is enforced for every transport that does not override it.

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.

struct tcp_config_t

tcp_transport_t’s knobs as one aggregate (#1593): what both its constructors take after the peer address, in place of positional, defaulted parameters.

Every member defaults to the historical default, so tcp_config_t{} is the unconfigured link and a caller names only what it sets: {.memory = {.rx = &pool}, .defer_recv = true}.

Public Members

link_memory_t memory = {}

The link’s memory (link_memory_t). rx: each inbound frame is read into a fresh exactly-len-byte segment from it (ADR-0042 §2); exhaustion is backpressure — the frame is drained off the stream, dropped, and dropped_rx() ticks; never an OOM.

std::size_t max_frame = 0

Receive cap, bytes (0 → tcp_transport_t::kMaxFrame). TIGHTEN-ONLY: a value above kMaxFrame is clamped to it (length_prefix_framer_t::configured_cap, #1035); a frame inside the cap the backend cannot hold is shed as backpressure, not treated as malformed (#932).

std::size_t recv_stack = 0

Recv-thread stack size in bytes, 0 = platform default (posix_endpoint_t::start). Non-zero right-sizes the recv thread on an MCU.

bool defer_recv = false

DIAL only — two-phase bring-up (#1045): the connect still runs in the constructor (so ok() answers for it on return) but the recv thread is NOT spawned until tcp_transport_t::start_receiving, so a peer that pushes the instant the connect completes cannot land a frame before the receiver is installed. The LISTEN constructor ignores it.

std::uint32_t liveness_window_ms = 0

The app-provided PEER LIVENESS WINDOW in ms, 0 = kDefaultLivenessWindowMs (#838): how long the peer may fail to take bytes before it is treated as broken. It bounds every send (and the write-mutex hold it takes); kMaxConsecutiveStalls records in a row that hit it — or one that half-reached the wire — close the connection.

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(std::string_view peer_host, std::uint16_t peer_port, const tcp_config_t &config = {})

DIAL mode: connect to peer_host: (synchronous).

The TCP connect happens in the constructor (the ws_client_transport_t 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).

  • config – The link’s knobs (tcp_config_t): memory, receive cap, recv-thread stack, deferred receive, liveness window.

explicit tcp_transport_t(std::uint16_t bind_port, const tcp_config_t &config = {})

LISTEN mode: bind+listen on bind_port, accept ONE inbound peer.

The same one-peer model as ws_server_transport_t: 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).

  • config – The link’s knobs (tcp_config_t); defer_recv is DIAL-only and ignored here.

~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 send(std::span<const std::span<const std::byte>> head, const graph::value_t &value) override

Retained send (RFC-0028 §6.9): one length-prefixed record of head then value — written in-call when no write is in flight, and otherwise QUEUED as the prefix and head bytes plus one reference to value, never as a copy of it.

The queued form is the one this override exists for: the enqueue-then-write queue used to copy the whole record into its slot, so a large value published while another write was in flight cost a payload copy. The writer that drains the record gathers the head and the value’s links into one sendmsg record under the same write lock and bound as any other record, so the two-frame race on one socket (RFC-0028 §9 item 6) stays whole: a record is written entirely, or a partial write condemns the peer.

Parameters:
  • head – The record’s leading spans (borrowed for the call).

  • value – The payload (kept by reference only if the record is queued).

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 (0 ⇒ kDefaultLivenessWindowMs) (#838).

inline virtual transport_drop_stats_t drop_stats() const noexcept override

The interface-level snapshot (#932) — the concrete accessors above, as the one shape a generic transport_t* holder reads.

Public Static Attributes

static constexpr std::size_t kMaxFrame = length_prefix_framer_t::kDefaultMaxFrame

The largest frame the length prefix may announce — the shared length_prefix_framer_t::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).

struct tcp_server_config_t

tcp_server_transport_t’s knobs as one aggregate (#1593), after the bind port.

Public Members

link_memory_t memory = {}

The link’s memory (link_memory_t); rx is the per-connection receive seam — see tcp_config_t::memory.

std::size_t max_frame = 0

Per-connection receive cap (0 → tcp_transport_t::kMaxFrame); tighten-only — see tcp_config_t::max_frame.

std::size_t max_peers = 0

Concurrent-peer admission cap (RFC-0006) — a connection beyond it is accepted and immediately closed. 0 takes the liveness window’s own ceiling (window / kBoundedWaitMs, #1295), and a larger request is clamped to it, because the cap is the denominator every send bound divides by. Read the enforced value back from slot_server_t::max_peers.

bool peer_named = false

Expose the bus_link_t facet (see transport_t::bus) — the board↔board wiring choice, same contract as ws_server_transport_t’s.

std::size_t recv_stack = 0

Poll-thread stack size in bytes, 0 = platform default. One thread serves every peer, so this is the whole server’s recv-stack knob.

std::uint32_t liveness_window_ms = 0

The 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); a session that stalls kMaxConsecutiveStalls records in a row, or once mid-record, is closed. It also SIZES the peer cap.

class tcp_server_transport_t : 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 ws_server_transport_t (#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_t 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 tcp_server_transport_t(std::uint16_t bind_port, const tcp_server_config_t &config = {})

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

  • config – The server’s knobs (tcp_server_config_t): memory, receive cap, peer cap, bus facet, poll-thread stack, liveness window.

~tcp_server_transport_t() 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

struct ws_client_config_t

ws_client_transport_t’s knobs as one aggregate (#1593), after the peer address.

Public Members

link_memory_t memory = {}

The link’s memory (link_memory_t). rx: the receive seam, as the server’s; a single-frame message’s block carries the RFC-0028 §6.9 ingress-loan reserve. io: the ADR-0079 EGRESS store (#873) the masked-frame buffer, the enqueue-then-write queue’s slots (#1661) AND the base class’s gather temporary draw from — one egress store per link; null means the process heap. Both bound once, at construction.

std::size_t max_frame = 0

Receive cap (0 → ws_server_transport_t::kMaxFrame); tighten-only — see ws_server_config_t::max_frame.

std::size_t recv_stack = 0

Recv-thread stack size in bytes, 0 = platform default.

bool defer_recv = false

Two-phase bring-up (#1025): the handshake still runs in the constructor (so ok() answers on return) but the recv thread is NOT spawned until start_receiving, so a server that pushes the instant the handshake completes cannot land a message before the receiver is installed.

std::uint32_t liveness_window_ms = 0

The PEER LIVENESS WINDOW in ms, 0 = kDefaultLivenessWindowMs (#838): it bounds every send, and kMaxConsecutiveStalls stalled records in a row close the connection.

std::size_t max_handshake = 0

The DIAL half of the pre-auth handshake budget (#934), resolved through ws_server_transport_t::handshake_cap (0 → the default; tighten-only). It bounds the RESPONSE header block the dialled server may make this node accumulate.

class ws_client_transport_t : 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 ws_server_transport_t: 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

ws_client_transport_t(std::string_view host, std::uint16_t port, const ws_client_config_t &config = {})

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

  • config – The link’s knobs (ws_client_config_t): memory (receive seam and egress store), receive cap, recv-thread stack, deferred receive, liveness window, handshake budget.

~ws_client_transport_t() 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 (0 ⇒ kDefaultLivenessWindowMs) (#838).

inline virtual transport_drop_stats_t drop_stats() const noexcept override

The interface-level snapshot (#932) — what a generic transport_t* reads.

inline std::size_t effective_max_frame() const noexcept

The cap actually honored: min(max_frame, backend.max_segment_size()).

inline std::size_t effective_max_handshake() const noexcept

The pre-auth handshake budget actually honored — the server-side accessor’s twin, same name, same tighten-only resolution (#934).

struct ws_server_config_t

ws_server_transport_t’s knobs as one aggregate (#1593), after the bind port.

Every member defaults to the historical default, so ws_server_config_t{} is the unconfigured server and a caller names only what it sets.

Public Members

link_memory_t memory = {}

The link’s memory (link_memory_t). rx: every inbound message fragment is copied into a fresh segment drawn from it (ADR-0042 §2); exhaustion is backpressure — the message is shed and dropped_rx() ticks; never an OOM.

std::size_t max_frame = 0

Per-connection receive cap (0 → ws_server_transport_t::kMaxFrame). TIGHTEN-ONLY (length_prefix_framer_t::configured_cap, #1035), and bounded by the backend’s real capacity. Checked against the DECLARED length in the WS frame header, so an oversize announcement is refused before one body byte is buffered.

std::size_t max_peers = 0

Concurrent-peer admission cap (RFC-0006) — a connection beyond it is accepted and immediately closed. 0 takes the liveness window’s own ceiling (window / kBoundedWaitMs, #1295), and a larger request is clamped to it.

bool peer_named = false

Expose the bus_link_t facet (see transport_t::bus): the browser-tabs server sets it so each tab gets its own return route.

std::size_t recv_stack = 0

Poll-thread stack size in bytes, 0 = platform default. One thread multiplexes the listener and every peer.

std::uint32_t liveness_window_ms = 0

The PEER LIVENESS WINDOW in ms, 0 = kDefaultLivenessWindowMs (#838). One fan-out round is bounded by it and a DIRECTED send by window ÷ max_peers (#1295), so a tab that stops reading cannot freeze the sending thread; a session that stalls kMaxConsecutiveStalls records in a row, or once mid-record, is closed. It also SIZES the peer cap.

std::size_t max_handshake = 0

PRE-AUTH request-size budget for the opening handshake in bytes (0 → ws_server_transport_t::kMaxHandshakeBytes). TIGHTEN-ONLY (handshake_cap): enforced BEFORE the append, so the byte that would exceed it is never copied; over budget ⇒ malformed_rx ticks and the link is closed (#934).

class ws_server_transport_t : 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 ws_client_transport_t 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 tcp_server_transport_t since #871; what this class adds is the RFC 6455 packaging — the opening handshake and the frame codec.

Public Functions

explicit ws_server_transport_t(std::uint16_t bind_port, const ws_server_config_t &config = {})

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

  • config – The server’s knobs (ws_server_config_t): memory, receive cap, peer cap, bus facet, poll-thread stack, liveness window, handshake budget.

~ws_server_transport_t() 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::try_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_t::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_t::kDefaultMaxFrame

The largest MESSAGE a peer may announce — the shared length_prefix_framer_t::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).

The payload is a VIEW into the buffer the frame was decoded from (#1780): the decoder unmasks in place and copies nothing, so the view is valid until that buffer is changed.

Public Members

opcode_t op

Frame opcode.

bool fin

FIN bit (true = final fragment).

std::span<const std::byte> payload

Unmasked application payload, in the buffer.

inline std::optional<std::pair<frame_t, std::size_t>> tr::net::ws::decode_frame(std::span<std::byte> buf) noexcept

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. A masked frame is unmasked in place (see decode_frame_checked).

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<std::byte> buf, std::size_t max_payload) noexcept

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 buf and the key zeroed, so the returned payload is a view into buf and nothing is copied — #1780), 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_t::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 bool tr::net::ws::try_encode_frame(mem::block_array_t<std::byte> &out, opcode_t op, std::span<const std::byte> payload, bool fin = true) noexcept

Encode one whole server→client RFC 6455 frame into out: 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. No shipping path builds a whole server frame (both gather sites in transport_ws.cpp ride the payload by reference behind encode_frame_header); this is the form a test or a tool that wants the bytes uses. Replaces the std::vector-returning encode_frame (#1780).

Parameters:
  • out – The frame store; replaced with the frame.

  • 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:

False when out could not be grown — it is then left empty.

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 — try_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 — try_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:

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

0 – payload exceeds kMaxControlPayload — nothing was written.

Returns:

The number of frame bytes written into out.

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

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 accept_key_t tr::net::ws::accept_key(std::string_view client_key) noexcept

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. The two parts are hashed in turn (sha1_t), so nothing is concatenated or allocated.

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)

struct quic_config_t

quic_transport_t’s knobs as one aggregate (#1593), after the address (and, on a dial, the TLS trust).

Public Members

link_memory_t memory = {}

The link’s memory (link_memory_t). rx: each inbound frame is reassembled into a fresh exactly-len-byte segment from it (ADR-0042 §2); exhaustion is backpressure — the frame is drained, dropped, and dropped_rx() ticks.

std::size_t max_frame = 0

Receive cap (:settings max_frame); 0 → quic_transport_t::kMaxFrame. Tighten-only (#1035).

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 buffer from config.memory.io 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(std::string_view peer_host, std::uint16_t peer_port, quic_dial_tls_t tls = {}, const quic_config_t &config = {})

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

  • config – The link’s knobs (quic_config_t): memory, receive cap.

quic_transport_t(std::uint16_t bind_port, std::string_view cert_file, std::string_view key_file, const quic_config_t &config = {})

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 / ws_server_transport_t 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.

  • config – The link’s knobs (quic_config_t) — 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_t::kDefaultMaxFrame

The largest frame the length prefix may announce — the shared length_prefix_framer_t::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_view ca_file

PEM CA bundle path to verify the server certificate against (empty = the system trust store). Borrowed for the constructor call only (#1780).

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.

struct webtransport_config_t

webtransport_transport_t’s knobs as one aggregate (#1593), after the address (and, on a dial, the CONNECT path and TLS trust).

Public Members

link_memory_t memory = {}

The link’s memory (link_memory_t). rx: each inbound frame lands in a fresh exactly-sized segment from it; exhaustion is backpressure (dropped_rx()), never an OOM.

std::size_t max_frame = 0

Per-link RX cap (:settings max_frame); 0 = the default.

bool defer_rx = false

DIAL only — hold inbound FRAMES until start_receiving (#1101, ADR-0081 §2). The session is established as always, 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. The LISTEN constructor ignores it.

std::size_t max_handshake = 0

Pre-auth H3 handshake budget; 0 = kMaxHandshakeBytes. TIGHTEN-ONLY (handshake_cap). A dial bounds the CONNECT RESPONSE’s field section; a listener bounds per-stream classification/HEADERS accumulation and a HEADERS frame’s DECLARED length.

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(std::string_view peer_host, std::uint16_t peer_port, std::string_view path = "/", webtransport_dial_tls_t tls = {}, const webtransport_config_t &config = {})

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

  • config – The link’s knobs (webtransport_config_t): memory, receive cap, deferred receive, handshake budget.

webtransport_transport_t(std::uint16_t bind_port, std::string_view cert_file, std::string_view key_file, const webtransport_config_t &config = {})

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.

  • config – The link’s knobs (webtransport_config_t); defer_rx is DIAL-only and ignored here.

~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::size_t session_path(std::span<char> out) 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. Copies into the caller’s buffer (#1780: no owning string crosses the API) — 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.

Parameters:

out – Where the path goes: its first min(out.size(), length) characters, no terminator.

Returns:

The path’s full length — larger than out.size() means it was truncated.

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_t::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 ws_server_transport_t::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_t::kDefaultMaxFrame

The largest frame the length prefix may announce — the shared length_prefix_framer_t::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_view ca_file

PEM CA bundle path to verify the server certificate against (empty = the system trust store). Borrowed for the constructor call only (#1780).

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.

struct tls_profile_t

One app-registered TLS profile: the trust anchor a DIAL verifies against and the credential a LISTEN serves, under the name a SPEC’s tls key selects.

A profile may fill only the half its links use: a dial-only profile leaves the credential empty, a listen-only one leaves the anchor empty.

Public Members

std::string_view name

The value a SPEC’s tls key selects this profile by. The EMPTY name is the default profile: the one a SPEC that carries no tls key gets.

std::string_view ca_file

DIAL: PEM CA-bundle path the peer’s certificate is verified against; empty = the system trust store.

std::string_view cert_file

LISTEN: PEM server-certificate path. A LISTEN whose profile leaves it empty is refused.

std::string_view key_file

LISTEN: PEM private-key path matching cert_file.

constexpr const tls_profile_t *tr::net::find_tls_profile(std::span<const tls_profile_t> profiles, std::string_view name) noexcept

The profile in profiles named name, or nullptr when the table holds none.

First match wins, so a table that repeats a name keeps its earlier entry. A linear scan, run once per connection creation and never on the frame path.

Parameters:
  • profiles – The app’s table (the factory’s view of it).

  • name – The SPEC’s tls value; empty when the key is absent.

Returns:

The matching profile, or nullptr.

transport_factory_t tr::net::quic_transport_factory(std::span<const tls_profile_t> profiles = {}, mem::mem_backend_t *rx_backend = &mem::net_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(profiles)). A subsequent :children[] SPEC whose config carries kind = quic then constructs a quic_transport_t from the parsed settings — DIAL: addr + port; LISTEN: port. Both read two quic-PRIVATE config keys, parsed by the factory itself from the raw config SETTINGS TLV so the shared conn_settings_t 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).

TLS material is app-owned. A SPEC can arrive from any writer, so it never carries a file path: the certificate, key and CA bundle come from profiles, the app’s table of tls_profile_t. The SPEC can at most select one by name:

  • tls (NAME) — the profile this link uses. Absent selects the profile named "" (the app’s default), if the table has one. A name the table does not hold is refused with TYPE_MISMATCH before any file is opened.

  • insecure (VALUE, u8; default 0) — 1 skips server-certificate validation entirely. DEV ONLY, and it requires the build capability tr::graph::default_config_t::kAllowInsecureTls (default false): without it a SPEC carrying insecure = nonzero is REFUSED at creation with PERMISSION_DENIED and counted in quic_insecure_refusals() (below), on either role. insecure = 0 is accepted on every build.

A LISTEN serves its profile’s cert_file/key_file; with no profile, or one that leaves either empty, creation answers TYPE_MISMATCH. A SPEC-created dialer verifies the server certificate (#918): against its profile’s ca_file, or, when there is no profile or it names no anchor, against the system trust store, and a certificate that does not chain is REFUSED (creation answers TRANSPORT_DOWN).

Parameters:
  • profiles – The app’s TLS profiles (default: none — dials verify against the system trust store, listens are refused). The factory keeps the span, not a copy: the table and the strings it views must outlive the factory and every transport it constructs.

  • 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_factory_t tr::net::webtransport_transport_factory(std::span<const tls_profile_t> profiles = {}, mem::mem_backend_t *rx_backend = &mem::net_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(profiles)). 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 a profile that carries the served credential. Both roles additionally read the OPTIONAL tls profile name and max_handshake budget (#1408). All four 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.

TLS material is app-owned, exactly as for the quic kind: a SPEC never carries a file path. The certificate, key and CA bundle come from profiles, and the SPEC’s tls key (NAME) can at most select one by name — absent selects the profile named "", and a name the table does not hold is refused with TYPE_MISMATCH before any file is opened. A LISTEN serves its profile’s cert_file/key_file and is refused without them.

A SPEC-created dialer verifies the server certificate (#918) — against its profile’s ca_file, or, with no profile or no anchor in it, against the system trust store; a certificate that does not chain is REFUSED (creation answers TRANSPORT_DOWN). insecure (VALUE u8, default 0) set to 1 skips validation entirely — DEV ONLY, and it requires the build capability tr::graph::default_config_t::kAllowInsecureTls (default false): without it a SPEC carrying insecure = nonzero is REFUSED at creation with PERMISSION_DENIED and counted in webtransport_insecure_refusals() (below), on either role. insecure = 0 is accepted on every build.

Parameters:
  • profiles – The app’s TLS profiles (default: none — dials verify against the system trust store, listens are refused). The factory keeps the span, not a copy: the table and the strings it views must outlive the factory and every transport it constructs.

  • rx_backend – The ADR-0042 §2 receive-segment seam every constructed endpoint draws inbound frame segments from (default: the process net sub-pool). 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::net_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 net sub-pool (#1777).

void tr::net::register_udp_transport(transport_vertex_t &vertex, mem::mem_backend_t *rx_backend, mem::block_source_t *egress_src = &mem::net_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::net_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::net_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.

inline graph::result_t<transport_ptr_t> tr::net::with_egress_source(graph::result_t<transport_ptr_t> link, mem::block_source_t *egress_src) noexcept

Wire egress_src into a just-constructed link — the EGRESS half of a factory’s memory injection (#873 family 1, ADR-0079’s net-plane failable store).

The rx_backend argument every factory already carries is the INGRESS seam (ADR-0042 §2); this is its egress twin, and the two are deliberately separate objects: an inbound frame becomes a refcounted segment_t the receiver may keep, while an egress gather is a raw, per-send scratch block whose exhaustion answer is “drop this frame”. Applied right after construction and before the link is wired into the router, which is the transport_t::set_egress_source contract (“before frames flow”).

A failed construction passes through untouched, so a factory can wrap its dial_or_listen result in one expression.

Parameters:
  • link – The factory’s result — forwarded on unchanged.

  • egress_src – The store to wire; nullptr leaves the link on the process default.

inline bus_link_t *tr::net::bus_of(transport_t &link)

link's BUS facet, or nullptr on a target that closed the bus module out — the ONE door the routing plane asks through (#375 deliverable 3).

At the default binding this is link.bus() and nothing else: the if constexpr selects the live branch, so every consumer’s machine code is byte-identical to the direct call (verified by object-file cmp — 68 of 68 objects unchanged). Bound tr::graph::default_config_t::kBusLinks false, the call is not merely predicted away — it is not compiled, so the peer-resolution, peer-enumeration and peer-lifecycle code that hangs off a non-null result is unreachable and the linker keeps none of it.

Consumers ask through here rather than reading tr::net::kBusLinks so that the point-to-point answer is spelled once. transport_t::bus() itself is untouched — still a virtual, still return nullptr by default (ADR-0047 §4: peer wiring is a wiring-frequency query, and this adds no template parameter and no dispatch mechanism to any control structure).

Warning

A DIRECT link.bus() call bypasses the gate and is correct only where the caller IS the bus (a transport’s own facet, a test pinning a link’s shape). Anything on the routing plane asks here.

Parameters:

link – The link to interrogate.

Return values:

nullptr – link is point-to-point, or this target carries no bus module at all.

write_fault_stats_t tr::net::write_fault_stats() noexcept

Read the write_fault_stats_t tally (relaxed; a diagnostic read, not a synchronizer).

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.

The transport set closes at link time: it is the sources a target compiles and the factories it registers, with ordinary runtime dispatch inside, and adding a platform transport appends to that set without editing core. Kinds are fixed at build time, but connections stay runtime: a creator-endpoint SPEC write names the kind as data.

See: can, fwd-router, interface map, reference §communication flows, bench suite.