fwd-router — FWD source routing and the /net plane (L4)¶
In one paragraph
fwd_router_t is the node’s hop-by-hop FWD forwarder and the whole of its remote-operation
surface. It binds one local graph_t to a set of named transport children held in a
child_registry_t, and on each inbound frame it does exactly one of three things: strip the
first dst segment and forward, resolve a local terminus and reply, or hand a fully-consumed
REPLY to the reply sink. It is stateless per request — the forward route is the shrinking
dst and the return route is the growing src, both carried in the frame — so a hop may reboot
mid-operation and the reply routes regardless. Connections themselves are vertices under /net
(transport_vertex_t), created in band and addressed at /net/<module>/<name>.
What it does¶
The net plane is explicit-source-routed FWD only
(ADR-0040 — the net plane is explicit-source-routed only).
A remote endpoint is addressed by its full path through transport vertices; each hop consumes the
segments that name its own link, so dst shrinks toward the target while src accumulates the way
back. That makes a route loop-free by construction and needs no per-hop dedup state: there
is no flooding, and no (origin, ts) suppression table. Two parallel links to the same peer are
therefore two different explicit addresses — deliberate redundancy chosen by the addresser, not
auto-multipath discovered by the router. 0x0D ROUTER is a reserved, decodable wire code
(type_t::ROUTER, core/include/libtracer/tlv.hpp:56) with no implemented mechanism behind it;
source routing needs none.
Four dispositions. Three are decided by resolving the first dst segment against the registry; the fourth is decided by the dst’s own type code, because a bound address has no segment to resolve:
First |
Disposition |
|---|---|
a registered transport child |
forward — strip the segment from |
a local non-transport vertex |
terminus — decode into an arena, apply the op, build |
a |
bound hop — consume element 0 (bounds, generation, ACL at the dereferenced vertex), egress the residual over the link it names (§bound hop) |
nothing, on a |
terminal reply — the accumulated return route is fully consumed, so this node is the originator; the frame goes to the reply sink |
A REPLY routes by the same step but never accumulates src: a reply expects no reply
(RFC-0004 — remote operation addressing §B).
The forward hop builds no decoded tree at all. It reads the frame’s headers by offset, writes
the shortened dst and grown src heads into small stack buffers, and hands the transport those
heads plus untouched views of the inbound frame. Only a terminus request decodes, and it decodes
into an arena drawn from a failable block source rather than the container resource — so a
peer-sized frame arriving behind no ACL cannot exhaust the heap silently. Registry lookups take no
lock; the control-plane mutex covers add_child / remove_child only, and the forward path never
takes it.
The bound hop¶
A dst that is a PATH_REF rather than a canonical PATH takes a fourth disposition, and it is
decided by the element count rather than by the registry (RFC-0024 — bound paths §3.4/§5):
one element left means this node is the terminus and the element names its target vertex;
more than one means this node is a hop, and it consumes element 0 — bounds-check the index,
compare the generation, evaluate the ACL at the dereferenced vertex for the operation’s own right
— then egresses the residual over the link that vertex names. No mount descent runs at all: the
resolve_mount_* family is not entered, there is no digest fold and no segment compare.
One peek decides all four dispositions. The dst’s form — canonical PATH, bound PATH_REF, or
neither — is read once, from the three headers a frame leads with, and the two forms are mutually
exclusive by that type code. Asking the canonical question and the bound question as separate
walks costs the bound form a whole second parse of the same bytes while buying the canonical form
nothing, and it measured a bound terminus slower than the canonical terminus it exists to beat.
The element→link join is one integer per child, recorded at add_child: a child’s mount run is
its connection vertex’s canonical key, so the router resolves that vertex once and remembers its
slot index. It is not a route table — one entry per link, sized by the graph and never by the
traffic — and a child registered before its connection vertex exists simply has none, which makes
every bound route through it fall back to canonical rather than misroute. A bus child never
records one, deliberately: a bus mount’s own send() broadcasts and a bus peer has no vertex, so
no element can name either.
An opcode the build cannot name is dropped rather than forwarded: §6.2 evaluates the ACL for the
operation’s own right, and a hop that does not know an opcode does not know its right, so
charging it the READ right that happens to be at hand is a guess a future write-like opcode
would cross a read-only gate on. A bound REPLY is refused the same way.
Any validation failure is a drop, and never a fall-through to the local terminus: a bound frame
this node cannot route is dropped, the origin still holds the canonical path the binding was minted
from, and re-resolving canonically and re-minting is its recovery. src accumulates canonically
throughout, so the reply of a bound request routes home through the ordinary descent and every hop
on the way back may be a peer that does not speak the bound form at all.
A hop that forwards a mint reply either contributes its element or strips the answer. Every cannot-contribute case strips — no connection vertex for the inbound link, a saturated or retired generation, and a list already at the 255-element cap — because a relayed list that skips a hop is not a shorter route but a wrong one: the skipped hop would later find one element left, believe itself the terminus, and dereference another host’s element against its own vertex map.
The router also carries the origin’s half — connection_ref, bound_egress, adopt_binding and
bound_dispatch — because both halves are the same act: consume element 0, dereference it,
egress. The origin’s element is the one no peer can supply, since the hop out of this node is the
one hop nobody else sees.
Alongside the routing legs the router installs the graph’s remote-delivery sink: a write to a
vertex that carries a remote subscriber fans out as FWD{WRITE} addressed by that subscriber’s
stored return route, or — when the subscription is compact-flagged — as a lean COMPACT bearing a
label bound by a prior ADVERTISE.
Interface¶
namespace tr::net {
class fwd_router_t {
// graph: terminus op resolution. label_src: the NOTHROW block source the route-handle
// label tables draw from (#603 defect 1 / ADR-0065 — it was a `memory_resource` until a
// peer's ADVERTISE proved pmr cannot report exhaustion by value).
// rx: the NOTHROW block source the terminus decode arena draws from (ADR-0065).
// flat: the byte backend EVERY rope flatten draws from, forward AND terminus (#730/#766).
// max_label_bindings_per_link: 0 = unbounded. egress: the reply-egress backend (#795).
// FOUR independent memory seams, each defaulting to the global heap on its own.
explicit fwd_router_t(graph::graph_t& graph,
mem::block_source_t* label_src = &mem::heap_source(),
mem::block_source_t* rx = &mem::heap_source(),
mem::mem_backend_t* flat = &mem::heap_backend(),
std::size_t max_label_bindings_per_link = 0,
mem::mem_backend_t* egress = &mem::heap_backend());
// `name` is this node's mount RUN for `link`: the leading dst segments that route onward
// through it, and the run prepended to src on the way back. ANY width (#523) -- the
// descent makes one registry pass and matches each slot against the prefix of its own
// width, so the only bound is the path-depth budget. Returns false, always (not only in
// debug), for a name no address could ever name: empty, with an empty segment, or wider
// than kMaxSegments. Optional per-child failable source; null falls back to the router's.
bool add_child(std::string name, transport_t& link, mem::block_source_t* rx = nullptr);
bool remove_child(std::string_view name); // removal, not departure
void link_down(std::string_view link_name); // departure: evict edges + drop label state
// Bind a LOCAL producer's subscription toward a MOUNT-PATH target (#739): resolves
// `target` through the SAME strip-K cached descent the forward path uses (ADR-0061),
// derives (link, return route), and admits through graph_t::subscribe_wire — so a
// caller never hand-splits, and an arbitrarily nested /net/A/net/B/x target works.
// Bind-time resolution, link-lifetime durability: teardown drops the binding, and
// re-binding is the application's job. A bus-PEER first hop answers INVALID_PATH.
graph::result_t<void> subscribe_toward(const graph::path_t& producer,
const graph::path_t& target);
// Per-frame sinks: function pointer + opaque context, never std::function (ADR-0047).
using reply_fn_t = void (*)(void* ctx, const view::rope_t& reply);
using inbound_fn_t = void (*)(void* ctx, std::string_view inbound,
const wire::tlv_t& fwd);
using raw_fn_t = void (*)(void* ctx, std::string_view inbound,
std::span<const std::byte> frame);
using compact_delivery_fn_t = void (*)(void* ctx, std::span<const std::byte> route,
std::span<const std::byte> payload);
using stale_label_fn_t = void (*)(void* ctx, std::string_view inbound,
std::uint16_t label);
void on_reply(reply_fn_t, void* ctx = nullptr) noexcept;
void on_inbound(inbound_fn_t, void* ctx = nullptr) noexcept;
void on_raw(raw_fn_t, void* ctx = nullptr) noexcept;
void on_compact_delivery(compact_delivery_fn_t, void* ctx = nullptr) noexcept;
void on_stale_label(stale_label_fn_t, void* ctx = nullptr) noexcept;
// Route-handle producer side (RFC-0004 §E.1).
std::uint16_t advertise(std::string_view link_name, std::span<const std::byte> route_path);
void send_compact(std::string_view link_name, std::uint16_t label,
std::span<const std::byte> payload);
void clear_link(std::string_view link_name);
void on_frame(std::string_view inbound_name, std::span<const std::byte> frame);
const child_registry_t& registry() const noexcept;
const route_handle_t& handles() const noexcept;
};
class child_registry_t { // the one NAME -> link demux table (ADR-0037)
// The link and its SHAPE are ONE atomic word (#882): read as a pair or a rebind that
// flips a name's shape hands a forward one publication's shape with another's link.
struct egress_t { transport_t* link; bool multi_peer; }; // tombstone = null link
struct child_t { std::string name; std::uint64_t name_digest;
std::vector<std::byte> mount_tlv;
egress_t egress() const noexcept; transport_t* link() const noexcept;
bool live() const noexcept; };
bool add(std::string name, transport_t& link); // rebinds; false = no slot
bool erase(std::string_view name); // tombstones in place
// ONE pass, each slot matched against the prefix of its OWN seg_count (#523) — so a
// mount of any width resolves and there is no per-width retry. Longest match wins.
template <class SegAt> const child_t* longest_prefix(SegAt&& at) const; // the demux
transport_t* by_name(std::string_view name) const;
static transport_t* resolve_peer(const child_t&, std::string_view peer);
std::size_t size() const noexcept; std::size_t live_size() const noexcept;
};
} // namespace tr::net
Signature source: core/include/libtracer/fwd_router.hpp:249 (constructor), :469
(add_child), :526 (subscribe_toward), :646-658 (the sink function-pointer types);
core/include/libtracer/child_registry.hpp:327 (add), :585 (resolve_peer), :600
(erase), :633 (entry_by_name), :654 (by_name), :695/:705 (size/live_size).
Routing one inbound frame¶
flowchart TB
IN["inbound frame on child NAME"] --> RAW["on_raw observer"]
RAW --> PEEK["offset peek: first dst segment"]
PEEK --> DEMUX{"child_registry_t<br/>longest_prefix"}
DEMUX -->|"resolves to a link"| FWD["strip dst segment<br/>prepend inbound NAME to src"]
FWD --> SG["stack-built heads<br/>+ untouched frame views"]
SG -->|"send(iov)"| OUT(["transport_t"])
DEMUX -->|"no link, op != REPLY"| TERM["arena decode<br/>(nothrow block source)"]
TERM --> RES["op_resolver_t against graph_t"]
RES --> RPL["FWD{REPLY} back over the inbound link"]
DEMUX -->|"no link, op == REPLY"| SINK["reply sink<br/>rope-native, no flatten"]
classDef zc fill:#dcfce7,stroke:#166534
class SG,SINK zc
Consequences¶
Stateless hops. No per-request table means no timeout sweeper, no correlation map, and no memory that scales with concurrent operations. The cost is that the frame carries its own route: address size grows with hop count, which is what
ADVERTISE/COMPACTroute handles exist to amortise on a steady flow.A reply is delivered as a rope, never flattened by the router (
core/include/libtracer/fwd_router.hpp:654-663). A sink that wants contiguous bytes holdsconst view_t m = reply.materialize()and readsm.bytes(); a single-link reply — the common case — is returned zero-copy, no allocation and no copy, and only a multi-link reply pays one flatten, on demand. The escape hatch sits at the consumer, so the router never pays for a consumer that did not need contiguity.mmust stay alive while its span is read.The default delivery leg copies nothing. A full-route
FWD{WRITE}fan-out scatter-gathers a fresh stack head, the stored return-route bytes, an emptysrc, and one span per link of the stored value (core/src/fwd_router.cpp:3227). TheCOMPACTleg is the one that flattens, because aCOMPACTwraps a contiguous payload (core/src/fwd_router.cpp:3012) — single-link, that flatten is a zero-copy adopt, and multi-link it draws from the router’s injectedflatbackend (#730), not the global heap.All rope flattens on the forward AND terminus paths draw from the injected seam.
flatstarted (#730) as the router’s own four sites — the two ingress control-frame sub-rope flattens, the cold bus-name rejection flatten, and the per-deliveryCOMPACTegress one. The terminus half was not covered: the resolver’s rope-tier flattens, one call belowresolve_terminus_rope, tookrope_t::materialize’s default global-heap backend, so a fragmented request from a peer allocated outside a bounded node’s slab no matter what it had injected. The router now passes the same pointer to itsop_resolver_t(#766), which threads it through the rope-tier node reader, so one injection covers both paths. A refused terminus flatten is answered by value: an addressedkind=ERROR STATUS{BACKPRESSURE}reply, or — when the refusal hit the reply’s own route bytes, leaving no trustworthy address — a drop. Never a reply built on a short span. Since #793 it also covers the rope tier’s single-link ownership copy, which used to reach the global heap throughview::over_bytes— soown_wire’s two branches no longer allocate from two different allocators depending on where the peer’s fragmentation fell. Since #801 it covers the span (arena) tier’sown_wireas well — that tier’s only allocating site, and the one a span-delivering child (a synchronous CAN/UART link) takes on every ordinary WRITE — so which allocator a stored value’s bytes come from no longer depends on which transport delivered the frame. Whatflatstill does not cover: the terminus arena (that isrx’s nothrowblock_source_t, bounded by a different seam, not unbounded) and the reply head segment (genuinely unbounded on both tiers; it answers exhaustion by value, and folding it intoflatwould silently re-scope an injection callers have already sized — see failable allocation and backpressure).Delivery drops rather than aborts on the value path — with one named residual. The flatten, frame build and
iovecreserve on the writer thread are all failable: each drops that one delivery — a subscriber misses a value under exhaustion, which is valid delivery behaviour — instead of raising an exception that-fno-exceptionswould turn intoabort(). A dropped freshADVERTISEself-heals through the peer’sHANDLE_NACK. The label store used to be the residual here — a compact-flagged flow’s first delivery on a link resolves its label before those three steps, and that allocated itslink_tables_tand its egress entry from a throwingstd::pmr::memory_resource, so that one leg could abort under-fno-exceptions(#603 defect 1). It is closed: the store draws every byte from an injectedmem::block_source_tand answers exhaustion by value, so the leg degrades to the full-routeFWD{WRITE}form instead of aborting. See failable allocation and backpressure.Per-frame sinks are a function pointer plus a context, not
std::function(ADR-0047 — build-time closed module sets and compile-time seams). These fire on the per-frame receive path, where type-erasure machinery — code size, a heap capability, exception paths — is the largest avoidable embedded liability. It is the same call shapegraph_t’ssubscriber_fn_tmakes at L4. Passingnullptrclears a sink.A per-child block source is a pointer branch, not a lookup. Each transport owns its receive thread, so a source parked on that child’s receiver context is touched by exactly one thread — the per-thread shape obtained by ownership rather than by a lock — and a bounded node giving each child its own slab makes the bound per-peer, so one noisy link cannot starve another’s decode (ADR-0067 — bounded recycling source and per-owner topology §3).
The /net connection model¶
A connection is a vertex, not a hidden table entry (ADR-0027 — transports and connections are vertices), so it is addressable, readable, subscribable and removable by the same operations as any other vertex.
Creation is an ordinary write. A SPEC appended to the /net catalog field —
write /net:children[] += SPEC{...} — instantiates a connection. The SPEC’s config carries a
kind selector naming a registered transport factory (core/src/transport_vertex.cpp:52 documents
the config shape; kind is read at :63), plus the universal keys addr, port, role,
keepalive, max_frame, backoff and connect_timeout. Two catalog child types are registered
against the graph, client and listener (core/src/transport_vertex.cpp:230,234), which supply
the role default. Extra transport kinds join the catalog through register_transport_type
(core/src/transport_vertex.cpp:263) — that is how the QUIC module extends a node without this
file ever learning about it.
The write is ACL-gated. The :children[] append is gated on the parent vertex’s CREATE
right and denied with PERMISSION_DENIED otherwise (core/src/graph.cpp:3389-3422). Under
RFC-0014 — creator endpoint, connection lifecycle and link liveness
that gate relocates onto the creator endpoint’s own ACL and gains its removal counterpart: a NAME
write is gated on WRITE — not DELETE — per
RFC-0009 — vertex removal and subscriber eviction §A.2.
Mount and routing are the same path. A created connection lives at /net/<module>/<name> and
routes by exactly that path: the routing key is the mount path, so the registry’s precomputed
NAME run is exactly the prefix a hop prepends to src and the forward path assembles nothing per
hop (core/src/transport_vertex.cpp:648-655,665-671;
ADR-0061 — per-transport mount routing, strip-K L5 demux).
The /net/<module> structural vertex is created lazily on first use, with graph_.find itself as the
dedupe rather than a second source of truth (core/src/transport_vertex.cpp:673-681). Because a
connection is addressed under /net/<module>/, a first-level local vertex cannot shadow one.
Module naming is declared-only, by the application
(ADR-0073
§4). There is no derived default and no library-side auto-registration: linking a built-in
transport registers no module name, and an undeclared (kind, role) pair fails creation with
SCHEMA_NOT_FOUND (core/src/transport_vertex.cpp:312). The application declares each module
under a name it chooses through register_module (core/src/transport_vertex.cpp:274,
core/include/libtracer/transport_vertex.hpp:462), a minting boundary gated by the shared
segment-validity predicate — a reserved-character name answers INVALID_PATH. The built-in
transports export suggested-name constants (kWsClientSuggestedModule, …) an application may
adopt; /net itself is likewise only the recommended root convention (a constructor default).
Creation is all-or-nothing. A connection is built in three steps — register the identity
vertex, insert the conns_ entry, wire the link into the router’s child_registry_t — and only
the last can be refused: add_child answers false when the registry cannot grow, and it is the
only place that can say so (core/include/libtracer/fwd_router.hpp:469,
core/include/libtracer/child_registry.hpp:327). A refusal unwinds the first two in reverse —
retire the vertex, then erase the entry, which destroys the config-constructed socket — publishes
no liveness, and answers BACKPRESSURE (core/src/transport_vertex.cpp:814-822). Discarding that
bool left a connection reporting UP that no dst resolved, no inbound frame reached, and
remove_child did not know about — a ghost a peer could mint by creating connections until the
registry slab exhausted. A provide_link staging is consumed only once the wiring has succeeded
(core/src/transport_vertex.cpp:828), so a retry after the pressure clears still finds its link.
Liveness is the connection vertex’s value. link_state_t is six states —
DORMANT, DIALING, RECONNECTING, UP, LISTENING, BIND_FAILED
(core/include/libtracer/transport_vertex.hpp:105-112). DIAL links use the first four; LISTEN
links report listen-socket reachability with the last two, never a per-accepted-peer state. The
value is a 1-byte VALUE on the vertex, so it is await-able and subscribable: subscribe /net/<module>/<name> streams every transition. The liveness engine that would drive these
automatically is not implemented — the value is set by the caller, and a config-constructed socket
reports UP or LISTENING at creation (core/src/transport_vertex.cpp:847-851).
The accepted direction, and what is not realised. RFC-0014 replaces the single global
/net:children[] catalog with a per-module creator endpoint at /net/<module>/conn, whose own
:schema is that module’s config catalog. The endpoint itself is implemented (S2b): declaring a
module mints it, a SPEC{name, config} written there creates /net/<module>/<name> and a
NAME{<name>} removes it, with transport and role positional. What is not implemented is the
rest of the surface around it — the conn:schema catalog read (S3), hiding conn from
/net/<module>:children[] (S4) and the CREATE/WRITE gating split (S2c); the link-liveness
engine (S5, self_heal_link_t) runs for kinds registered self_heal_dial, the built-ins not
yet among them — and the /net:children[] catalog described above still works in parallel until
S7 retires it. One further
boundary is open by design: RFC-0014 delivers the link, while third-party multi-hop SUBSCRIBER
origination — making one node subscribe to another and then departing — is a separate unanswered
question, so an orchestrator can create the wires’ links in band without being able to
originate the wires (#491).
Connection settings are transport-private¶
conn_settings_t (core/include/libtracer/transport_vertex.hpp:129) and conn_role_t (:84) are
a device-private :settings facet of a connection vertex
(ADR-0021 — the colon-field plane is the vertex ioctl
draws the standard / device-private line). They live on the tr::net leaf record and are never
the L4 vertex :settings surface — conflating the two would put addr, port and kind into
the universal settings surface of every vertex in the graph. (That surface’s core namespace is
now empty anyway — RFC-0022 §3.B — which makes the separation structural rather than merely
disciplined.)
The record carries only the keys every transport kind shares. A kind’s private configuration —
a QUIC certificate and key path, for instance — never lands here; that kind’s own factory parses it
out of the raw config SETTINGS TLV handed to it alongside these settings.
Both families — the universal keys and every kind’s private ones — are tabulated key by key on connection config.
Pitfalls¶
add_childreturningfalseis not advisory. A mount name of any width registers and resolves (#523) — the descent makes one registry pass and matches each slot against the prefix of that slot’s ownseg_count— so there is no width pitfall left. Whatadd_childdoes refuse, always and not only in debug, is a name no address could ever name: empty, containing an empty segment, or wider thangraph::kMaxSegments; it also refuses when the registry cannot grow.falsemeans nothing was registered, so a caller that ignores it wires a link that is audible on its transport and resolvable by nodst, whilesize()andlive_size()report the registry as healthy.remove_childis removal;link_downis departure. A link that merely dropped must takelink_down, which evicts subscriber edges and label state but keeps the registry entry. Callingremove_childon a reconnectingDIALlink permanently unroutes it, because under the RFC-0014 model a connection’s vertex outlives its socket. Conversely, callremove_childbefore destroying thetransport_t, or a forward can resolve a freed object.A NAME owns one receiver context, for the router’s life (#884).
remove_childtombstones the child’schild_rx_ctx_twhere it stands — it stays on the lock-free published chain, because a receive thread may be walking it, but it answers no lookup — andadd_childof that same NAME revives it rather than appending a second. Two consequences callers rely on: a re-added child resolves to its current tenancy on the bound path (connection_ref/hop_mintre-resolveconn_slotper registration, so a child that gained a connection vertex between registrations becomes bindable, and one re-added as a bus mount stops being), and create/remove churn on a stable name set neither leaks a context nor lengthens the chain the bound hop walks.receiver_ctx_count()is the assertable form of the second, the twin ofchild_registry_t::size()— which has had this rule since #494/#521.A sink’s
ctxmust outlive every possible delivery. Sinks fire on transport receive threads, possibly several concurrently, and clearing a sink is the only thing that stops future calls.materialize()returns an owner, not a span. Holdingreply.materialize().bytes()past the end of the full expression reads freed memory; bind theview_tto a named object first.Configure children and sinks before frames flow.
add_childand the sink setters are control-plane calls; the forward path deliberately takes no lock, so concurrent reconfiguration is not part of the contract.A bus link’s own connection NAME is not a routable next-hop. A
dstthat names a multi-peer link’s NAME with a residual below it — rather than naming one of its peers — is rejected withtr::path::invalid(0x0021), never forwarded (RFC-0020). Forwarding it would reach the bus endpoint’ssend(), which broadcasts, so one directed request would draw one reply per peer and scramble any client that correlates replies FIFO. Naming the mount exactly still terminates locally, and a peer-directed hop (/net/<module>/<name>/<peer>/…) still forwards — only the broadcast shape is refused.A stale
COMPACTlabel is dropped, never fatal. A label with no ingress binding on its link produces aHANDLE_NACKback to the producer, which re-advertises. An implementation that treats an unknown label as a protocol error breaks the self-heal.
The rest of the plane¶
The router is the orchestrator, but most of the plane’s surface is in the pieces it delegates to — and each of those was extracted precisely so its rules could be tested against hand-built frames with no live transport.
op_resolver_tis the terminus half. When adstnames a vertex on this node, it applies the operation (READ / WRITE / AWAIT, plus any:fieldselector) against the graph and builds the reply as a rope: one exactly-sized head segment prepended to refcount-clones of the vertex’s stored payload, never a serialize into a fresh buffer. It is local-only by construction — adstthat does not resolve locally is answeredNOT_FOUND, and hop-by-hop forwarding stays the router’s job.route_handle_tis the per-node label store behind delivery compaction. Read literally, “a delivery is a FWD WRITE” makes every streamed sample re-carry its full return route; a per-link label aliases that route instead, and each hop swaps the label the way a CAN ID is re-resolved against each bus. Binding is advertise-driven and re-advertise on reconnect is the self-heal. A flow that is not flagged for compaction allocates nothing here, which is what preserves the stateless-forwarder property for everything else.The frame view (
fwd_hdr_t,fwd_pre_t,dst_seg_walk_t,control_head_t,fwd_rebuild_t,stack_writer) is the offset-dispatch cluster the forward hop reads a frame by: one header read as absolute offsets, the forward-versus-terminus peeks, the control-frame head peek, a fixed-capacity stack byte writer, and the shrunk-dst/ grown-srchead rebuild. Everything is templated over a cursor concept and yields offsets, never spans, so the same logic serves a contiguous frame and a link-walking rope and the caller re-slices from its own cursor.sink_slot_t(tr::sink_slot_t— layer-neutral since #1049, when L4 took its three configuration seams into one;tr::net::sink_slot_tremains an alias) holds each of the router’s five observability/terminus sinks — reply, inbound-FWD, raw-frame, compact-delivery, stale-label. A sink is a{function pointer, context}pair set from a control thread and read on every transport receive thread, so the two halves have to be published as a unit: a torn read hands a newly installed function the previous sink’s context, and every sink casts that context. The slot is three words — a generation counter and the pair — published through the counter and read with plain atomic loads, so the frame path takes no lock and never spins: a reader that lands inside a publish reports no sink for that frame rather than waiting. An unset slot therefore costs one load, which is what the plain member it replaced cost, and the router serializes the five setters against each other with one mutex no reader ever takes. It is the observer-shaped sibling of the transport plane’sreceiver_slot_t, which owns the same discipline plus the delivery-tier select (transport) — and the size matters: a first cut that carried astd::mutexper slot put the two sinks the FWD path reads on separate cache lines andbench_forward_demuxcharged ~2% for it at 64 registered links.The grammar (
tr::wire::grammar) is the shared TLV header/trailer core underneath all of that; it is documented with the codec on frame-codec.
API reference¶
-
class fwd_router_t¶
A stateless hop-by-hop FWD forwarder (RFC-0004 §A/§B, ADR-0035 slice 3).
Wires a local graph::graph_t (terminus op resolution, via an internal graph::op_resolver_t) to a set of named transport children. Configure the children (and optional reply sink) before frames flow; thereafter on_frame fires on the transports’ receive threads — possibly several concurrently — and holds no mutable routing state, so no per-request locking is required.
The counted cold-path drops behind @ref drop_stats (#1503 step 3)
std::atomic<std::size_t>and not plain counters becauseon_frameruns on SEVERAL transport receive threads at once and the router takes no lock on the frame path — a plain++here would be a data race by the memory model. Word-sized and RELAXED percore/STYLE.md§Introspection clause 5, so rv32 keeps a lock-freeamoadd.wand pulls no libatomic. Every bump sits on a COLD arm that has already decided to lose the frame; no success arm gains an instruction (ADR-0039’s steady-state hop is the referee).LAST in the class, and that is a measured placement, not tidiness. Declared beside
label_not_found_— their natural home — these seven words pushregistry_, therx_head_chain and the two sink slots the FWD span path reads on every frame apart by a cache line, andbench_forward_demuxcharged ~6% for it at 64 registered links. The sink slots’ own doc block records the same hazard from the other direction. Cold state goes behind the hot state it would otherwise displace.The four injected seams, exposed
A host that injected a seam already holds it; a host that took a DEFAULT (the process-wide heap source / backend) could not reach it at all, so it could not read the
stats()#1503 step 2 put onmem::block_source_t. These mirrorgraph_t::control_source— a reference, never null, never owned, and observation only — nothing in the library reads its own seams back through them.Per-seam, never aggregated (ADR-0079): there is deliberately no combined census here.
-
inline mem::block_source_t &label_source() const noexcept¶
The source the
route_handlelabel tables draw from.
-
inline mem::block_source_t &rx_source() const noexcept¶
The DEFAULT terminus-arena / rx-scratch source. A child that carries its own (ADR-0067 §3) is not reported here — that one is the child’s.
-
inline mem::mem_backend_t &flatten_backend() const noexcept¶
The byte backend every rope flatten this router performs draws from (#730).
-
inline mem::mem_backend_t &egress_backend() const noexcept¶
The byte backend the terminus REPLY head and the mint egress bytes draw from (#795).
Public Types
-
using reply_fn_t = void (*)(void *ctx, const view::rope_t &reply)¶
Reply-terminus sink (on_reply):
ctx, then the FWD{REPLY} frame.
-
using inbound_fn_t = void (*)(void *ctx, std::string_view inbound, const wire::tlv_t &fwd)¶
Inbound-FWD observer (on_inbound):
ctx, inbound child, decoded FWD.
-
using raw_fn_t = void (*)(void *ctx, std::string_view inbound, std::span<const std::byte> frame)¶
Raw-frame observer (on_raw):
ctx, inbound child, the whole frame.
-
using compact_delivery_fn_t = void (*)(void *ctx, std::span<const std::byte> route, std::span<const std::byte> payload)¶
Local COMPACT delivery sink (on_compact_delivery):
ctx, the bound local route PATH bytes, the delivered payload TLV bytes.
-
using stale_label_fn_t = void (*)(void *ctx, std::string_view inbound, std::uint16_t label)¶
Stale-label observer (on_stale_label):
ctx, inbound child, label.
Public Functions
-
inline explicit fwd_router_t(graph::graph_t &graph, mem::block_source_t *label_src = &mem::heap_source(), mem::block_source_t *rx = &mem::heap_source(), mem::mem_backend_t *flat = &mem::heap_backend(), std::size_t max_label_bindings_per_link = 0, mem::mem_backend_t *egress = &mem::heap_backend())¶
Bind to the local
graph; terminus ops resolve against it.Also installs the graph’s remote-delivery sink (#136): a write to a vertex with a remote subscriber fans out a
FWD{WRITE}(or auto-promotedCOMPACT) back over the subscriber’s link. The sink capturesthis, so the router must outlivegraph'suse — the same lifetime the heldgraph_reference already requires.This parameter used to be a
std::pmr::memory_resource*. It could not stay one: a pmr resource cannot report exhaustion by value, so on the shipping-fno-exceptionsprofile a peer’s ADVERTISE storm against an abortingheap_resource_trebooted the node. Kept in the SAME position rather than appended, because feeding the label tables was its only job — a call site that passedstd::pmr::get_default_resource()now passes nothing (or its own source) and gets a compile error rather than a silent re-route. A call site that passed its OWN resource — a node whose graph and router historically shared one pmr arena — migrates by pointing mem::pool_source_t’s SPAN CONSTRUCTOR at the same storage that resource was partitioning (#1493). It must NOT reach for the adapter that shape invites, ablock_source_twrapping the pmr resource: that wrapper’stry_alloccannot answernullptr, so it reinstates the very abort this parameter change removed. mem::block_source_t’s warning carries the full reasoning, including why a budget-tracking variant is declined too. Split fromrxdeliberately (ADR-0079’s per-plane default):rxis per-frame decode scratch and may legitimately be abump_source_t, while label state is LONG-LIVED and would monotonically fill one.
An injected
flatMUST be thread-safe, with the same forcegraph_trequires of itsvalue_backend(ADR-0060 §2) — and for the same two reasons, both of which hold here. Three of the four sites run on a transport child’s RECEIVE thread and several children receive concurrently; the fourth runs on the WRITER thread inside the remote-delivery fan-out. And thesegmentthis backend hands out self-routes its reclaim on whichever thread drops the last reference, which is not in general the thread that allocated it. The defaultheap_backend()already is thread-safe. A bare mem::pool_t is NOT — its free list is a plainstd::size_thead and count with no lock and no atomic, so two receive threads can be handed the same slot — and must be composed with the target’s arch-selected synchronisation before injection:mem::synchronized_pool_ttakes it as a compile-time policy — mem::sync_pool_t is the multi-core spinlock,tr::esp::critical_pool_tthe MCU interrupt-disable one. A bounded node points this at the same slab aslabel_src/rxonly through such a composition. Must outlive the router.- Parameters:
graph – The node’s local graph.
label_src – The nothrow source the
route_handleLABEL TABLES draw from (#603 defect 1 / #873 family 3, ADR-0065 / ADR-0079 §Decision 4) — the library holds no buffer of its own. A bounded node injects a mem::pool_source_t over its static slab (one slab, whole stack — ADR-0039 §2); the default is the process-wide nothrow platform heap. Must outlive the router, and must be thread-safe: the label tables are written fromon_advertise, which runs on a transport RECEIVE thread and is driven entirely by a remote peer.rx – The nothrow source the TERMINUS ARENA draws from (#588). Split from
label_srcbecause the arena is built from a peer’s frame, on the RX path, behind no ACL: astd::pmr::memory_resourcecannot report exhaustion by value, so on-fno-exceptionsits only failure mode isabort(). Drawing the arena from a mem::block_source_t instead makes an over-large frame aTLV_NESTING_TOO_DEEPreject. Appended with a default, so every existing call site is unchanged; a bounded node points this at the same slab aslabel_src. Must outlive the router.flat – The byte backend EVERY rope flatten on the router’s forward AND terminus paths draws its owned
segmentfrom — the router’s own four (#730): the ingress control-frame sub-rope flattens (ADVERTISEroute,COMPACTpayload), the cold bus-name rejection flatten, and the per-deliveryCOMPACTegress flatten; PLUS the terminus resolver’s rope-tier flattens one call belowresolve_terminus_rope(#766) —view_node::ensure_cache(the per-node contiguous span everywire()/body()read of a multi-link TLV materializes) andview_node::own_wire(the ADR-0053 ⑤ ownership flatten) — which the router reaches by passing this pointer straight to its graph::op_resolver_t. Until #766 the terminus half drew from the global heap, so a fragmented request from a peer escaped the bound; the honest sentence now is that all rope flattens on the forward and terminus paths draw from the injected seam. Since #801 that sentence covers the SPAN-tier terminus too:arena_node::own_wire, the ADR-0041 §2 ownership copy a span-delivered request takes, is this backend’s as well — so the MCU terminus (a synchronous CAN/UART child delivers spans, not ropes) no longer escapes the bound on its ordinary WRITE. Still NOT every allocation the router path makes: the reply head segment and the arena are their own injections (egressandrx) — the head drew fromview::heap_alloc’s global heap until #795 gave itegressbelow, which is the reading #730 was filed about. Split fromrxbecause these are BYTE buffers with cache hooks and an owning refcount (a mem::mem_backend_t), not the arena’s raw blocks — the same splitgraph_tmakes between itsctland itsvalue_backend(ADR-0060). Until #730 all of them took mem::heap_backend by default, so a bounded node’s memory bound did NOT cover them; now a node that points this at its own slab bounds them all, and every flatten failure is answered by value — the forward-path frame is dropped (never stored empty), and a refused terminus flatten answers an addressedkind=ERROR STATUS{BACKPRESSURE}reply (or, when the refusal hit the reply’s own route bytes, a drop).max_label_bindings_per_link – Ceiling on one link’s ingress table and, separately, its egress table (#603).
0⇒ unbounded, the default and the prior behavior. Without it the tables are peer-driven and grow to the whole 16-bit label space — megabytes per link on a 16 KB node. A full table refuses NEW flows, which then deliver over the full-routeFWD{WRITE}form; established flows are untouched. See route_handle_t::refused_bindings for the counter.egress – The byte backend the terminus REPLY’s egress-construction segments draw from (#795, ADR-0074): the reply head (peer-driven size — the swapped route bytes plus the inline tail) and, on a mint, the trailing 12-byte
PATH_REF. It is the last reply-egress byte source that escaped a bounded node’s slab (both folded READs’ POINT headers — the composed root’s and the":children"listing’s — are payload framing and draw from the graph’s ownvalue_backendseam instead: #831, closed) — the head was hard-wired toview::heap_alloc’s global heap, one allocation on every reply, peer-drivable and pre-auth reachable (the denied path builds a head too). A DEDICATED injection, deliberately NOT folded intoflat:flatis documented and sized against FLATTEN (payload) bytes, and a reply head is egress construction sized against ROUTE bytes, so wideningflat'scontract would silently re-scope a slab deployments already set for flattens (a node could begin refusing replies it used to send). Passed straight to the graph::op_resolver_t. Appended with a default of the global heap, so every existing call site is byte-unchanged and only a bounded node that points it at its slab gets the bound. A refusal degrades through the same empty-rope →or_backpressure→ addressedSTATUS{BACKPRESSURE}path OOM already takes — answered by value, never an abort. MUST be thread-safe on the same terms asflat. Must outlive the router.
-
inline void configure_path_labels(path_label_table_t *labels) noexcept¶
Inject the RFC-0027 PATH-LABEL mint table, turning label switching ON for this node (§8.3) — or pass
nullptr(the default) to leave it off.Off is the conformant default, not a degraded one (§6.3). A router with no table never mints, so every hop’s local part travels as the string it travels as today and nothing on the route notices; a peer that presents a label to such a node gets §7.2’s
NOT_FOUNDand falls back to the full-string path it still holds. That is why this is aconfigure_verb and an injected pointer rather than a constructor parameter with a capacity: the bound comes from the embedder’s own store (ADR-0079’s per-plane axis) and the library chooses neither the capacity nor the ceiling (CONTEXT.md§Resource bound).It is also what keeps the judging lens honest. The plain-string forwarding path — the shape every deployment already ships — tests this one pointer against null on a member already in the router’s first cache line, and takes the not-taken branch. Nothing else about a string-only hop changes: no field is added to
fwd_rebuild_t(whose 256-byte ratchet is MEASURED, #1235), no byte is read that was not read before, and the mint work itself is out of line.Call during setup, before frames flow, on the same terms as the constructor’s seams.
labelsmust outlive the router.
-
inline path_label_table_t *path_labels() const noexcept¶
The injected mint table, or null when this node does not mint (§6.3).
-
inline std::size_t label_not_found() const noexcept¶
Labelled
dstelements this node refused and answeredNOT_FOUNDfor (§7.2).The counter
dispatch_edge_target’starget_canonical_resolves_is one seam out (core/src/graph.cpp): a refused deref is *”this answer is no longer trustworthy”*, and the discipline that precedent fixes is that the event is COUNTED rather than merely handled. Non-zero means peers are presenting labels this node cannot validate — stale ones after a teardown (the designed, self-correcting case) or labels it never minted. It is never an error: the sender’s recovery is the canonical path it still holds.
-
inline std::size_t label_resolves() const noexcept¶
Labelled
dstelements this node dereferenced and forwarded on (§7.2’s other side) — the numerator to label_not_found’s denominator.
-
inline router_stats_t drop_stats() const noexcept¶
One snapshot of this router’s counted cold-path drops (#1503 step 3).
See router_stats_t for what each field means and why the drops are counted HERE rather than through
graph_t::count_external_drop. Snapshot coherence is thecore/STYLE.md§Introspection clause: six relaxed loads, so use the difference between two snapshots, never the instant.
-
bool add_child(std::string name, transport_t &link, mem::block_source_t *rx = nullptr)¶
Register a named transport-child vertex (ADR-0027).
nameis the mount RUN by which THIS node addresseslink— both the leadingdstsegments that route onward throughlink, and the run prepended tosrcwhen a frame arrives onlink(the way back). Installs a receiver onlinkthat funnels each inbound frame toon_frame(name, ...). Call once per link, during setup (before frames flow).Installs the receiver matching the link’s capability (ADR-0042 §1, ropes per ADR-0053): an owning link (
link.delivers_ropes()) gets a rope receiver whose frame funnels through the SAME routing (the forward hop stays span-based zero-heap; the refcounts ride only to the terminus); every other link keeps the borrowed-span receiver unchanged.may have ANY number of segments —
"up","ws-server/up", the RFC-0014 mount"net/<module>/<name>", or something far deeper. The 1..3 bound is GONE (#523): the mount descent no longer tries key widths from a compile-time constant downward, it makes ONE pass over the registry and matches each slot against the prefix of that slot’s own width. Mount width is therefore bounded by the path-depth budget every address spends from, and by nothing else — there is no constant to raise.The one thing still refused, and now refused ALWAYS rather than in debug builds only, is a name no address could ever name: empty, containing an empty segment, or wider than
graph::kMaxSegments(adstcannot carry that many segments, so nothing could ever prefix-match it). Such a name used to append a slot thatsize()andlive_size()reported as a healthy child while every forward to it missed and fell through to the terminus with no error anywhere — the #516 failure shape, one layer up.A NAME owns exactly one receiver ctx, for the router’s life (#884). Registering a name that already has one — a re-add after remove_child, or a duplicate add of a live name — REBINDS that ctx rather than appending a second, the same one-slot-per-name rule child_registry_t::add follows and for the same two reasons. A second ctx would shadow the first on every name-keyed lookup (which returns the FIRST match, so the DEAD one), and churn on a stable name set would grow the published chain without bound.
[[nodiscard]]since #892, and the reason it was NOT is worth recording because it was wrong in a way that shipped a bug. The attribute was declined on the ground that “every
existing call site registers a name it composed itself, so it stays correct ignoring
it” — true of the
unaddressable-name half, and false of the other half the return value also carries.child_registry_t::addreturns false when the table could not grow, which no composed name can rule out;make_connectiondiscarded exactly that and minted a connection published UP but resolvable by nodst(#930). With the attribute, that bug is a compile error rather than a silent ghost.A caller that genuinely cannot act on the failure writes
(void)and says so. That is the point of the attribute: it does not forbid ignoring the result, it makes ignoring it a deliberate, greppable act instead of the default.- Parameters:
name – This node’s local mount name for the link (e.g. “up”, “ws-server/up”).
link – The transport carrying the next/previous hop.
rx – Optional per-child failable-block source; null uses the router’s. Give each child its OWN when injecting a bounded one — see ADR-0067 3 for why sharing one across receive threads is the wrong shape.
- Returns:
false ⇔
nameis unaddressable, or the registry could not grow — either way NOTHING was registered.
-
bool remove_child(std::string_view name)¶
Un-register child
name— it stops resolving, and its routing state goes.The teardown counterpart of add_child (#494). Tombstones the registry entry (child_registry_t::erase) and then runs the full link_down eviction, so the subscriber edges and route-handle labels bound to the name leave with it.
This is removal, not departure. link_down alone is the right hook for a link that merely dropped: under RFC-0014 a DIAL connection’s vertex outlives its socket and self-heals, so evicting its registry entry on a down notification would permanently unroute a link that is only reconnecting. Call this one when the connection itself is gone — and call it BEFORE destroying the
transport_t, so no forward can resolve a freed object.The child’s receiver ctx is TOMBSTONED, not unlinked (#884): it stays on the published chain — a lock-free reader may be walking it right now — but stops answering every name-keyed and slot-keyed lookup, and a later add_child of the same name revives it in place. That is what keeps
connection_ref/hop_mintoff the dead context after a re-add, and what keeps create/remove churn on a stable name set from growing the chain.- Parameters:
name – The child’s registered NAME.
- Returns:
true if
namenamed a live child, false if it named none.
-
graph::result_t<void> subscribe_toward(const graph::path_t &producer, const graph::path_t &target)¶
Bind a local producer’s subscription toward a MOUNT-PATH target — the locally-initiated dual of the wire
:subscribers[]append (#739).targetis one ordinary path that routes through a transport mount (e.g./net/ws-client/b/display/val, arbitrarily nested/net/A/net/B/x). It is resolved through the SAME strip-K cached descent the forward path uses (ADR-0061), so the caller never hand-splits(link, return route)— the split that bakes in a single-hop assumption and thenet/<module>/<name>string shape. The residual below the matched mount becomes the delivery route the first hop forwards, exactly as an inboundFWDwould carry it.Bind-time resolution, link-lifetime durability. The
(link, route)split is computed ONCE, here. If the link later tears down, the binding is evicted with it (link_down) and re-binding is the application’s job — this helper does not track topology churn (re-establishment is #716’s question, not this API’s).A target whose first hop lands on a bus PEER (
/net/ws-server/mesh/p0/...) is rejected withINVALID_PATH: the per-delivery sink resolves the stored link by its registry NAME, and a peer has no directed registry entry to store — binding it would produce a subscription that silently broadcasts. Directed bus-peer delivery arrives with the #741 work.- Parameters:
producer – The local producer vertex’s path.
target – The mount-path target, spelled from THIS node’s root.
- Returns:
NOT_FOUNDifproducernames no vertex;INVALID_PATHiftargetdoes not route through a mount (or lands on a bus peer);BACKPRESSUREon allocation failure; otherwise the graph::graph_t::subscribe_wire admission result.
-
graph::wire_target_split_t split_subscriber_target(std::span<const std::byte> key) const¶
Split a subscriber target key at the mount it routes through (RFC-0021 §4.B) — the descent subscribe_toward and the wire
:subscribers[]door share.The one place “does this target leave this node, and over which link” is decided, so the host-local dual and its wire twin (#491) cannot drift. Runs the ADR-0061 strip-K descent over the child registry, exactly as the forward path resolves a frame’s
dst.- Parameters:
key – The target’s canonical key — concatenated
NAMErecords. Its bytes must outlive the returned split, whoseresidualborrows from them.- Returns:
{}(empty link, not unroutable) ⇔ NO mount matched — the target names something this node terminates;unroutable⇔ a mount was named but no directed delivery can be bound through it; otherwise the mount’s registry NAME and the residual below it.
-
std::optional<wire::path_ref_element_t> connection_ref(std::string_view link_name) const¶
This node’s own vertex ref for the connection vertex of child
link_name— element 0 of a route that leaves through it (RFC-0024 §4.1).The origin mints its OWN first element, because no one else can: an element is node-scoped, and the hop out of this node is the one hop no peer ever sees. A mint reply therefore comes back one element short of the route, and this is the element that completes it (which adopt_binding does for the caller).
- Return values:
std::nullopt –
link_namenames no registered child, the child has no connection vertex in this node’s graph, or that vertex’s generation has SATURATED (permanently unbindable, §4.4 rule 3). Every one of those means the route has no bound spelling from here, and the canonical path is the answer.
-
transport_t *bound_egress(wire::path_ref_element_t e, std::string_view caller, graph::acl_right_t right) const¶
The egress link a bound element names, after the full §5.1 check.
The forwarder’s hop and the origin’s first hop are the same act — consume element 0, dereference it, egress — so they are the same function. It bounds-checks the index, compares the generation, refuses a saturated one, and evaluates the ACL at the dereferenced vertex for
right(§6.2: a generation match authorizes nothing).- Parameters:
e – The element to consume.
caller – The subject context — the inbound link’s name at a forwarder, empty for this node’s own local caller at an origin.
right – The right the operation carries.
- Return values:
nullptr – Any part of the check failed, or the vertex is not a connection vertex of a live point-to-point child of this node. The caller MUST then drop — never repair, never fall through to a different route (§5.3). A bus PEER is among the refusals and not by omission: a peer has no vertex, so no element can name one, and egressing over the bus link itself would BROADCAST a directed operation (ADR-0073 §3 / RFC-0020).
-
bool adopt_binding(graph::path_t &path, std::string_view link_name, const wire::tlv_t &reply)¶
Install the bound form on
pathfrom the mint answer on a reply (RFC-0024 §7.4).The origin’s side of the exchange, and the reason
path_t::bindhad no production caller until now.replyis a decodedFWD{REPLY}; its LAST child is the accumulatedPATH_REF— the terminus’s element, with one prepended by every hop that forwarded the reply. This prepends THIS node’s own element forlink_name(§4.1: element 0 is the origin’s reference to its first-hop connection vertex) and records the whole stack.Nothing is installed unless the complete route is spellable: a reply with no mint, a link with no bindable connection vertex, or a stack past the normative element cap all leave
pathexactly as it was — bound to nothing, and therefore canonical. A binding is an optimisation with a fallback that always works, so a partial one is never worth having.- Returns:
true iff
pathcame out bound.
-
std::optional<bound_dispatch_t> bound_dispatch(const graph::path_t &path, graph::acl_right_t right) const¶
Spell the next operation over
path'sbinding — the origin’s own hop (§4.1).The origin consumes element 0 exactly as every forwarder consumes its own: it is this node’s reference to its first-hop connection vertex, so it selects the link and does NOT go on the wire. What goes out is the residual,
4 + 8×(H−1)bytes, and the host that receives it consumes ITS element in turn — the monotone shrink that makes a bound path loop-free for the same reason a canonicaldstis.- Parameters:
right – The right the operation carries, evaluated at the dereferenced connection vertex like any other hop’s (§6.2).
- Return values:
std::nullopt –
pathis unbound, or element 0 no longer validates — a link removed, a connection vertex retired, an ACL revoked. The caller’s recovery is the one that always works:clear_binding()and send the canonical path it still holds, which may then re-mint (§5.3).
-
void on_reply(reply_fn_t fn, void *ctx = nullptr) noexcept¶
Set the sink for a REPLY that terminates at this node’s reply endpoint.
Invoked (with the
FWD{REPLY}frame as a view::rope_t) when a REPLY’s firstdstsegment does not name a transport child — i.e. the accumulated return route has been fully consumed and this node is the originator. Optional; absent ⇒ such a reply is dropped.The frame is handed over rope-native (ADR-0055): the router performs NO decode and NO flatten — a rope-delivered reply reaches the sink zero-copy. A sink that wants contiguous bytes holds
const view_t m = reply.materialize()and readsm.bytes()— a single-link reply (the common case) is returned zero-copy, no alloc, no copy; only a multi-link reply pays one flatten, on demand. A sink that wants the eager tree decodes those bytes (wire::decode(m.bytes())). The materialize escape hatch (ADR-0052) now lives at the consumer, not the router; keepmalive while reading its span.- Parameters:
fn – Callback invoked on a transport receive thread; keep it cheap.
ctx – Opaque pointer handed back as
fn'sfirst argument.
-
void on_inbound(inbound_fn_t fn, void *ctx = nullptr) noexcept¶
Set a read-only observer of every inbound FWD (observability/tests).
Invoked after decode with the inbound child name and the decoded FWD, before routing. Carries no routing semantics; used to assert the per-hop
dst-shrink /src-grow invariant and as the seam where a per-hop:aclforward-right check (RFC-0004 §F) will later hang.- Parameters:
fn – Callback invoked on a transport receive thread.
ctx – Opaque pointer handed back as
fn'sfirst argument.
-
void on_raw(raw_fn_t fn, void *ctx = nullptr) noexcept¶
Set a read-only observer of every inbound frame’s RAW bytes (any type).
Fires before dispatch with the inbound link name and the complete frame span — used by tests to measure the on-wire byte-delta between a lean COMPACT delivery and the equivalent full-route FWD{WRITE} (the point of the route-handle).
- Parameters:
fn – Callback invoked on a transport receive thread.
ctx – Opaque pointer handed back as
fn'sfirst argument.
-
void on_compact_delivery(compact_delivery_fn_t fn, void *ctx = nullptr) noexcept¶
Set the sink for a label-compacted delivery that terminates at this node.
Invoked when a COMPACT’s label resolves to a LOCAL terminus binding (the established route names a vertex here): the payload has already been written to that vertex (delivery-is-a-write, RFC-0004 §D). Carries the bound local route PATH bytes and the delivered payload TLV bytes (both borrowed for the call).
- Parameters:
fn – Callback invoked on a transport receive thread; keep it cheap.
ctx – Opaque pointer handed back as
fn'sfirst argument.
-
void on_stale_label(stale_label_fn_t fn, void *ctx = nullptr) noexcept¶
Set the observer for a dropped stale/unknown-label COMPACT (RFC-0004 §E.1).
Invoked when a COMPACT bears a label with no ingress binding on its link — the frame is dropped and a HANDLE_NACK is sent back to prompt a re-advertise (never a crash). Carries the inbound link name and the stale label.
- Parameters:
fn – Callback invoked on a transport receive thread.
ctx – Opaque pointer handed back as
fn'sfirst argument.
-
std::uint16_t advertise(std::string_view link_name, std::span<const std::byte> route_path)¶
Advertise a
label ↔ routebinding over linklink_name(producer side).Sends an ADVERTISE carrying
routeand records the egress binding (so a NACK can re-advertise). Call when a compact-flagged flow starts or on (re)connect — re-advertising IS the self-heal, and the label is minted once per(link, route)then REUSED (#913): the frame goes out on every call, but a re-advertise loop grows no label or table state.- Parameters:
link_name – This node’s NAME for the downstream link to advertise over.
route_path – A complete PATH TLV’s bytes — the delivery route to alias.
- Returns:
The label to stamp on subsequent send_compact, or 0 if
link_namenames no child, or that link’s label space is exhausted / its egress table full (#603 — seeroute_handle_t::ensure_egress). No ADVERTISE is sent in either case.
-
void send_compact(std::string_view link_name, std::uint16_t label, std::span<const std::byte> payload)¶
Send a label-compacted delivery over link
link_name(producer side).Emits
COMPACT{ label, payload }— the route does NOT ride, only the label bound by a prior advertise. No-op iflink_namenames no child.- Parameters:
link_name – This node’s NAME for the downstream link.
label – A label returned by advertise for that link.
payload – A complete payload TLV’s bytes (the delivered VALUE).
-
void clear_link(std::string_view link_name)¶
Forget all route-handle label state for link
link_name(self-heal hook).A transport calls this on (re)connect/disconnect; a subsequent re-advertise rebinds cleanly and a delivery on a now-cleared label is NACK’d, not misrouted.
On a MID-CHAIN node this also drops every ingress binding held on ANY link whose downstream half crossed
link_name(#716) — seetr::net::route_handle_t::clear_link. Without it the upstream, which never saw the reconnect, keeps streaming COMPACTs onto a dead out-label and the flow drops silently forever; with it the upstream’s next COMPACT draws the ordinary stale-labelHANDLE_NACKand the flow re-advertises itself back up.- Parameters:
link_name – This node’s NAME for the link whose label state to drop.
-
void link_down(std::string_view link_name)¶
The link-departure hook (RFC-0009 §D extended to peer departure): evict everything the routing plane holds against a link that just died.
Two halves, in order:
graph_t::evict_link_edges(link_name)deactivates and reclaims every subscriber edge whose stored link islink_name(write fan-outs stop addressing the dead session, and its ~90 B/edge of route/link/caller state is released — the C6’s measured ~27 KB/browser-session leak), then clear_link drops the link’s route-handle label state (unchanged self-heal semantics).add_childinstalls this automatically behind every child’s departure notifier (transport_t::set_down_notifierwith the child’s registered NAME; the bus facet’sbus_link_t::set_peer_down_notifierwith the departed PEER’s name — the same name inbound frames were tagged with, hence the name subscriber edges stored). A host that learns of a departure out-of-band (its own session manager) may call it directly; calling it for a live or unknown link is safe (the next delivery-compact flow re-advertises; eviction of nothing is a no-op).Runs on the calling thread (typically a transport receive/close thread) and takes graph locks — callers must hold no transport-internal locks (the
set_down_notifiercalling discipline).- Parameters:
link_name – The routing plane’s inbound NAME for the departed link/peer.
-
inline const route_handle_t &handles() const noexcept¶
The route-handle label store (test introspection — assert statelessness).
-
void on_frame(std::string_view inbound_name, std::span<const std::byte> frame)¶
Route one inbound FWD frame that arrived on child
inbound_name.Forwards (dst-shrink + src-grow) toward a transport child, resolves+replies for a local terminus, or delivers a terminal REPLY to the reply sink. A malformed or non-FWD frame is dropped. Never blocks on a downstream peer beyond the transport’s own bounded send.
Runs on the calling (transport RX) thread and takes graph and router locks — the caller must hold no transport-internal locks across this call.
- Parameters:
inbound_name – This node’s NAME for the link the frame arrived on.
frame – The complete inbound FWD frame bytes (borrowed; consumed before this call returns — sends happen inline).
-
inline const child_registry_t ®istry() const noexcept¶
The connection registry (test introspection — the shared demux table).
-
inline std::size_t receiver_ctx_count() const¶
How many receiver contexts this router holds (test introspection — the churn bound of #884): one per NAME ever registered, live or tombstoned.
The twin of child_registry_t::size
, and asserted against for the same reason: it is the length of the chain every name-keyed and slot-keyed lookup walks, so “create/remove
churn does not grow it” is a latency property as well as a memory one. Takes the control lock, so it is a control-plane call — never a per-frame one.
Public Static Functions
-
static std::string session_anchor_id(std::string_view mount, std::string_view peer)¶
The node-scoped identity string of the session
peerholds on bus mountmount— the keygraph_t::register_session_anchorallocates against (#1223).Deliberately NOT a spellable address. It is
:<mount>/<peer>, and both:and/are among the seven characterspath::valid_segmentrejects, so the anchor’s rendered key is bytes no registered path in this graph can produce — the property that keeps an anchor out of every listing, every descent and every sweep-set collision. Qualified by the mount because two listeners each name their first sessionp0, and those are two sessions.Exposed because it is what a test (and a later step’s mint) needs in order to name the anchor the router created; it is a pure function of its arguments and holds no state.
-
struct bound_dispatch_t¶
What the origin sends a bound operation as: the link, and the
dston the wire.Public Members
-
transport_t *link = nullptr¶
The egress link element 0 named.
-
std::vector<std::byte> dst¶
The
PATH_REFTLV carrying the RESIDUAL.
-
transport_t *link = nullptr¶
-
inline mem::block_source_t &label_source() const noexcept¶
-
struct router_stats_t¶
The router’s counted cold-path drops — one snapshot of every frame this router lost rather than forwarded (#1503 step 3).
Vocabulary and polarity per
core/STYLE.md§Introspection; the snapshot-coherence clause stated there applies verbatim (each field is one relaxed load, so a multi-field snapshot may tear — the intended use is the difference between two snapshots).Every field counts a **
dropped**, not arefused: these sites lose a frame and tell nobody, which is exactly why the doctrine requires them counted. They are ROUTER-LOCAL and deliberately NOT folded throughgraph_t::count_external_drop— these frames die before the graph is involved, and that door’s exclusion rules exist to stop one refusal being tallied on both sides of the net/graph seam (#1503 Q2).The resource causes are split per-cause because a sizing operator grows a different seam for each; the malformed/opcode drops are fused into malformed_rx because they are one operator symptom (#1503 Q3).
Public Members
-
std::size_t flatten_dropped = 0¶
Frames dropped because the reject-reply flatten could not be served by the injected byte backend (
fwd_router.cpp, therejectarm).
-
std::size_t forward_iov_dropped = 0¶
Forward hops dropped because the scatter-gather iov table could not be built — the rope arm’s source refusing growth, or the contiguous arm overrunning
kFwdMaxIov. Both arms state one policy: drop, never truncate.
-
std::size_t arena_dropped = 0¶
Terminus requests dropped because the injected rx source could not serve the decode arena —
TLV_NESTING_TOO_DEEP, which RFC-0006 spells as “exceeds this
receiver’s decode resources”. The number a deployment grows
rxagainst.
-
std::size_t assemble_dropped = 0¶
Terminus REPLIES dropped because the resolver assembled an EMPTY rope — an allocation refusal inside reply assembly, surfaced as
link_count() == 0.
-
std::size_t reply_iov_dropped = 0¶
Terminus REPLIES dropped because the egress span table could not grow (
try_to_iovecrefused). The request is lost; the client retries.
-
std::size_t delivery_iov_dropped = 0¶
Remote DELIVERIES dropped because the delivery iov table could not be built from the control source (
deliver_remote, both the COMPACT and full-route arms).
-
std::size_t malformed_rx = 0¶
Frames dropped as MALFORMED — a failed decode, a failed root CRC, an oversized or non-forwardable op, an unknown/unauthorized opcode. ONE bucket by ruling: an operator reads every one of these as the same symptom (“a peer is speaking
something this node cannot parse”), and nothing is sized against them.
-
std::size_t flatten_dropped = 0¶
-
class child_registry_t¶
This node’s
NAME → transport linktable (the compositor demux, ADR-0037).A child is addressed by its mount path —
/net/<module>/<name>(RFC-0014, ADR-0061): the run ofdstsegments this node consumes to route onward, and the run prepended tosrcon the way back. Lookups are lock-free.Link identity is the QUALIFIED name — one string, so every existing consumer of a link identity (route-handle label tables, subscriber-edge eviction, the departure notifiers) keeps working on an opaque string and needs no signature change. The demux, which holds two raw segment spans and must not allocate on the hot path (
bench_forward_heap’sallocs=0gate), matches through longest_prefix, which compares each slot’s key against thedstprefix in place and never builds a key.Shape is per-CONNECTION, not per-module — a refinement of ADR-0061, which assumed a module declares it. It cannot:
ws-server’speer_namedconfig decides whether the bus facet is exposed, so two connections in one module may differ. The shape is therefore captured ONCE at add time fromlink.bus()and stored on the slot, which still honours the ADR’s actual requirement — nobus()probe on the forward path. It is stored IN the link’s own word (#882, ADR-0063 erratum 6), so a reader can never pair one publication’s shape with another’s link.Mutation model (#494). The table was add-only, which left a retired link’s
name → transport_t*resident and dangling. erase closes that, and it does so by tombstoning in place — the slot’s link pointer is nulled and its NAME kept — never by erasing fromchildren_. That is deliberate: shifting the vector under a concurrent lock-free reader is a hard use-after-free, whereas a tombstone leaves the slot in place and a racing reader sees either the old pointer ornullptr. A later add of the SAME name reuses its tombstone, so create/remove churn on a stable name set does not grow the table; a genuinely new name still appends, so the table’s high -water mark is the count of DISTINCT names ever registered. Compaction (and the full mutation-vs-forward concurrency contract) lands with the RFC-0014 S5 liveness engine, where the TSan gate and a safe reclamation scheme arrive together — see ADR-0061 (docs/adr/0061-per-transport-mount-routing-strip-k-l5-demux.md).Slots ARE address-stable (#521, ADR-0063). The tombstone alone bought stability against ERASE only:
children_was astd::vector, so appending a genuinely new name reallocated and invalidated every slot reference and iterator in the table — which RFC-0014 turned from a dormant caveat into a live hazard by making connection create/remove a RUNTIME operation. The storage is now an append-only CHUNKED LIST: a chunk is never moved, resized, or freed before this object dies, so a slot’s address is fixed from the moment it is published. ADR-0062’s forward cache builds on exactly that — it holds aconst child_t*and reads the tombstone as its invalidation.Writers are serialized by the caller; readers are not (ADR-0063). add and erase are control-plane calls and must not run concurrently with each other —
add’s scan-then- append is not atomic, so two racing writers can be handed the SAME empty slot.fwd_router_tholds the lock that prevents this. Readers need no lock and take none.Public Functions
-
inline ~child_registry_t()¶
Frees the chunks. Nothing is reclaimed before this point, by design.
-
inline bool add(std::string name, transport_t &link)¶
Register the link addressed by qualified name
name("<module>/<name>").REBINDS
name'sexisting slot when it has one — live or tombstoned — and only appends for a name the table has never held. Captures the link’s SHAPE here, once, so the forward path never probesbus(). A control-plane call: not for the forward path.A name has exactly one slot. Re-adding a LIVE name used to append a second, and that shadow slot reopened precisely the dangling-
transport_t*hole #494 closed: erase nulled only the first match and returnedtrue, so the caller destroyed its transport believing teardown had succeeded while by_name kept resolving the freed link through the shadow. Rebinding makes the name→slot mapping one-to-one, which is what every other operation here already assumes.[[nodiscard]]since #892. It was declined here on the ground that “the control-plane
tests that ignore it are unchanged, and the ONE caller that must not is `add_child`” — which is an argument for the attribute, not against it. Naming the one caller that must check is precisely what the compiler should be enforcing, and it was not:
add_childdiscarded this bool and reported success, somake_connectionminted a ghost UP-but- unroutable connection under heap exhaustion (#930). Tests that mean to ignore an allocation failure now write(void)and are unchanged in behaviour.- Return values:
false – The table could not grow —
appendgot no chunk from the allocator, so NOTHING was registered. It used to returnvoidand swallow exactly this, leaving the caller (fwd_router_t::add_child) to report success and wire a receiver onto a link the registry does not hold: a GHOST child, audible on its transport, resolvable by nodst, and removable by noremove_child— the same “healthy-looking child that every forward misses” shape #523 was filed about.
-
inline std::uint32_t mount_generation() const noexcept¶
The MOUNT-SHAPE generation — bumped whenever a
dstprefix could start or stop resolving to a different mount (#765).The third validate-on-use stamp, beside
graph_t::retire_generation(a revived vertex) and the slot tombstone (a departed link). Neither of those two can see the hazard this one exists for: bind a label through mountnet/ws/s, then registernet/ws/s/rack, and a fullFWDresolves against the NEW, deeper mount while aCOMPACTriding the old label still dereferences the binding made against the old split. Both targets are alive and both are the vertex/link they always were — what moved is the POINT at which the address divides into “local mount” and “remote residual”.Until #523 the two planes agreed about a deeper mount only because NEITHER could reach it — the descent capped its width, so the deeper registration was unroutable to both. That is agreement by mutual failure, and lifting the width bound ends it.
Coarse ON PURPOSE: it counts mount-table mutations, not the mounts a given label depends on. A mutation that could not have changed one label’s split still restamps it, and that label takes the RFC-0004 §E.1 self-heal — drop, observe,
HANDLE_NACK, re-advertise. A per-label dependency set would be a reverse index, which is the option ADR-0062 already rejected: it moves work onto the control plane’s lock to serve the minority flow, and it is a SECOND invalidation mechanism beside one that works.
-
template<class SegAt>
inline const child_t *longest_prefix(SegAt &&at) const¶ The live child whose qualified name is the LONGEST prefix of the
dstsegmentsatyields — the forward demux’s entry point (#523).ONE pass over the table. Each slot is matched against the prefix of ITS OWN child_t::seg_count, so a mount of any width resolves and the descent never retries a width: O(N) slot visits, independent of how wide the widest mount is. It replaces a
k = W..1loop that re-scanned the whole table at every width — O(W×N), measured at 270 ns vs 25 ns for one scan at W=12, N=64 — and which, worse, could only ever match the widths a compile-time constant enumerated (kMountPeekMax, deleted with this).Segments are read through
atand compared against the stored key in place, so no key is ever built and the hot path stays allocation-free (bench_forward_heap’sallocs=0gate). Nothing here is sized by a width: there is no per-width array, no peek window, and no constant to raise.LONGEST-MATCH-WINS is the contract, preserved exactly: the old loop started at the widest key and returned the first hit, so a more specific mount always beat a shorter one. Here the incumbent’s width is the filter (
k <= best_kcannot win), which is also what keeps the pass cheap when a wide mount matches early.- Template Parameters:
SegAt – See prefix_walk_t.
- Returns:
The matched slot, or nullptr when no registered mount prefixes the
dst.
-
template<class SegAt>
inline const child_t *longest_prefix_confirmed(SegAt &at) const¶ longest_prefix with the confirm INSIDE the pass — the cold fallback.
Same contract, same answer, and it is the definition the fast path is an optimisation of. Reached only when the digest filter’s pick fails to confirm.
-
inline const child_t *longest_prefix(std::span<const std::string_view> segs) const¶
longest_prefix over a ready-made segment list — the control plane’s form.
on_advertiseholds decodedNAMEchildren, not a frame cursor, andsubscribe_towardholds a parsedpath_t. Same descent, same answer: the two planes resolving a mount by different rules is precisely what #516 was.
-
inline bool erase(std::string_view name)¶
Tombstone the link addressed by
name— it stops resolving.Call in the same step the link/vertex is torn down, and BEFORE the
transport_tis destroyed, so no forward can resolve a freed object. The slot itself is kept (see the class docs).- Returns:
true if
namenamed a live child, false if it named none.
-
inline const child_t *entry_by_name(std::string_view name) const¶
The link addressed by
name(nullptr if none).Resolution order (ADR-0044): an exact static child NAME wins; otherwise each registered BUS child (a link exposing transport_t::bus) is asked to resolve
nameas a currently-audible peer (bus_link_t::peer_link), yielding a DIRECTED per-peer endpoint. So an announced bus peer’s name is a routable next-hop segment with no registry mutation and no stored peer state — the peer table lives inside the bus transport and expires with its traffic.The live child slot registered under exactly
name(nullptr if none).
-
inline transport_t *by_name(std::string_view name) const¶
The link addressed by
name(nullptr if none), peer fallback included.The identity lookup used off the mount-descent path (reply/advertise plumbing, which addresses a link by its qualified name). Resolution order (ADR-0044): an exact child NAME wins; otherwise each registered BUS child is asked to resolve
nameas a currently-audible peer. Prefer longest_prefix on the forward path, and resolve_peer for scoped peer resolution.
-
inline transport_t *by_segment(std::span<const std::byte> seg) const¶
The link whose NAME equals the raw segment bytes
seg(nullptr if none).
-
inline std::size_t size() const noexcept¶
Number of slots — live children PLUS tombstones (test introspection).
-
inline std::size_t live_size() const noexcept¶
Number of children that still resolve (test introspection).
Public Static Functions
-
static inline constexpr std::size_t segment_count(std::string_view name) noexcept¶
Segments in a qualified mount name —
"a/b"is 2,""is 0.Counts separators rather than splitting: a count is all any caller wants, and building a vector of pieces to learn one would be the wrong shape even on a control-plane path.
-
static inline transport_t *resolve_peer(const child_t &child, std::string_view peer)¶
Resolve
peerwithin THIS endpoint’s own peer table (ADR-0061).The per-endpoint replacement for
by_name’s global cross-bus scan: a peer segment is resolved against the multi-peer child it was addressed through, so two servers’ same-named peers stay distinct and a peer is never reachable through the wrong module. A point-to-point child resolves no peer at all — and neither does anything, on a target that closed the bus module out (tr::net::bus_of, #375 deliverable 3).- Returns:
The directed per-peer endpoint, or nullptr if this child has no such peer.
-
static inline std::vector<std::byte> mount_run_for(std::string_view name)¶
Qualified name
namepre-encoded as a run of NAME TLVs — the mount run.The same bytes a slot’s
mount_tlvholds, exposed so a link’s receiver ctx can carry its OWN copy and a forward hop need not scan the table to find them. It is a pure function ofname, so the copy cannot drift from the slot’s. A control-plane call.
-
static inline constexpr std::uint64_t fold_segment(std::uint64_t h, std::string_view seg) noexcept¶
A per-segment digest of a qualified name — the scan’s cheap discriminator.
Deliberately NOT a general hash. A general hash (FNV-1a was tried) walks every byte in a serial xor-multiply chain, and on a short name that chain is ~24 dependent
imuls — MEASURED at ~30 ns, which is ~20% of a whole forward hop and is paid on EVERY lookup, including the single-child case that has nothing to scan. It made the fixed cost worse to make the scan cost better.This reads three cheap facts per segment — its length and its first and last byte — and folds them with one multiply per segment. For a two-segment mount that is ~3 multiplies instead of ~24, and it costs the same whether the names are 8 bytes or 80.
It is a FILTER, never a decision: a collision costs one full compare, which is what the scan did unconditionally before. The length pre-filter runs alongside it and catches a different axis, so the two together leave very little for the compare to reject.
-
static inline constexpr std::uint64_t digest_name(std::string_view name) noexcept¶
Digest a stored qualified name (
"<module>/<name>") by splitting it on/.Runs once per add — control plane — so the split costs nothing that matters. It MUST produce what digest_segments produces for the same name;
child_registry_testpins that agreement directly rather than only through a lookup, because a silent disagreement would not fail loudly: it would simply stop resolving.
-
static inline constexpr std::uint64_t digest_segments(std::span<const std::string_view> segs) noexcept¶
Digest
segs— the same value digest_name gives for them joined by/.
Public Static Attributes
-
static constexpr std::uintptr_t kBusShapeBit = 1¶
The SHAPE bit an egress word carries in the link pointer’s spare low bit.
Set ⇒ the link is MULTI-PEER (it exposes transport_t::bus). The shape is still captured once at add time, so the forward path still never probes
bus()— what changed (#882) is that it is captured IN the same word as the pointer it describes.
-
static constexpr std::uint64_t kDigestSeed = 0¶
The empty name’s digest.
-
struct child_slot_layout_oracle_t¶
child_t’s fields with child_t::owner_hint taken back out — the layout oracle for that word’s “costs the slot nothing” claim.
Spelled as a mirror rather than as an
offsetof, which is only conditionally supported on a type with mixed access control, and rather than as a literal== 80, which is a 64-bit host’s arithmetic and would break the 32-bit C6 build the whole per-slot byte argument is made for. What is pinned is the PROPERTY: the hint rides in paddingseg_countwas already leaving, so a future reordering that pushes it out of that hole — or a wider field that stops fitting — fails here instead of quietly costing the mount descent eight more cache lines per frame atN = 64.Public Members
-
std::uint32_t seg_count = 0¶
Mirrors child_t::seg_count.
-
std::uint64_t name_digest = 0¶
Mirrors child_t::name_digest.
-
std::string name¶
Mirrors child_t::name.
-
std::vector<std::byte> mount_tlv¶
Mirrors child_t::mount_tlv.
-
std::uint32_t seg_count = 0¶
-
struct child_t¶
One registered child: its qualified mount name, and its egress word.
nameis"<module>/<name>"(RFC-0014’s/net/<module>/<name>minus the constantnetroot). A null link marks a TOMBSTONE (#494) — the slot is dead but stays put so a concurrent lock-free reader’s iteration remains valid. The link and its shape are ONE atomic word and are read through egress; seeegress_for why.Public Functions
-
inline egress_t egress() const noexcept¶
This slot’s link AND its shape, from ONE acquire load.
The only way to read either fact. A routing decision needs BOTH — the shape picks the branch, the link is what that branch sends over — and reading them as two loads let a rebind that FLIPS a name’s shape hand a forward one publication’s shape with another’s link (#882). The dangerous pairing is a stale point-to-point shape with a fresh BUS link: the descent then returns the bus link as a directed egress and its
send()fans out to every open peer, which is the one-request/N-replies misroute (#409) the descent’s rejected-hit branch exists to prevent. One word, one load, and that pairing cannot be spelled.
-
inline transport_t *link() const noexcept¶
This slot’s link alone (nullptr ⇒ TOMBSTONE) — for the shape-agnostic callers (identity lookups, teardown sweeps).
-
inline bool live() const noexcept¶
True while this slot still resolves — i.e. it is not a tombstone.
Public Members
-
std::uint32_t seg_count = 0¶
How many
/-separated segments name has — the slot’s OWN mount width.The field the INVERTED SINGLE-PASS descent turns on (#523). The descent used to try key widths
W..1and re-scan the whole table at each one — O(W×N) slot visits, which is invisible only while a constant caps W at 3. Storing each slot’s own width lets ONE pass match every slot against the prefix of exactly that width, so the descent is O(N) whatever the widths in the table are, and no width needs to be known in advance.Written once, on the append path, before the slot is published — the same contract name_digest has, and for the same reason: it is a pure function of name.
**Declared FIRST, beside name_digest.** These two are the ONLY fields the scan’s hot loop reads, once per slot; everything else is touched for the single slot that wins. Appended at the END of the struct they grew
child_tfrom 80 to 88 bytes, and 64 slots of that is eight extra cache lines the scan walks per frame. An earlier placement that tucked this field into the shape bool’s tail padding (offset 44, digest at 48) got the size back, but the PAIR then straddled a 64-byte boundary on one slot in every four. Here it is 16 bytes at offsets 0/8, so the size is 80 AND every slot’s hot read is one line. (The separate shape bool is gone since #882 — folding it intoegress_left both this offset and the 80-byte size unchanged.)Honest about what that last move bought: it was made to remove the straddle and the A/B moved by ~1 ns at
W = 3,N = 64— inside the run-to-run range, so it is a shape argument, not a measured win. It is kept because the straddle-free layout is the one that does not depend on the compiler having placed the fields luckily; the regression this PR actually had to close was code generation, not slot layout (seeresolve_mount_deepinfwd_router.cpp).
-
mutable std::atomic<std::uint32_t> owner_hint = {0}¶
An OPAQUE cache word the registry’s owner may keep per slot — free of charge.
The registry never reads it, never writes it and attaches no meaning to it. It exists because
seg_countabove already leaves four bytes of padding before name_digest’s eight-byte alignment, so a word parked here costs the slot nothing:child_tis 80 bytes with it and 80 bytes without, and the descent’s hot read is still the one line at offsets 0/8. A field appended at the end instead would have grown the slot to 88 and made the scan walk eight more cache lines per frame atN = 64— a frame-path cost for a control-plane convenience, which is the wrong way round.fwd_router_tkeeps this mount’sgraph_t::intern_link_hintedSLOT HINT here (#1437), so a mount-routed subscribe resolves the link’s interned token by subscript instead of by the graph’s linear name scan. That use imposes no invalidation contract on this struct and must not be given one: the hint is validated by NAME on every use, so a stale word, a rebound tenancy, a tombstone coming back to life and a fresh zero are all merely a wasted comparison. Anything stored here MUST have that property — the registry will not clear it for you, on add, on erase, or ever.Atomic and relaxed on both sides: a lock-free forward reader owns no part of this word, and the control plane is not publishing anything through it.
-
std::uint64_t name_digest = 0¶
A cheap digest of name, computed once at add time.
A pure function of the slot’s own name, so it has NO invalidation contract: a name has exactly one slot and add writes the name only on the append path, before the slot is published. Tombstoning nulls the link in
egress_and leaves this untouched, which is what lets the scan test it BEFORE the acquire-load — a stale-looking hash can only ever cause an extra live check, never a wrong answer.Why it exists: the scan’s per-candidate work was an acquire-load plus a string compare, so a wide table paid a real cost per frame even though at most one slot could match. An inline integer discriminator in the slot’s own cache line makes the overwhelming majority of candidates cost one compare.
-
std::string name¶
Qualified mount name,
"<module>/<name>".
-
std::vector<std::byte> mount_tlv¶
The mount path PRE-ENCODED as a run of NAME TLVs (#508), built once here so a forward hop emits the grown
srcprefix as ONE span with no per-segment work — and so no fixed buffer bounds how long a NAME may be.IMMUTABLE AFTER PUBLISH (ADR-0073’s sibling ruling on #684): the encoding is a pure function of the slot’s key (
encode_mount_name(name)), and a rebind matches by name — so a live slot’s replacement bytes are identical by construction, and add never reassigns them. That immutability is what lets the forward path read this vector as a span with NO lock while add runs concurrently on a control thread; reassigning here would be a use-after-free on the reader (#684). Slot reuse under a DIFFERENT name (should teardown ever recycle slots) inherits this invariant.
-
inline egress_t egress() const noexcept¶
-
struct egress_t¶
ONE read of a slot’s egress: WHERE it sends, and WHAT SHAPE that link is.
The two facts are only ever meaningful together — the shape decides which branch of the mount descent a link is routed down, so a reader that pairs one slot’s shape with another publication’s link routes a BUS link point-to-point and its
send()fans out to every open peer (#882, the #409 misroute). They are therefore read as a pair, by child_t::egress, and never field-by-field on any path.Public Members
-
transport_t *link = nullptr¶
The link; nullptr marks a TOMBSTONE (#494).
-
bool multi_peer = false¶
The shape published WITH that link.
-
transport_t *link = nullptr¶
-
template<class SegAt>
class prefix_walk_t¶ Walks a
dst’s leading segments once, forward, keeping the digest chain with it.The state the single-pass descent needs to test a slot of ANY width for one integer compare. fold_segment is an ACCUMULATOR —
h_k = fold(h_{k-1}, seg_k)— so the digests of every prefix form a chain, and reach walks it forward on demand: a table whose slots share a width folds it exactly once and every slot after the first costs two integer compares, the same per-slot work the old exact-match scan paid.- Template Parameters:
SegAt –
std::optional<std::string_view>(std::size_t)— segmentiof thedst,std::nulloptwhen thedsthas no segment there. An EMPTY string means “present but not routable” (over-long, or unreadable off a rope), which is a different answer: it stops the chain without meaning the address ended.
Public Functions
-
inline bool reach(std::size_t want)¶
Advance the chain to cover the first
wantsegments.- Returns:
false if the
dsthas no usable run that long — and then no LONGER run is usable either, whichlimit_remembers so the rest of the pass costs one compare per slot instead of one walk.
-
inline std::uint64_t digest() const noexcept¶
Digest of the first
reached segments — comparable toname_digest.
-
inline ~child_registry_t()¶
-
template<typename Fn>
class sink_slot_t¶ One
{function pointer, context}observer pair, published coherently.Holds a sink in the ADR-0047 hot-path shape — a plain function pointer plus an opaque
void*, never astd::function— and makes the pair readable from a transport receive thread while a control thread installs or clears it.Thread contract. set may race every reader. get returns a pair that was installed by ONE set call — never a new
fnbeside a stalectx— and the caller dispatches from that snapshot, so a concurrent clear can no longer null the pointer between a caller’s test and its call. Two set calls must NOT run concurrently with each other: the publish is serialized by the owner (fwd_router_tholds one mutex for its five slots), not by the slot, which is what keeps a slot three words wide.What a set costs a frame in flight. Installing or clearing leaves a window of a few instructions in which the slot reads as EMPTY, so a frame that lands exactly there is not dispatched to either the old sink or the new one. That is deliberate: these sinks are configured, not swapped per frame, and skipping one dispatch is strictly better than the alternative it replaces — calling the new
fnwith the oldctx, which every sink then casts.Cost: with no sink installed a read is ONE atomic load, which is what the plain member pair it replaces cost; with one installed it is three more loads of adjacent words and a compare. No lock, no spin, on either path.
The context pointer’s lifetime is the caller’s responsibility and must cover every possible dispatch, exactly as it must for
receiver_slot_t.- Template Parameters:
Fn – The sink’s function-pointer type; its first parameter is the context (e.g.
void (*)(void* ctx, std::uint16_t label)).
Public Functions
-
inline void set(Fn fn, void *ctx) noexcept¶
Install (or clear, with a null
fn) the sink.Safe to call while readers run; NOT safe to call concurrently with another set on the same slot — the owner serializes setters.
- Parameters:
fn – The sink;
ctxis passed back as its first argument.ctx – Caller-owned context; must outlive every possible dispatch.
-
inline bool installed() const noexcept¶
The ONE-load filter: false iff the slot is certainly empty.
Exposed for a caller whose read site is inside an
always_inlinebody it must keep small — the L4 per-edge dispatch loop (#1049) — so the cheap test can stay there while the coherent get moves into the out-of-line leg it guards. It is NOT a check-then-call licence: the guarded leg still dispatches from a get snapshot and does nothing when that snapshot is empty. Atruehere is only ever a hint, exactly as the identical load inside get is.
-
inline snapshot_t get() const noexcept¶
Read the pair coherently; dispatch from the result, never from the members.
- Returns:
The installed pair, or
{nullptr, nullptr}when no sink is installed or a set is in flight.
Terminus resolution¶
An inbound operation carries two claims, not one string: WHERE it arrived (the link name,
which a remote subscription’s deliveries route back through) and WHO sent it (the opaque
per-peer handle the transport minted at accept). The ACL subject is derived from the second
at the terminus, which is what makes a per-writer subject reachable at peer_named=false
— see ADR-0082 — the auth subject and peer_named are decoupled claims.
-
struct inbound_ref_t¶
The inbound identity of a resolved operation: WHERE it arrived and WHO sent it (#375 Part 2 / #1266, fused — the 2026-08-16 ruling on #375).
Two claims, and ADR-0082 is the reason they are two fields rather than one string:
link is ADDRESSING. It is this node’s NAME for the link (or bus peer) the request arrived over, and it is what a remote subscription’s deliveries route back through. It has always been the resolver’s
inbound_linkand its meaning is unchanged.peer is IDENTITY. It is the opaque per-peer handle the transport minted at accept (#1294) and the peer-receiver seam tags each frame with — eight bytes, register-passed, carried instead of re-supplying a name string per frame. The operation’s ACL SUBJECT is derived FROM it, at the terminus, through op_resolver_t::on_peer_subject.
Before the fusion these were the same
std::string_view, which is exactly what made a per-writer subject unreachable atpeer_named=false: one FLAT link has one name for every peer on it. Splitting them is what lets the subject differ per writer while the return route stays the link’s.The converting constructor from a
std::string_viewis deliberate and load-bearing for compatibility:resolve(fwd, "up")still means what it always did — no handle, so the subject IS the link name, byte for byte the pre-fusion behaviour.Public Functions
-
constexpr inbound_ref_t() noexcept = default¶
A resolve with no peer identity: the subject is the link name itself.
-
template<class S>
inline constexpr inbound_ref_t(const S &inbound_link) noexcept¶ Implicit from a bare link NAME — the pre-#375-Part-2 spelling, unchanged.
Templated over anything a
std::string_viewis constructible from, rather than taking one directly, because aresolve(fwd, "up")would otherwise need TWO user-defined conversions (const char[3]→string_view→ here) and stop compiling. The point of the implicit door is that no existing caller has to change; a door every literal misses is not that door.
-
inline constexpr inbound_ref_t(std::string_view inbound_link, net::peer_handle_t inbound_peer, const void *supplier_origin) noexcept¶
The full form the router builds: a link name, its frame’s peer, and the opaque token its subject supplier resolves that peer against.
Public Members
-
std::string_view link¶
This node’s NAME for the link the request arrived on; empty ⇒ LOCAL resolution (no remote-subscriber binding, the trusted local ACL door).
-
net::peer_handle_t peer¶
The interned per-peer identity of the writer (
tr::net::peer_handle_t, #1294). Notvalid()⇒ this kind supplied none, and the subject falls back to link.
-
const void *origin = nullptr¶
An OPAQUE token the subject supplier interprets — never dereferenced here.
A handle is meaningful only to the link that minted it, so resolving one to a subject needs to know WHICH link. The router passes its own per-child receive context through this field and reads it back inside its supplier; every other caller leaves it null. It is a
const void*becausetr::graphis L4 and may not name a transport type (core/STYLE.md — dependencies point up the layers only).
-
class op_resolver_t¶
Resolves an arena-decoded FWD against a local graph and builds the FWD{REPLY} rope.
Local-only (RFC-0004 / ADR-0035): no transport, no multi-hop forwarding, no route-handle. Construct over the node’s graph_t; call resolve once per inbound request FWD, with the arena from
wire::decode_into(ADR-0041).Public Types
-
using reverse_ref_fn_t = std::optional<wire::path_ref_element_t> (*)(void *ctx, std::string_view inbound_link)¶
The responder’s own reverse-direction element supplier (RFC-0024 §7.1 amendment 1): asked, at most once per mint-flagged remote subscribe, for THIS node’s reference to the connection vertex
inbound_linkarrived on.The mapping from a link NAME to its connection vertex is the transport plane’s (
fwd_router_t’s receiver contexts), which this graph-layer resolver deliberately cannot name — so it is injected as a bare{fn, ctx}pair, the ADR-0047 seam shape.nullopt(or no installed supplier — every pre-amendment embedder) means the responder cannot complete the reverse list: the subscription stores none and stays canonical-only, the documented degrade.
-
using subject_fn_t = std::string_view (*)(void *ctx, const inbound_ref_t &inbound, std::span<char> scratch)¶
The SUBJECT supplier (#375 Part 2 / #1266): asked, at most once per resolve and only for a request carrying a valid
inbound_ref_t::peer, for the ACL subject token that peer writes under.This is where *”the subject is derived at the terminus from the handle”* actually happens. The handle is opaque and the table that gives it meaning belongs to the transport plane, which this graph-layer resolver deliberately cannot name — so the derivation is injected as a bare
{fn, ctx}pair, the ADR-0047 seam shape on_reverse_ref and on_path_label already use.fwd_router_tinstalls one that forwards totransport_t::peer_subjecton the link the frame arrived over.Called at most ONCE per resolve, before any graph call, and never for a local resolve: the derived token is then the caller context for every gate the walk runs — the READ gate, the WRITE gate, the SUBSCRIBE gate and the
graph::write_ctx_ta HANDLER sees — so a handler and the:aclthat admitted its write cannot disagree.An EMPTY answer is the conformant default, not an error: the subject falls back to
inbound_ref_t::link, i.e. exactly the caller context the resolver used before the split. Every embedder with no installed supplier is that case, so no shipped deployment’s gate decisions move until a link starts minting per-peer subjects.- Param ctx:
The caller-owned context handed to on_peer_subject.
- Param inbound:
The request’s inbound identity —
peeris the handle to resolve andoriginis the opaque token the installer put there.- Param scratch:
At least
tr::net::kPeerNameCharsbytes of storage the token may be formatted into; it outlives the whole resolve, so the returned view may point into it.
-
using path_label_fn_t = std::span<const std::byte> (*)(void *ctx, std::string_view inbound_link, wire::path_ref_element_t target)¶
The TERMINUS’s path-label supplier (RFC-0027 §6.1 point 3): asked, on a successful operation only, for the label element standing for the residual this node just resolved.
§6.1 point 3 — *”the terminus does the same for the residual it resolved”* — is the residual half of the rewrite whose forwarding half rides
reply_label. The reply’ssrcIS the request’sdst(the residual), so the terminus’s rewrite is a SUBSTITUTION of that one region: the label REPLACES the string bytes and never appends, exactly as §6.1 requires in both directions, and it lands in the one region that survives to the origin (erratum 2).What is injected and why: a label is minted against a
peer_handle_tout of a table the TRANSPORT plane owns (tr::net::path_label_table_t), and this graph-layer resolver deliberately cannot name either. So the mapping from “the link this request arrived on” plus “the vertex it resolved to” to “seven encoded bytes” is injected as a bare{fn, ctx}pair — the ADR-0047 seam shapeon_reverse_refalready uses.The contract each side holds:
Post-auth, always (§8.1). The supplier is called only after the operation’s own gates have passed — after
graph_t::read/write/await/subscribe_wireanswered success — so no label is ever minted for a destination an ancestor ACL hides, and a denied operation answers denied and nothing else.An EMPTY answer is the conformant default, not an error (§6.3): the reply’s
srcstays the string it is today. Every host with no installed supplier is that case, so no shipped reply’s bytes move.The returned span must be exactly
tr::wire::kPathLabelRecordByteslong and must outlive the reply assembly; anything else is ignored and the part stays a string.
Public Functions
-
inline explicit op_resolver_t(graph_t &graph, mem::mem_backend_t *flat = &mem::heap_backend(), mem::mem_backend_t *egress = &mem::heap_backend()) noexcept¶
Bind the resolver to the local
graphit resolvesdstagainst.The SPAN (arena) tier draws from it too since #801 — at one site,
arena_node::own_wire, the ADR-0041 §2 ownership copy of a borrowed arena span. That is the whole of what this tier allocates (itswire()/body()spans are borrowed from the frame and never materialize), and it was the last ownership copy in either tier still going toview::over_bytes’s global heap. It is the MCU terminus’s ORDINARY case, not an exotic one: a synchronous CAN/UART child delivers a contiguous span, so every WRITE it carries took the unbounded copy. A refusal is answered by value, through the empty-view BACKPRESSURE channel below — not throughspans_intact(), which stays a constanttrueon this tier because a borrowed span cannot be shortened by a refused allocation.The default is the global heap, so every existing call site is unchanged.
A refused flatten is answered BY VALUE, never by reading a short span: the resolve walk carries a per-call “spans intact” flag (
spans_intact()on the node-reader concept) and turns a refusal into an addressedkind=ERRORSTATUS{BACKPRESSURE}reply — or, when the refusal hit the reply’s OWN route bytes and no trustworthy address is left, into aBACKPRESSUREstatus on the error side, which the router drops. Never a truncated reply.An injected
flatMUST be thread-safe on the same termsfwd_router_tdocuments for its own: the terminus resolves on a transport child’s receive thread and several children receive concurrently. Must outlive the resolver.- Parameters:
graph – The node’s local graph.
flat – The byte backend the terminus draws its owned
segments from (#766, #793, #801) — the per-node contiguous-span materialize (view_node::ensure_cache, reached by everywire()/body()read of a multi-link TLV) and BOTH branches of the ownership copy (view_node::own_wire: the ADR-0053 ⑤ flatten of a straddling payload and the ADR-0041 §2 copy of a contiguous one — the latter reached the global heap throughview::over_bytesuntil #793, so which allocator a write used depended on where the PEER’s fragmentation happened to fall). These sit one call BELOWfwd_router_t::resolve_terminus_rope, and until #766 they took mem::heap_backend unconditionally: a bounded node that pointed every other injection at its own slab still drew from the global heap the moment a peer sent a FRAGMENTED terminus request — peer-drivable, and anabort()under-fno-exceptions.fwd_router_tpasses its ownflathere, so one injection now covers the router’s four sites AND the terminus.egress – The byte backend the FWD{REPLY}’s EGRESS-construction segments draw from (#795, ADR-0074) — the reply head (peer-driven size: the swapped route bytes plus the inline tail) and, on a mint, the trailing 12-byte
PATH_REF. It is the last reply-egress byte source a bounded node could not previously bound (both folded READs’ POINT-header framing — composed-root and":children"— is payload framing and draws from the graph’s value seam instead: #831, closed): the head was hard-wired toview::heap_alloc’s global heap regardless of every other injection. A DEDICATED seam, notflat:flatis documented and sized against FLATTEN (payload) bytes, and folding an egress head into it would silently re-scope a budget deployments already set. The default is the global heap, so every existing call site is byte-unchanged; a bounded node points it at its own slab and this allocation joins the bound. A refusal returns an empty rope thator_backpressureturns into an addressedkind=ERRORSTATUS{BACKPRESSURE}— exhaustion answered by value, never an abort. MUST be thread-safe on the same terms asflat. Must outlive the resolver.
-
result_t<view::rope_t> resolve(const wire::tlv_arena_t &fwd, const inbound_ref_t &inbound = {}, const view::view_t *frame_view = nullptr, const wire::path_ref_element_t *dst_label_target = nullptr)¶
Resolve an arena-decoded request FWD and build the zero-copy
FWD{REPLY}rope.The op-level outcome (NOT_FOUND for a non-local
dst, INVALID_PATH for a[*]wildcard on a non-subscriber path, TIMEOUT for an AWAIT, …) is encoded as akind=ERRORreply on the value side — a built reply, not a failure. The error side is reserved for a structurally malformed FWD (not a FWD, or missing the requiredop/dst/srcchildren) that no reply can describe, and for a REPLY frame (which is routed, not resolved, here).A request whose is a ZERO-LENGTH is UNACKNOWLEDGED (RFC-0004 Amendment 2, #1502): the return route is also the acknowledgement request, and the empty route is the request not made. A
WRITEis applied and answers with an empty rope — success, refusal and ACL denial alike, since none of them has anywhere to go. AREAD, anAWAIT, a mint-flagged frame or a:subscribers[]subscribe is MALFORMED on that route and refuses on the error side. The empty rope is therefore no longer only the egress-exhaustion signal it was; callers already treat it as “nothing to send” (fwd_router_t’s two terminus arms), and this is a second, deliberate reason for it.A non-empty
inbound.linkmakes an inbound:subscribers[]WRITE bind a REMOTE subscriber (#136): the slot retains this request’s accumulated return route (src, copied once — trailer-sliced) andinbound.link, so the producer fan-out delivers aFWD{WRITE}/ auto-promotedCOMPACTback over that link (RFC-0004 §D/§E.1). An emptyinbound.linkis the local-only field-write — sofwd_router_t, which knows the link, passes it; a bare local resolve does not.The operation’s ACL caller context (#81, ADR-0018) is the SUBJECT, which is derived HERE, at the terminus, and threaded through every graph call the walk makes — so with a subject resolver installed a denied op replies
kind=ERRORwithSTATUS{ERROR{VALUE tr::access::denied}}(0x0050). The derivation isinbound.peerthrough the installed supplier (on_peer_subject), falling back toinbound.linkwhen there is no valid handle, no supplier, or the supplier declines — which is every caller that passes a bare link name, and is byte for byte what the resolver did before the two claims were split.The arena (and the frame it borrows) only needs to outlive this call: every span the reply retains is copied once to its owner (ADR-0041 §2) — or, on an owning-delivery frame, referenced off it (ADR-0042 §3, below).
A non-null
frame_viewmarks the frame as OWNING (delivered as a refcountedview_tover the same bytes the arena borrows — the ADR-0042 receiver seam). Then a WRITE whose payload TLV (node.wire) clears the RFC-0022 §3.D amplification predicatepayload * K >= segment—Kbeing the target vertex’s owner-declaredpin_payload_ratiowhen set andconfig_t::kPinPayloadRatiootherwise — and whose opt byte carries no trailer bits is stored as a SUBVIEW of the frame — a refcount bump that pins the whole frame, zero copy. Segment-dominated, trailered, or span-delivered payloads keep the ADR-0041 one-copy trailer-sliced store, byte-identical to before; the remote-subscriber return route always keeps its subscription-scoped one-copy behavior.A label REPLACES the string bytes of the part it stands for (§6.1), so a labelled
dstthat reaches its terminus carries no name to look up:path_lookup_keyrefuses an escape record in key context and must, because reading a peer’s slot index as UTF-8 is the guessing §7.2 forbids. The address is resolved BEFORE this call, by the transport plane that owns the label table (tr::net::path_label_table_t), and what arrives here is that resolution: this node’s own reference to the vertex the label aliases — the identical element RFC-0024’s bound spelling carries.Non-null means resolve against this element and not against ‘s bytes, on the SAME arm the bound spelling takes: one
deref_vertex_slot, then the operation’s own ACL gate insidegraph_t::read/write/awaitat the dereferenced vertex. That reuse is how §8.2’s *”exactly as the string form does”* becomes one implementation instead of two kept in agreement. A stale element — the vertex retired between the label’s mint and this frame — answersNOT_FOUNDon the error side, which the router turns into a drop, exactly as a stalePATH_REFdoes.Two MINTS are suppressed while it is set, and both are §11.2’s rule rather than an optimisation: this address is already spelled in one compressed form, so the reply neither mints a second label into it (there is nothing left to replace — the request’s
dst, which the reply echoes as itssrc, IS the label) nor answers an RFC-0024 mint request with aPATH_REFfor it (*”SHOULD NOT bind a
`PATH_REF` over a path whose elements are already labelled”*).
- Parameters:
fwd – An arena-decoded request FWD (from
wire::decode_into).inbound – WHERE the request arrived and WHO sent it (inbound_ref_t) — implicitly constructible from a bare link NAME, which is the pre-#375-Part-2 spelling and behaves identically. An empty
inbound.linkis a local resolution (no remote-subscriber binding); a validinbound.peeris what the installed subject supplier (on_peer_subject) derives the ACL subject from.frame_view – The owning frame view when the link delivers views (ADR-0042); nullptr on the borrowed-span path.
dst_label_target – RFC-0027 §7.2 at a terminus — the vertex a LABELLED
dstalready dereferenced to, or nullptr, which is every string- andPATH_REF-spelled request and therefore every request a host with no injected label table ever sees.
- Returns:
The reply as a view::rope_t (head segment + roped payload views), or a
status_ton a malformed/non-request frame.
-
result_t<view::rope_t> resolve(const wire::tlv_view_t &fwd, const inbound_ref_t &inbound = {}, const view::view_t *frame_view = nullptr, const wire::path_ref_element_t *dst_label_target = nullptr)¶
Resolve a rope-delivered request FWD (the lazy
tlv_view_ttier) and build theFWD{REPLY}rope — the owning-delivery twin of the arena overload (ADR-0053 §7).The same terminus semantics as the wire::tlv_arena_t overload — it runs the ONE templated resolve walk, here over the forward-only wire::tlv_view_t reader (ADR-0053 §1), so a frame reassembled as a scatter-gather rope (fragmented WS / CAN) is resolved WITHOUT an interim flatten of the whole frame. Byte-identical replies to the arena tier for the same logical request (the differential oracle in
op_resolve_view_test).- Parameters:
fwd – A rope-backed request FWD (
wire::tlv_view_t::over).inbound – WHERE the request arrived and WHO sent it, exactly as the arena overload documents (inbound_ref_t).
frame_view – Reserved for the ADR-0042 owning-store seam; the rope tier stores its one ownership copy, so pass
nullptr.dst_label_target – The RFC-0027 §7.2 labelled-
dstresolution, with exactly the meaning and the two suppressed mints the arena overload documents at length. Both tiers take it because a labelled request may arrive fragmented like any other, and the two tiers answering one logical request differently is the drift ADR-0053 §7’s single walk exists to make impossible.
- Returns:
The reply as a view::rope_t, or a
status_ton a malformed/non-request frame.
-
inline void on_reverse_ref(reverse_ref_fn_t fn, void *ctx) noexcept¶
Install the reverse-element supplier (null
fnuninstalls).
-
inline void on_peer_subject(subject_fn_t fn, void *ctx) noexcept¶
Install the subject supplier (null
fnuninstalls).
-
inline void on_path_label(path_label_fn_t fn, void *ctx) noexcept¶
Install the terminus path-label supplier (null
fnuninstalls).
-
inline void on_link_id(link_id_fn_t fn, void *ctx) noexcept¶
Install the terminus LINK-TOKEN supplier (null
fnuninstalls) — #1266 / #1417’s carry.The seam that lets a remote subscribe reach the subscriber index by SUBSCRIPT instead of by name hash. The supplier is the transport plane’s: it minted the link’s
graph_t::intern_linktoken when the link (or the bus peer) became audible and cached it in its own per-link receive context, which is what inbound_ref_t::origin points at. Worth 42–56 % of the index operation net of its control, and 27 % of its bytes.ASKED LAZILY, at the subscribe branch and nowhere else. That is the load-bearing part of the contract: a control-plane saving paid on every terminus frame is what killed #1290’s prototype, so unlike subject_fn_t this one is NOT resolved once per resolve. A read, a write, an await and a forwarding hop never call it.
An invalid answer is the conformant default and costs nothing but the lookup the index does today — no installed supplier, a peer the supplier has no token for, a census bus that never announced. So is a WRONG answer: the index verifies that the token’s slot spells the key it is about to index under, so a supplier that confuses two links loses a subscript, not an edge.
-
using reverse_ref_fn_t = std::optional<wire::path_ref_element_t> (*)(void *ctx, std::string_view inbound_link)¶
-
enum class tr::graph::fwd_op_t : std::uint8_t¶
The four FWD operations (RFC-0004 §B — the
opchild, a u8).Values:
-
enumerator READ¶
Read the data LKV or the selected
:field.
-
enumerator WRITE¶
Write the payload TLV to the vertex or the selected
:field.
-
enumerator AWAIT¶
Block for the next write (honoring
await_timeout).
-
enumerator REPLY¶
A reply routed back; not resolvable as a request here.
-
enumerator READ¶
Delivery compaction¶
-
class route_handle_t¶
Per-connection
label ↔ routetables for ws delivery-compaction (RFC-0004 §E.1).The label state lives PER LINK (ADR-0038 §3 / ADR-0039): each connection owns its own small tables — an INGRESS table (a label arriving on the link → its handle_binding_t), an EGRESS table (a label this node advertised over the link → the route it aliases, retained so a NACK can re-advertise), and a monotonic label allocator — drawn from the injected tr::mem::block_source_t and guarded by the LINK’S OWN mutex, so label traffic on one connection never contends with another. The only cross-link lock is a
shared_mutexover the link registry, taken exclusively when a link’s tables are first CREATED or when clear_link removes them. Each link’s tables are REFCOUNTED, so the accessors hand out a PINNING copy:clear_linkcan erase the registry entry while a concurrent writer still holds the tables — the node is destroyed only when the last outstanding reference drops (no dangling reference), and the registry is bounded to LIVE link names instead of growing one empty shell per departed name (#488). State exists only for flows that opted into compaction, so ingress_count on a node forwarding only one-shot/cold traffic is zero.Public Functions
-
inline explicit route_handle_t(mem::block_source_t *src = &mem::heap_source(), std::size_t max_bindings_per_link = 0)¶
Draw all label state from
src(ADR-0065 / ADR-0079), bounding each link’s tables atmax_bindings_per_linkentries.A bounded node passes a
mem::pool_source_tover its slab and the label tables live entirely in host-chosen memory; the default is the process-wide nothrow platform heap.srcmust outlive this object, and must be thread-safe if more than one transport receive thread can reach this store (the RFC-0014 wire-driven paths do).The bound is injected, never assumed ([CONTEXT.md §Resource bound]).
0means unbounded — the default, and the pre-#603 behavior. A bounded host sizes it from its own slab; ADR-0038 §3 calls for exactly this (*”sized by `:settings`”*).Since #603 defect 1 the SOURCE is a bound in its own right, which is ADR-0079’s “a bounded node is a property the deployer injects”: a full source refuses exactly as a full table does, so a deployment can size the label plane by the slab alone and leave
max_bindings_per_linkat0. The two are complementary rather than redundant — the count bounds ONE link’s share, the slab bounds the node’s total.- Parameters:
src – Where all label state is allocated (nothrow, by value).
max_bindings_per_link – Ceiling on a link’s ingress table AND, separately, its egress table;
0⇒ unbounded.
-
~route_handle_t()¶
Release every live link’s tables (and the byte blocks they own).
-
bool bind_ingress(std::string_view in_link, std::uint16_t label, handle_binding_t binding)¶
Record an ingress binding: a
labelarriving onin_linkmeansbinding.Rebinding a label already present always succeeds — it replaces in place and adds no entry. Only a NEW label can be refused, and only when the link is at
max_bindings_per_link.- Parameters:
in_link – This node’s NAME for the link the ADVERTISE/COMPACT arrives on.
label – The label as seen on that inbound link.
binding – Its meaning (forward-swap or local terminus). Its
down_link/local_routebytes are BORROWED for the call and copied into the store’s own blocks; nothing is retained.
- Return values:
false – The link’s ingress table is full, or the injected source is exhausted — nothing was recorded, and refused_bindings
was incremented. A COMPACT on the unbound label then takes the same drop-and-HANDLE_NACK path a stale label already takes, which prompts the peer to re-advertise. The two refusals are deliberately ONE answer: ADR-0079 makes the injected store’s size a bound, so “the slab said no” degrades exactly as “the count
said no” already did, and no caller learns a new shape.
-
std::uint32_t link_epoch(std::string_view link) const¶
The CLEAR EPOCH of
link— the token that says “this link’s tables have not been
reconnected since” (#827).
Sampled by a forwarding hop BEFORE it mints anything against its downstream link, and handed back to bind_ingress_forward when the swap is finally bound. Every clear_link advances a node-wide counter, and a link’s tables carry the value they were created at, so once the link HAS tables its epoch changes only when THIS link is cleared — an unrelated link’s reconnect leaves it alone. Before the tables exist there is nothing to stamp, so a sample reads the shared counter: an unrelated clear_link landing between that sample and the table creation makes the fresh tables stamp the bumped value and the bind is refused SPURIOUSLY. The refusal direction is safe — the upstream’s next COMPACT draws a stale-label NACK and the re-advertise binds normally — a liveness nit confined to a link’s first-contact window, never a stale binding.
- Parameters:
link – This node’s NAME for the link.
- Returns:
An opaque token, meaningful only when compared against a later sample of the SAME link. A link with no tables reads the current counter, so creating them changes nothing and the first advertise on a link is refused only in the first-contact window above.
-
bool bind_ingress_forward(std::string_view in_link, std::uint16_t label, handle_binding_t binding, std::uint32_t down_epoch)¶
bind_ingress for a FORWARDING swap, refused if
down_epochwent stale.The store-under-inbound / point-at-outbound asymmetry again (#716), now in its racing form (#827). clear_link’s cross-link sweep can only erase bindings that already exist; an
on_advertiserunning on another link’s rx thread mints its out-label and retains its egress route against the PRE-clear downstream table, and binds the swap afterwards. A reconnect landing between those two steps sweeps the inbound link before the binding is there, and the binding lands after the table it aims into is gone — reproducing the exact #716 state the sweep exists to prevent, through a window measured in microseconds and with a permanent outcome.So the swap is bound only if
down_epoch— sampled from link_epoch before the mint — still names the downstream tables the label was minted against. The epoch read and the insert are ONE critical section against clear_link, so the sweep cannot interleave between them. Refusing takes the path a full ingress table already takes (false, nothing recorded): the peer’s COMPACT misses, draws the ordinary stale-label HANDLE_NACK, and the flow re-advertises from a clean slate. Nothing new goes on the wire, and this is the COLD advertise path — the per-delivery path is untouched.A refusal is deliberately NOT counted in refused_bindings
, which means “a link’s
table was at its injected bound”. This is not a resource refusal and it is not silent: the very next COMPACT on the unbound label fires the stale-label observer, which is where the event is already visible.
- Parameters:
in_link – This node’s NAME for the link the ADVERTISE arrived on.
label – The label as seen on that inbound link.
binding – The forwarding swap;
binding.down_linknames the linkdown_epochwas sampled from. A terminus binding has no downstream half and must use bind_ingress instead.down_epoch – The link_epoch of
binding.down_link, sampled BEFORE the out-label was minted.
- Return values:
false – Nothing was recorded — either the ingress table is at its bound, or the downstream link was reconnected inside the window.
-
binding_copy_t copy_binding(std::string_view in_link, std::uint16_t label, std::span<char> link_out, std::span<std::byte> route_out) const¶
Copy what a
labelarriving onin_linkmeans into CALLER storage — allocation-free (found == false⇒ stale/unknown).Replaces the owning
lookup_ingress(#603 defect 1). That returned astd::optional<handle_binding_t>whosestd::string+std::vectorwere built on the throwing global heap — on the COLD COMPACT arm, which a peer provokes by sending a frame on a label whose resolution has not been memoized yet, and on the observed-delivery arm. Both are peer-driven, so both wereabort()candidates on-fno-exceptions. There is no allocation here at all: the bytes land inlink_out/route_out.A buffer that is too small does NOT lose the answer. The binding’s real sizes are reported and
truncatedis set, so a caller can retry against a block grown from its own injected source (fwd_router.cppdoes exactly that for a route wider than its frame buffer). That is the difference between “the route does not fit here” and “there is no
binding”, which a plain empty result would have conflated.
- Parameters:
in_link – This node’s NAME for the inbound link.
label – The inbound label.
link_out – Destination for a FORWARD binding’s downstream link name. A link name is one path segment, so
graph::kMaxSegmentBytesalways suffices.route_out – Destination for a TERMINUS binding’s local route bytes.
- Returns:
The binding’s facts, with views into
link_out/route_outthat are valid only while those buffers are.found == false⇒ drop + NACK, exactly as anulloptdid.
-
resolved_binding_t resolved(std::string_view in_link, std::uint16_t label) const¶
The steady-state lookup: the RESOLVED binding, by value, allocation-free.
Prefer this on the COMPACT hot path. The retired owning
lookup_ingresscopied astd::stringand astd::vectorout of the table on every call — two allocations per frame, paid before anything checked whether the flow was already resolved, and both on the throwing global heap. This copies ~24 trivially copyable bytes instead, and returnsfound=falsefor an unknown label so the caller still NACKs identically.A
warm=falseresult means the caller must resolve and then call cache_resolution.
-
std::size_t copy_local_route(std::string_view in_link, std::uint16_t label, std::span<std::byte> out) const¶
Copy a TERMINUS binding’s local route bytes out, without allocating.
The warm-COMPACT companion to resolved.
resolved_binding_tdeliberately omits the route bytes because a warm delivery does not need them to WRITE — but an installed fwd_router_t::on_compact_delivery observer is handed them, and serving it through the retired owninglookup_ingressmade the warm path re-pay the whole owning copy (astd::stringplus astd::vector) that resolved exists to remove, per frame.The bytes are copied under the link’s own mutex into caller storage rather than handed to a callback from under it: the observer is host code, and the lock order this class establishes is registry → table, so invoking anything re-entrant while holding a table mutex would invert it against clear_link.
- Parameters:
in_link – This node’s NAME for the inbound link.
label – The inbound label.
out – Destination; written only when the route FITS.
- Returns:
The route’s full size in bytes —
0when no binding exists (or it is a forwarding swap, which has no local route). A return greater thanout.size()means nothing was written and the route is too long forout; the caller retries against a larger buffer (copy_binding reports the same size). Callers must therefore test the returned size againstout's, never assume a copy.
-
void cache_resolution(std::string_view in_link, std::uint16_t label, const resolved_binding_t &r)¶
Record the resolution
ragainst (in_link,label) so later frames skip it.Idempotent and best-effort: a binding that vanished between resolve and cache is simply not updated, and the next frame resolves again. Never invalidates a live delivery.
-
bool record_egress(std::string_view out_link, std::uint16_t label, std::span<const std::byte> route)¶
Remember the
routeadvertised overout_linkunderlabel.Lets this node re-advertise the binding on a HANDLE_NACK (or reconnect) without re-deriving the route. Idempotent — re-recording the same key replaces it.
- Parameters:
out_link – This node’s NAME for the downstream link the ADVERTISE went out on.
label – The label this node assigned for that downstream flow.
route – The (possibly stripped) dst PATH TLV bytes the label aliases. Borrowed for the call and copied into the store’s own block.
- Return values:
false – The link’s egress table is full, or the injected source is exhausted — nothing was recorded and refused_bindings was incremented. The caller must not advertise a label it cannot re-advertise on a NACK.
-
std::pair<std::uint16_t, bool> ensure_egress(std::string_view out_link, std::span<const std::byte> route)¶
Find this link’s label for
route, or allocate + record a fresh one (#136).The producer-origin lazy-advertise primitive (RFC-0004 §E.1, Q5): the first compact delivery on a
(out_link, route)flow has no binding, so a new label is allocated, recorded as egress, and returned withfresh == true(the caller must send the ADVERTISE once); subsequent deliveries find the same label and returnfresh == false(send only the COMPACT). clear_link drops the binding so a post-reconnect delivery re-advertises — the self-heal, with no transport “up” event. Since #913 the forwarding-hop swap andfwd_router_t::advertisemint here too, in place of an alloc_label + record_egress pair that minted unconditionally and burned one label per re-advertise cycle.A fresh entry is born RECLAIMABLE and the first reuse of it clears that (#833): while the mint is the only take of a label, the minter may still hand it back with release_egress. The steady-state delivery path pays one byte-load and a not-taken branch for that — the store happens at most once per entry, on the first reuse — and never a store on an entry a second caller has already taken.
- Parameters:
out_link – This node’s NAME for the downstream link.
route – A complete PATH TLV’s bytes — the delivery route the label aliases.
- Returns:
{label, fresh}— the (reused or new) label, and whether it was just created.{0, false}⇒ no label is available: this link’s label space is exhausted (see alloc_label), its egress table is atmax_bindings_per_link, or the injected source could not serve the route copy. Nothing was recorded; the caller delivers over the full-route FWD path. A flow ALREADY in the table is never refused — reuse is checked before the bound, so a full table degrades new flows only.
-
void release_egress(std::string_view out_link, std::uint16_t label, std::span<const std::byte> route)¶
Hand back a label + egress route taken from ensure_egress and never put on the wire — the refused-bind unwind (#833).
A forwarding hop mints its out-label and retains its stripped egress route BEFORE it binds the ingress swap, because the binding names the label. When that bind refuses — a full ingress table, or the #827 epoch guard — the hop returns without advertising, so the label it minted aliases a route no ingress binding aims at and no peer has ever seen. Nothing reclaimed it short of the downstream link’s next clear_link. This gives it back: the entry is erased, and the label itself returns to the allocator when it is still the most recently minted one.
Only the MINT is reclaimable.
Since #913 an egress entry is SHARED — one label serves every ingress flow whose stripped route is identical — so erasing it on one claimant’s refusal would strand every other. The entry therefore carries “the mint is
still the only take of this label”, set when
ensure_egress creates it and cleared by the first reuse, and this call erases nothing once that is false. That is what makes the two-thread interleaving safe without holding a lock across the bind: an advertise on another link’s rx thread that reuses the label between this caller’s mint and its refusal has already cleared the flag, so the entry it now depends on survives. An established flow is untouched by construction — its take was a reuse.Note
A no-op when the link has no tables — a release must not CREATE a link shell, which is the state link_count bounds (#488). So the common companion of a refusal, a downstream reconnect that erased the whole table, costs nothing here.
- Parameters:
out_link – This node’s NAME for the downstream link the label was minted on.
label – The label ensure_egress returned.
0is ignored.route – The route bytes that were passed to ensure_egress; an entry whose route has since been replaced (record_egress) is left alone.
-
std::size_t copy_egress_route(std::string_view out_link, std::uint16_t label, std::span<std::byte> out) const¶
Copy the route this node advertised over
out_linkunderlabelintoout— allocation-free (for re-advertise).Replaces the owning
egress_route(#603 defect 1). That one was already nothrow, but it got there by probing the GLOBAL heap throughdetail::try_assign— which on-fno-exceptionsfrees the probe block and then runs a throwingassignon the inference that the block is still free. This arm is reached from an inbound HANDLE_NACK on a transport receive thread, so a racer in that window is the normal case, and theassignhitting exhaustion inside anoexceptaborts the node (#850, the #981 residual). Copying into caller storage removes the probe, the window and the global-heap reach in one move.- Parameters:
out_link – This node’s NAME for the downstream link.
label – The downstream label.
out – Destination; written only when the route FITS.
- Returns:
The route’s full size in bytes —
0when no route is bound for this label. A return greater thanout.size()means nothing was written; the caller retries against a larger buffer (its own injected source) rather than reading a truncated route. Callers MUST test the returned size againstout's.
-
std::uint16_t alloc_label(std::string_view link)¶
Allocate a fresh, per-link, monotonic label (≥1; 0 is reserved “none”).
The allocator SATURATES rather than wrapping (#603). It issues 1..65535 in order and is then permanently exhausted, returning 0 — because a wrapped counter re-issues labels that still alias live routes, and a delivery on a reused label resolves the WRONG route (a misroute, not a drop). Labels are not reclaimed individually today; clear_link drops a link’s whole table and restores its space, which is the self-heal a (re)connect already performs.
- Parameters:
link – This node’s NAME for the link the label is scoped to.
- Returns:
A label unique among this link’s currently allocated labels, or 0 when the link’s 16-bit space is exhausted — callers MUST treat 0 as “cannot compact” and fall back to the full-route form, never stamp it on a frame.
-
void clear_link(std::string_view link)¶
Drop ALL state (ingress, egress, allocator) for
link, AND every ingress binding on any OTHER link whose downstream half crossedlink— the self-heal hook.A transport calls this on (re)connect/disconnect of
linkso a subsequent re-advertise rebinds from a clean slate; a delivery on a now-cleared label is stale and is NACK’d rather than misrouted.The cross-link sweep is what makes that true on a MID-CHAIN node (#716). A forwarding binding is stored under the inbound link while
handle_binding_t::down_linknames the outbound one, so clearing onlylink'sown tables leaves an ingress binding elsewhere still pointing at an out-label that died with them. The upstream never saw the reconnect and so never re-advertises: it keeps streaming COMPACTs, this node keeps forwarding the dead out-label, the downstream keeps NACKing, and the NACK is answered from the very table that was erased — a permanent, silent drop of the whole flow. Erasing those bindings makes the upstream’s next COMPACT miss, which draws the ordinary stale-labelHANDLE_NACKand prompts the upstream to re-advertise; nothing new is put on the wire. Terminus bindings have no downstream half and are never swept.The sweep can only erase bindings that already EXIST, which is why it is only half the guard (#827): a forwarding swap still in flight on another rx thread is bound after the sweep has already scanned its inbound link. That half is bind_ingress_forward’s — this call advances the clear epoch link_epoch reports, so a swap minted against the tables erased here is refused rather than bound.
Cost is O(links x bindings) on this COLD (re)connect path; the per-delivery path is untouched.
- Parameters:
link – This node’s NAME for the link whose state to forget.
-
std::size_t ingress_count() const¶
Count of live ingress bindings (tests assert a non-compact flow holds 0).
-
std::size_t egress_count() const¶
Count of live egress (advertised) bindings.
-
std::size_t link_count() const¶
Count of live per-link table shells in the registry (diagnostic).
One shell per link name that currently holds compaction state. clear_link reclaims a departed link’s shell, so a workload that churns through many distinct link names returns here to its steady-state live-name count rather than growing unboundedly (#488). Tests assert this reclamation.
-
inline std::size_t refused_bindings() const noexcept¶
Count of bindings refused because a link’s table was at its bound, or because the injected source was exhausted (diagnostic).
ONE counter for both, because ADR-0079 makes them one fact: the store’s size IS a bound, so “the slab refused” and “the count refused” are the same operator-visible event — this node is delivering some flows over the full-route form. Splitting them would ask an operator to watch two counters for one symptom.
The counted-drop half of the bounded-resource contract (
can_reassembly_tkeepsdropped_groupsfor the same reason): a bound that silently discards work is indistinguishable from one that is never reached. A non-zero value here means some flows on this node are delivering over the full-route form rather than compacted — degraded throughput, never a wrong or missing delivery.
-
std::size_t labels_used(std::string_view link) const¶
Labels SPENT out of
link's16-bit space — used-polarity occupancy of the one resource this class cannot grow (#1503 finding 3).capacityfor this seam is the constant 65535 (see alloc_label): the space is per-link and fixed by the wire, so unlike every other bounded resource in the tree there is no injected ceiling to report beside it. Free is65535 - labels_used(link).The allocator is monotonic and saturating — labels are not reclaimed individually, so this only ever rises until clear_link forgets the link and restores its whole space. A value climbing toward 65535 on a long-lived link is the advance warning for labels_exhausted; reading it after the fact only tells you the degrade already happened.
- Parameters:
link – This node’s NAME for the link.
- Returns:
Labels issued on
link,0for a link that holds no compaction state, and 65535 once the space is spent.
-
inline std::size_t labels_exhausted() const noexcept¶
Times a mint was refused because a link’s 16-bit label space was SPENT (#1503 finding 3) — the previously silent degrade.
Distinct from refused_bindings, and deliberately not fused into it: that counter means “a link’s table was at its INJECTED bound”, which a deployment answers by sizing the table up. This one means the wire’s own 65535/link space ran out, which no amount of memory fixes — the answer is a reconnect (clear_link) or fewer distinct flows. One event, one counter, but only where an operator would act differently.
The degrade it makes visible is the one #1491 showed matters: a caller handed
0falls back to the full-route form, which REPLIES per frame — so throughput drops and the reply traffic returns, with nothing on the wire saying why.Counted, never enforced (
core/STYLE.md§Introspection): the library does not refuse or reconnect on its own account.
-
inline explicit route_handle_t(mem::block_source_t *src = &mem::heap_source(), std::size_t max_bindings_per_link = 0)¶
-
struct resolved_binding_t¶
The RESOLVED form of a binding — everything a steady-state COMPACT frame needs.
ADR-0062’s point, made concrete: an established flow must not re-derive what it already knows. This is trivially copyable and allocation-free, so route_handle_t::resolved can hand it out from under the link’s mutex without the
std::string+std::vectorcopylookup_ingresspays on EVERY frame today — a cost that landed before anything even looked at whether a resolution was cached.The route bytes are deliberately absent. A warm binding never touches them: the terminus dereferences
targetand writes; the forwarding hop sends ondown. They are needed only to RE-resolve, which is the cold path and keeps the owning accessor.Both cached forms are self-invalidating rather than notified — no callback fires from under a lock:
targetis paired withtarget_gen, compared againstgraph_t::retire_generation; a retired-and-revived vertex bumps it (#511), so a stale handle is detected on use (RFC-0009 §B.6 re-virginize).downis read from the registry SLOT, whoselinkteardown nulls in place (ADR-0063 made slot addresses permanently stable) — so a departed link readsnullptr: the same clean miss as an unresolved lookup. The tombstone IS the invalidation.
Public Members
-
bool found = false¶
false ⇒ no binding for this (link, label).
-
bool terminus = false¶
true ⇒ deliver locally; false ⇒ forward + swap.
-
bool warm = false¶
true ⇒ the resolution below is populated.
-
std::uint16_t out_label = 0¶
Forward: label to stamp downstream.
-
const void *down_slot = nullptr¶
Forward: cached registry slot.
-
std::optional<graph::vertex_handle_t> target¶
Terminus: the cached vertex.
std::optionalrather than a defaulted handle — ADR-0056 keepsvertex_handle_topaque and ALWAYS valid, so “no resolution yet” must be expressed outside the handle, not as an invalid one.
-
std::uint32_t target_gen = 0¶
Terminus: generation
targetwas resolved at.
-
std::uint32_t mount_gen = 0¶
The MOUNT-SHAPE generation this binding was resolved against (#765) — see handle_binding_t::mount_gen. Carried into the allocation-free view so the warm COMPACT path can validate it without taking the owning form.
-
struct handle_binding_t¶
One learned per-link label binding — what an inbound label means here.
Either a forwarding swap (rewrite to
out_labeland re-emit overdown_link) or a local terminus (resolvelocal_routeand apply the write). The trailing fields memoize what that resolution produced (ADR-0062), so an established flow stops re-deriving it; the allocation-free view of them is resolved_binding_t.A NON-OWNING descriptor (#603 defect 1, ADR-0079). It used to carry a
std::stringand astd::vector, which made it the structural reason this store could not leavestd::pmr: an entry holding those types is neither trivially copyable nor trivially destructible, somem::block_array_tcould not hold it and the tables stayed on a throwing allocator that a peer’s ADVERTISE reaches. The bytes are now the CALLER’s, borrowed for the duration of the route_handle_t::bind_ingress call, and the store copies them into blocks drawn from its injected tr::mem::block_source_t. That inverts the ownership so both halves get what they need: the caller can point at a decoded frame it already holds (no allocation at all), and the store’s copy fails by value instead of throwing.Warning
The views must outlive only the bind call. Nothing here is retained.
Public Members
-
bool terminus = false¶
true ⇒ deliver locally; false ⇒ forward + swap.
-
std::string_view down_link¶
Forward: this node’s NAME for the downstream link.
-
std::uint16_t out_label = {}¶
Forward: label to stamp on the downstream COMPACT.
-
std::span<const std::byte> local_route¶
Terminus: the local dst PATH TLV bytes to resolve + write.
-
bool warm = false¶
ADR-0062: the resolution, filled on first use and re-filled when it goes stale.
warm == falsemeans “never resolved”; the two cached forms carry their own staleness signal (see resolved_binding_t).true ⇒ the cached fields below are filled.
-
const void *down_slot = nullptr¶
Forward: cached
child_registry_t::child_t*.
-
std::optional<graph::vertex_handle_t> target¶
Terminus: the cached vertex.
-
std::uint32_t target_gen = 0¶
Terminus: generation
targetwas resolved at.
-
std::uint32_t mount_gen = 0¶
The mount-shape generation this binding’s SPLIT was decided against (#765).
The THIRD validate-on-use stamp, and it exists because the other two cannot see this hazard.
target_gencatches a retired-and-revived vertex; the slot tombstone catches a departed link. Neither catches the split MOVING: bind a label through mountnet/ws/s, then registernet/ws/s/rack, and a fullFWDresolves against the new, deeper mount while aCOMPACTriding this label still dereferences the binding made against the old one. Both targets are alive and both are what they always were — what changed is the point at which the address divides into “local mount” and “remote residual”, and the two planes now disagree about the same address with nothing reporting it.Until #523 the disagreement was unreachable, but only by accident: the descent capped its width, so a deeper mount was unroutable to BOTH planes. That is agreement by mutual failure, and it stopped holding the moment the width bound was lifted.
Compared against child_registry_t::mount_generation on use; a mismatch takes the SAME RFC-0004 §E.1 self-heal a stale label already takes — drop, fire the stale-label observer,
HANDLE_NACKupstream to prompt a re-advertise. No new error code, and no second invalidation mechanism (the objection that killed ADR-0062’s reverse index).
-
bool terminus = false¶
The FWD frame view¶
-
struct fwd_hdr_t¶
One top-level TLV header read in isolation (NO descent) — the byte offsets the zero-copy forward rebuild needs.
Kept as ABSOLUTE offsets into the source so the rebuild can re-slice src/payload as views (no copy). It is a thin ADAPTER over the ONE wire grammar (
grammar::parse_header, ADR-0048 §1): the length math is not mirrored here — this only turns the grammar’s relativeheader_tinto the absolutebody_off = pos + headerthe forward plane reads by. CRC is DEFERRED (the forward hop never walks a payload; the terminus / next hop verifies). One deliberate difference from the pre-grammar reader: the grammar rejects atype == 0x00or reserved-opt-bit header up front, so a malformed frame is dropped at this hop instead of forwarded — every caller already rejected such a header by its type check, so well-formed traffic is byte-identical.
-
struct fwd_pre_t¶
What a
dstpeek already learned about a FWD frame, so the head rebuild need not re-derive it (ADR-0038 inv. #1 — the forward hop parses each header ONCE).A forward hop used to walk the same TLV headers twice: the
dstpeek read the FWD header, the op VALUE, the dst PATH and every leading dst segment to decide where the frame goes — then rebuild_fwd_forward threw all of it away and re-read the identical bytes to build the outgoing heads. Profiling a 1-link hop put ~88% of it in header parsing, most of it that duplicate. Carrying the offsets forward is what removes it.Offsets, not spans, for the same reason the peeks are: the source may be a rope, so a caller re-slices from its own cursor. Filled by peek_fwd_dst; strip_at is filled by the CALLER once the mount descent has decided how many segments this hop consumes, since the peek runs before that is known.
Public Members
-
bool valid = false¶
False ⇒ nothing was learned; rebuild parses itself.
-
std::size_t body_end = 0¶
End of the FWD body.
-
std::size_t op_pos = 0¶
Offset of the op VALUE TLV.
-
std::size_t op_total = 0¶
Its total size.
-
std::size_t op_body_off = 0¶
Its body — read to test for REPLY.
-
std::size_t op_body_len = 0¶
Its body length. Carried rather than re-checked so the rebuild keeps its own
body_len == 0rejection: the peek does NOT reject an empty op (such a frame falls through to the terminus decode today), and making the peek stricter would silently turn a dropped frame into a terminus one.
-
std::size_t dst_body_off = 0¶
First byte of the dst PATH body.
-
std::size_t dst_end = 0¶
End of the dst PATH body.
-
std::size_t after_dst = 0¶
First byte after the dst PATH TLV.
-
std::size_t seg0_off = 0¶
The FIRST
dstsegment’s[body_off, body_len)— the gate’s own read, kept.peek_fwd_dst must parse this header anyway: “the leading child is a NAME” is the gate that decides a
dstis an address at all. It used to throw the parsed offsets away, and the descent immediately re-read the identical four bytes — one duplicatedparse_headeron EVERY forward hop, which is a per-frame cost the pre-lift peek did not pay (its one walk both gated and collected). Carrying the two integers forward removes the duplicate without moving the gate: the same header, read once, decides the same thing. Meaningless when valid is false.
-
std::size_t seg0_len = 0¶
Length of the first
dstsegment’s body.
-
std::size_t strip_at = 0¶
Where the surviving
dststarts after this hop consumes its leading segments — i.e. the end of segmentstrip_k - 1, or dst_body_off when nothing is stripped. Filled by the caller after the mount descent; leaving it 0 withvalidset would silently forward an unshrunk dst, so the rebuild treats astrip_atbelow dst_body_off as “not supplied” and walks the segments.
-
bool dst_ref = false¶
The
dstis aPATH_REF(0x14) — a BOUND address (RFC-0024 §4).Filled by peek_fwd_dst_ref and never by peek_fwd_dst, which gates on a canonical
PATHof NAMEs. It changes exactly two things in the rebuild: the shrunkdstheader is emitted as aPATH_REF(opt.PL = 0— the body is a fixed-stride record array, not child TLVs), and the shrink is an element rather than a run of segments. Everything else about a forward hop — the grownsrc, the selector, the payload, the egress gather — is identical, because a bound path changes how the address is SPELLED and nothing about what a hop does with the rest of the frame.
-
bool dst_to_path = false¶
Re-head the shrunk BOUND
dstas a canonical emptyPATHinstead of aPATH_REF— the reverse-list delivery’s LAST hop (RFC-0024 §7.1 amendment 1).Set only by the router’s session-delivery arm, where the consumed element was the final one and the egress is the accepted session itself: the peer behind it is an ORIGIN, which never speaks the bound form, so the frame it receives must be the canonical delivery shape byte-for-byte (
dst= an emptyPATH, exactly what the canonical mount descent leaves after stripping mount + peer). Meaningless unless dst_ref is also set.
-
wire::opt_t fwd_opt = {}¶
The outer FWD header’s decoded
optbits — the peek’s own read, kept (#1109).The rebuild needs them for exactly one thing: preserving the frame’s trailer-timestamp across the hop (
opt.TS/opt.TFname the trailer window atbody_endthat the fresh head must re-claim and the gather must re-emit — without them the origin’s stamp is silently dropped at the first forwarder). Meaningless when valid is false.
-
bool valid = false¶
-
template<class Cursor>
class dst_seg_walk_t¶ A FORWARD-ONLY walker over a
dst’s leading NAME segments (#523).Hands out segment
ias[body_off, body_len)on demand. The mount descent walks adstTWICE by nature — once to fold the digest chain the registry scan filters on, once to CONFIRM the one candidate that survived it — so a purely forward walker re-parsed every header of the run per frame, and that showed up as a measured latency regression on theW <= 3shapes that already worked. The first kDstSegCacheSlots offsets are therefore remembered; past them a backwards ask resumes from the last cached one rather than from thedstbody, so even a very deep mount re-reads only the uncached tail.Its whole state is that fixed cache plus three integers — a CONSTANT, config-derived stack cost. That is what lets the mount width be unbounded: nothing here is sized by W, so the router’s stack frame does not grow with the deepest
dstit may ever see, and no deep-peek scratch has to be drawn from the injectedflatbackend either. (The measured alternative — a W-sized peek array — cost 592 B of rv32 stack at W=4 and 2912 B at W=33, per rope frame.)Offsets, not spans, for the reason every peek here uses them: the source may be a rope, so the caller re-slices from its own cursor.
It BORROWS the cursor, which must outlive it — and it is the only thing in this cluster that does. Every peek here (peek_fwd_dst, peek_fwd_first_dst_seg, rebuild_fwd_forward) consumes its cursor within the call and hands back offsets, so a caller may pass a temporary to any of them; this walker keeps reading through the cursor after the constructor returns, so a temporary there is a dangling read on the first at. Stated here because it was not, and because a caller cannot infer it from a signature that takes
const Cursor&like every other function on the page — the rvalue constructors below turn the mistake into a COMPILE error rather than a sanitizer finding.- Template Parameters:
Cursor – A grammar byte-source cursor (span or rope).
Public Functions
-
inline dst_seg_walk_t(const Cursor &cur, const fwd_pre_t &pre) noexcept¶
Walk the
dstwindowpredescribes, overcur.curis BORROWED — this object holds a pointer to it and reads through that pointer on every at, socurmust outlive the walk.preis copied (three integers), so it need not.
-
dst_seg_walk_t(Cursor&&, const fwd_pre_t&) = delete¶
A TEMPORARY cursor is refused at compile time (both value categories).
dst_seg_walk_t<span_cursor> w(span_cursor{frame}, pre);reads exactly like thepeek_fwd_dst(span_cursor{frame}, pre)one line above it and is the one spelling that is wrong: the temporary dies at the end of the full expression and every laterat()reads a dead stack slot. That is not hypothetical — it is what a test wrote and what ASan caught asstack-use-after-scopethroughread_packed_seg. Deleting these makes the shape unrepresentable instead of merely documented, at zero runtime cost; a caller with a temporary in hand names it first, which is what the three in-tree call sites already do.
-
dst_seg_walk_t(const Cursor&&, const fwd_pre_t&) = delete¶
The
constrvalue spelling of the same mistake — deleted for the same reason.
-
inline void prefill()¶
Segment
i's[body_off, body_len).Fill the inline cache NOW, in ONE tight loop.
The descent’s first act is to materialize the cached run, and doing it through
atmeant one out-of-line walk call per segment — a profile of the forward hop put a quarter of it there, because the header parse makes that half ofattoo large to inline. One call fills the whole run; every later ask is then the inlined cache half ofat.Purely an optimisation. Every answer is identical without it, and a
dstdeeper than the cached run is still walked on demand — which is exactly what makes this a CACHE and not the fixed peek window it replaced.- Return values:
std::nullopt – The
dsthas no segmenti— it ended, or the next child is not a NAME (a selector, say), which is where an ADDRESS stops.
-
inline std::optional<std::pair<std::size_t, std::size_t>> at(std::size_t i)¶
Segment
i's[body_off, body_len).- Return values:
std::nullopt – The
dsthas no segmenti— it ended, or the next child is not a NAME (a selector, say), which is where an ADDRESS stops.
-
inline std::optional<std::size_t> end_of(std::size_t i)¶
Where segment
iENDS — thestrip_atthe head rebuild wants.A packed record’s payload is its tail, so the consumed run ends at
body_off + body_lenof the last stripped segment. Returnsstd::nulloptif that segment does not exist. An escape record STEPPED OVER inside the run is inside[body, end_of(i))too, so the strip stays a single contiguous shrink.
-
struct control_head_t¶
A control frame (ADVERTISE / COMPACT / HANDLE_NACK) peeked off any cursor without a decoded tree (ADR-0055 §2).
Carries the
type, theu16label (child[0] VALUE, LE), and the[off, total)of child[1] — the route (ADVERTISE) / payload (COMPACT) sub-TLV, or{0, 0}for a bare-label HANDLE_NACK. Source-agnostic (offsets, not spans), so the caller re-slices from its own cursor (ADR-0053 ④b/⑥).
-
struct fwd_rebuild_t¶
The rebuilt forward-hop frame: fresh stack heads + the untouched source regions to interleave (ADR-0038 inv. #2 — ZERO heap on the forward hop).
Produced by rebuild_fwd_forward. Layout of the outgoing frame is
head1 · rem_dst · sel · head2 · src_body · tail, where every non-head region is an[off, len)window into the SOURCE cursor — the emit order is fixed by gather so the bytes a downstream child receives are byte-identical to the pre-extraction router.Public Functions
-
inline std::size_t ts_bytes() const¶
The preserved trailer timestamp’s WIDTH in bytes — 0 (none), 4 (narrow) or 8 (wide), as the producer spelled it (#1109).
The forwarder never chooses a width: it reports the one ts_window recorded from the inbound head, and rebuild_fwd_forward sets that word and the outgoing head’s TS/TF bits from the same
optin one place, so the emitted trailer and the header declaring it cannot disagree.
-
inline std::size_t ts_off() const¶
The window’s byte offset into the SOURCE cursor (bit 31 masked off).
-
inline bool ok() const¶
True ⇔ every head fits its stack buffer (else the caller drops).
-
template<class Cursor, class Push>
inline void gather(const Cursor &cur, Push &&push) const¶ Emit the outgoing frame’s regions, in wire order, through
push.Written ONCE over the cursor seam: each source region is emitted via
for_each_span, which yields exactly one sub-span for a contiguous source and one per straddled link for a rope — so only the caller’s iov container varies (a stack array for the span path, a pmr vector for the rope path). At most kFwdMaxIov regions for a contiguous source — see that constant for the region-by-region count. (This line previously said “at most 6”, which omittedhead2,mount_tlvand the peer pair.)- Template Parameters:
Cursor – A grammar byte-source cursor (span or rope) — the SAME source rebuild_fwd_forward read the offsets from.
Push – Callable taking one
std::span<const std::byte>.
Public Members
-
stack_writer<kFwdHead1Cap> head1¶
FWD header + op (copied) + shrunk dst header.
-
stack_writer<kFwdSrcHdrCap> head2¶
The grown src PATH header.
-
std::span<const std::byte> mount_tlv¶
The inbound mount as ALREADY-ENCODED packed records, emitted as ONE span and never copied. Precomputed once per child (#508), so a hop does no per-segment work.
-
std::uint32_t ts_window = 0¶
The inbound frame’s trailer-TIMESTAMP window, re-emitted VERBATIM as the outgoing frame’s last bytes (#1109) — offset in bits 0-30, FORM in bit 31.
Zero ⇒ the frame carried no stamp, unambiguously: a trailer sits past a TLV’s own 4-byte header, so no stamped frame has a window at offset 0. Bit 31 (kTsNarrow) is the
opt.TFform the PRODUCER chose and this hop relays rather than picks — clear = the WIDE absolute stamp (8 bytes), set = the NARROW relative one (4). Read it through ts_off and ts_bytes, never raw. The CRC half of an inbound trailer is NOT here and never will be: the rebuilt body invalidates it, so it is dropped rather than forwarded stale (see stack_writer::header).One 4-byte word here, rather than an offset and a width at the end of this struct, is MEASURED, not tidiness (#1235). It occupies the alignment hole after extra_hdr, which keeps
sizeof(fwd_rebuild_t)at 256. The twostd::size_tfields this replaced pushed it to 272, and that alone — with the members NEVER READ, the ablation that proved it — costfwd-demux-fixed 79B/fan1/1epp50 +9.6% / throughput −9.3% on the pinned host. A 264-byte intermediate (twostd::uint32_ts) still cost +9.5%, so the step is at 256 and it is the whole object’s size that matters, not the field count. Packing the form INTO the word rather than deriving it from the emitted head is measured too: the derived form left the rope hop’sroute_fwd_forward16 B larger and the demux row 5 ns short of the parent, where this shape returns both to it.A frame whose body ends past kTsNarrow cannot express its window here, so the rebuild drops the stamp AND its header bits together rather than declaring a trailer it will not emit — a 2 GiB single TLV, which no shipped transport will carry.
-
std::string_view extra_seg¶
One dynamically-named trailing mount segment — a bus PEER, whose name is not known until the frame arrives and so cannot be precomputed. Empty means none; referenced, not copied, so it must outlive gather.
-
std::size_t rem_dst_off = 0¶
Remaining dst body after the stripped segment.
-
std::size_t rem_dst_len = 0¶
Length of the remaining dst body.
-
std::size_t sel_pos = 0¶
The optional FIELD selector TLV; 0 len ⇒ none.
-
std::size_t sel_total = 0¶
Total bytes of the selector TLV.
-
std::size_t src_body_off = 0¶
The original src PATH body.
-
std::size_t src_body_len = 0¶
Length of the original src body.
-
std::size_t tail_off = 0¶
Bytes after src (payload etc.).
-
std::size_t tail_len = 0¶
Length of the tail region.
-
stack_writer<4 + wire::kPathRefElementBytes> mint¶
This hop’s contribution to a mint answer: a fresh
PATH_REFheader plus ONE 8-byte element (RFC-0024 §7.1 step 2). Empty ⇒ this frame carries no mint.Written only on a forwarded REPLY whose last child is already a
PATH_REF. The element goes FIRST in the new body, ahead of ref_body_off — the elements the hops further out have already contributed — because the list is origin-first and this hop is nearer the origin than every host that has touched the reply so far. That is the mirror of the waysrcaccumulates on the way in (RFC-0004 §B), and it is a rope operation on the egress rather than a rewrite: the existing elements are referenced, never copied.
-
std::size_t ref_body_off = 0¶
The trailing
PATH_REF’s existing element array.
-
inline std::size_t ts_bytes() const¶
-
template<std::size_t N>
class stack_writer¶ A fixed-capacity stack byte-writer — the zero-heap head builder for the forward hop (ADR-0038 inv. #2).
The zero-heap counterpart of the old vector-based header builder: “the fresh
header bytes … a stack std::array, not a std::vector”. Bounded by the wire header widths + one NAME (kMaxSegmentBytes), so
Nis a small compile-time constant; a write past capacity clamps to empty (the caller treats an empty head as a drop — never a buffer overrun).- Template Parameters:
N – The writer’s stack capacity in bytes.
Public Functions
-
inline void header(wire::type_t type, std::size_t body_len, wire::opt_t trailer = {})¶
Append a structured TLV header (
plset,llauto-widened) forbody_len.trailercontributes its TS/TF bits alone (#1109) — the builder can now EXPRESS a trailer, in either form, so a forwarded frame’s origin stamp survives the head rebuild (the caller that sets them owns emitting the trailer bytes after the body). CR never crosses: a rebuilt body invalidates any inbound CRC by construction, so preserving the bit would mint a frame its own receiver rejects ascrc_fail.
-
inline void header_bare(wire::type_t type, std::size_t body_len)¶
Append a BARE TLV header (
opt = 0) forbody_len— aPATH_REF’s own shape.Separate from header, which sets
opt.PLbecause every header it writes frames child TLVs. APATH_REFbody is a fixed-stride record array, soPL = 1would make a generic walker read the first four body bytes as a TLV header and mis-frame the whole body — the rule is a MUST (RFC-0024 §4.2), not a preference.LLis never set either: the element bound caps the body at 2040 bytes, so a body needing a u32 length cannot be reached, and abody_lenthat claims otherwise overflows rather than widening.
-
inline void header_path(std::size_t body_len)¶
Append a
PATHheader forbody_len—opt.PL = 0,LLauto-widened.Its own method rather than header, because a packed
PATHbody is NOT a child run:opt.PL = 1would make a generic walker read the first body bytes as a TLV header and mis-frame the whole address (RFC-0018 §5 — the same MUST that header_bare states forPATH_REF). And not header_bare either, because that one refuses to widen: aPATHbody may legally pass 0xFFFF, where aPATH_REFbody cannot.
-
inline void path_seg(std::string_view s)¶
Append one packed PATH segment record over
s([u8 len][bytes], RFC-0018). An emptyswould spell the §5.4 escape, so it overflows rather than mints.
-
inline void raw(std::span<const std::byte> bytes)¶
Copy opaque
bytesverbatim (the op TLV).
-
inline std::span<const std::byte> span() const¶
The written bytes.
- Return values:
empty – A write overflowed
N— the caller must drop the frame.
-
inline bool ok() const noexcept¶
False ⇔ a write overflowed
N.
-
template<class Cursor>
std::optional<fwd_hdr_t> tr::net::read_fwd_header(const Cursor &cur, std::size_t pos, wire::grammar::crc_check_t crc = wire::grammar::crc_check_t::DEFER)¶ Read ONE TLV header at absolute offset
posofcur(no descent).Templated over the grammar
Cursorconcept (ADR-0053 ④b): the forward plane reads its dispatch offsets through the SAME byte-source seam the one grammar validates through —span_cursorfor the contiguous path, the rope cursor for a scatter-gather frame, with no per-cursor offset math.cur.region(pos, …)narrows either source in O(1) before the header parse.- Template Parameters:
Cursor – A grammar byte-source cursor (span or rope).
- Parameters:
cur – The cursor positioned at the frame’s first byte.
pos – Absolute offset of the header to read.
- Return values:
std::nullopt –
posis out of range or the grammar rejects the header.
-
enum class tr::net::fwd_dst_kind_t : std::uint8_t¶
Which of the two routable
dstforms a FWD frame carries.The two forms are mutually exclusive by the
dst’s own type code, and telling them apart is ONE read of the frame’s three leading headers — so it is one function that answers, not two gates run in sequence. Running them in sequence is what put a whole second header walk on every bound frame (a shipped shape once RFC-0024 lands) while buying the canonical form nothing at all.Values:
-
enumerator NONE¶
Not a structured FWD, or a
dstin neither routable form.
-
enumerator PATH¶
A canonical
PATHof NAMEs — the mount descent’s address.
-
enumerator PATH_REF¶
A BOUND address (RFC-0024 §4) — a fixed-stride element array.
-
enumerator PATH_LABEL¶
A canonical
PATHwhose FIRST record is an escape — RFC-0027’s labelled address.Told apart from the plain
PATHanswer because the two take different routes and must not be confused: a canonicalPATHopens with a NAME the mount descent folds a digest over, and this opens with a record that is not a name at all. Descending it would read a peer-supplied slot index as UTF-8.A node that does not implement minting treats it exactly as it treated a non-NAME leading child before RFC-0027 existed — it names no mount here, so the frame falls to the terminus arm and is refused there. That is why this is a distinct answer rather than a flag on the plain
PATHanswer: the ONE branch a non-minting node takes on it is the one it already took, and it takes it without a table, without a lookup, and without reading the record.
-
enumerator NONE¶
-
template<class Cursor>
fwd_dst_kind_t tr::net::peek_fwd_dst_any(const Cursor &cur, fwd_pre_t &pre, std::size_t &ref_count)¶ Open the
dstwindow of a FWD frame and say WHICH form it is — the routing gate.Fills
prewith everything the descent, the bound hop and the head rebuild need about the frame’s structure: where the op VALUE and thedstbody are, and where the body ends. It reads NO segments and materializes nothing, so its cost and its stack are the same whatever thedst’s depth or element count.The two arms diverge only at the
dstheader’s type code:PATH— the canonical address, a packed record run withopt.PL = 0(RFC-0018). The leading record must be a LITERAL segment (adstwhose first record is the label escape is not an address this node can descend), and fwd_pre_t::strip_at starts at the body because onlystrip_kcan say how much of it this hop consumes.PATH_REF— the bound address. The four STRUCTURAL rules (opt.PL = 0,opt.LL = 0,length % 8 == 0,length <= 2040) are checked throughtr::wire::path_ref_body_valid— the one locus that owns them — and fwd_pre_t::strip_at is known HERE, past element 0: each hop consumes exactly one element (§4.1), with no descent to wait for.
Note
flatten— see rebuild_fwd_forward for the measurement. The four read_fwd_header calls below are this function’s whole body, and each returns a ~56-bytestd::optional<fwd_hdr_t>that an out-of-line call must return through memory.- Template Parameters:
Cursor – A grammar byte-source cursor (span or rope).
- Parameters:
cur – The cursor positioned at the frame’s first byte.
pre – Filled on every non-
NONEanswer; reset withvalid = falseotherwise, so a caller cannot pass stale offsets to the rebuild.ref_count – Written with the
PATH_REFelement count on thePATH_REFanswer, 0 otherwise. 1 is the terminus (the residual is this node’s own reference to the target vertex); **> 1 is a forwarder hop**; 0 is a route with no hops, which the codec deliberately admits and the router refuses.
-
template<class Cursor>
bool tr::net::peek_fwd_dst(const Cursor &cur, fwd_pre_t &pre)¶ Open the
dstwindow of a FWD frame — the mount descent’s gate, read by OFFSET.The canonical arm of peek_fwd_dst_any, for a caller that routes only the canonical form (the unit tests and
bench_forward_demux). Fillsprewith everything the descent and the head rebuild need about the frame’s structure: where the op VALUE and thedstPATH body are, and where the body ends. It reads NO segments and materializes nothing, so its cost and its stack are the same whatever thedst’s depth — the point of #523. Segments are then walked lazily through dst_seg_walk_t, one at a time, only as far as the registry actually asks.This replaces
peek_fwd_dst_segs, which eagerly filled akMountPeekMax-sized array of offsets. That array was the width bound’s last physical residue: it decided in advance how many segments the descent could ever look at, and sizing it by the widest mount would have put a W-sized array on every rope frame (measured on rv32: 592 B of stack at W=4, 2912 B at W=33). Nothing here is sized by a width at all.- Template Parameters:
Cursor – A grammar byte-source cursor (span or rope).
- Parameters:
cur – The cursor positioned at the frame’s first byte.
pre – Filled on success; reset with
valid = falseon every failure, so a caller cannot pass stale offsets to the rebuild.
- Return values:
false – Not a structured FWD with an op VALUE, a non-empty
dstPATH, and a leading NAME segment — the caller falls through to the terminus/control arms exactly as it did on the oldn == 0. A well-formed BOUNDdstis among the false answers, andpreis cleared for it too.
-
template<class Cursor>
std::optional<std::size_t> tr::net::peek_fwd_dst_ref(const Cursor &cur, fwd_pre_t &pre)¶ Open the
dstwindow of a BOUND FWD frame — the bound hop’s gate (RFC-0024 §5).The bound arm of peek_fwd_dst_any, for a caller that has only the bound question to ask (the conformance and unit tests). The router asks BOTH questions at once, because a frame is one form or the other and finding out twice is a second header walk for nothing.
Fills
preas peek_fwd_dst_any does on itsPATH_REFanswer, plus fwd_pre_t::dst_ref, and sets fwd_pre_t::strip_at past element 0 — the ONE element this hop consumes (§4.1: each hop consumes element 0 and forwards the remainder, the same monotone shrink the canonicaldstperforms, which is why a bound path is loop-free by construction and needs no visited set).The four STRUCTURAL rules (
opt.PL = 0,opt.LL = 0,length % 8 == 0,length <= 2040) are checked throughtr::wire::path_ref_body_valid— the one locus that owns them — so a frame that fails any of them is not a bound address and falls through to the caller’s terminus arm, where the resolver refuses it as it refuses every other malformeddst.- Return values:
std::nullopt – Not a structured FWD whose
dstis a structurally validPATH_REF.- Returns:
The element count on the wire. 1 is the terminus (the residual is this node’s own reference to the target vertex); **> 1 is a forwarder hop**; 0 is a route with no hops, which the codec deliberately admits and the router refuses.
-
template<class Cursor>
wire::path_ref_element_t tr::net::read_path_ref_element(const Cursor &cur, std::size_t off)¶ Read the 8-byte
PATH_REFelement atoffthrough the cursor seam.Byte-wise rather than through
tr::wire::path_ref_element_at, because on the rope tier an element may straddle a link boundary and there is then no span to hand that function. Eightbyte_atcalls need no scratch, no stitch slot and no flatten, which is the property that lets a bound hop stay allocation-free on a fragmented frame; the codec’s own reader stays the one that serves a contiguous body.Note
Precondition:
off + 8is inside the frame — the caller has already had the body shape settled by peek_fwd_dst_ref and knows the element count.
-
struct trailing_mint_t¶
The trailing bound-path child a forwarded frame carries — the mint list so far, in whichever direction the caller asked for.
See also
Public Members
-
std::size_t pos = 0¶
Offset of the
PATH_REFchild’s own header.
-
std::size_t body_len = 0¶
Length of the element array already on the wire.
-
bool can_contribute = false¶
False ⇔ the list is at the normative element cap and one more would not be spellable, so this hop MUST strip it rather than relay it (§7.1 erratum 1).
-
std::size_t pos = 0¶
-
struct no_mint_t¶
The mint supplier of a hop that contributes nothing — rebuild_fwd_forward’s default, and the shape every caller outside the router has.
A callable rather than a pointer so the router’s own supplier can be a closure that runs ONLY when the frame turns out to carry an extendable mint answer. Returning
nullopthere does not relay the answer: the rebuild STRIPS it, which is the §7.1 erratum-1 rule.Public Functions
-
inline std::optional<wire::path_ref_element_t> operator()() const noexcept¶
Nothing to give.
-
inline std::optional<wire::path_ref_element_t> operator()() const noexcept¶
-
template<class Cursor>
std::optional<trailing_mint_t> tr::net::peek_trailing_mint(const Cursor &cur, std::size_t from, std::size_t end, wire::type_t want = wire::type_t::PATH_REF)¶ Where a forwarded frame’s trailing mint list sits, if it carries one (RFC-0024 §7.1).
A mint list rides its frame as the LAST child, so a hop that wants to contribute its own element looks exactly there and nowhere else. The presence of that child IS the signal that the origin asked for a mint — a hop holds no per-flow state and has nothing else to read it from, which is what keeps the accumulation stateless.
The two directions are told apart by TYPE, never by position (RFC-0024 §7.1 amendment 2): the forward mint ANSWER on a reply is a
PATH_REF(0x14), the REVERSE list on a mint-flagged request aPATH_REF_REVERSE(0x15).wantis that discriminant, and it is free: the loop below already compares each tail child’s type byte, so asking for the other constant is the same compare. A positional rule (“the only trailing child”) would have cost the same here and foreclosed a rawPATH_REFpayload on a mint-flagged WRITE, which is why the type carries the role.**
wantis a RUNTIME parameter, and that is measured, not stylistic.** As a template parameter it reads better and folds to an immediate — and it costs +14% on the fixed forward hop and +23% on the 64-link demux scan (bench_forward_demux, reproduced againstmain), because two instantiations stop being one shared out-of-line function and get inlined into the twonoinlinemint helpers instead, which repartitions theflattenedrebuild_fwd_forwardthe whole demux path runs. This is exactly the hazard the mint helpers’ ownnoinlinenotes describe. One shared copy, one register argument: the pre-amendment code shape, and level with it.FINDING the answer and being able to ADD to it are two different answers, and the caller needs both: a hop that finds a list it cannot extend MUST STRIP it (§7.1 erratum 1), never relay it. A relayed list that skips a hop is not a shorter route, it is a WRONG one — see the strip branch in rebuild_fwd_forward for the mis-route it produces. Reporting a full-cap list as “not found” would take exactly that forbidden branch, so the cap rides back as trailing_mint_t::can_contribute rather than as a
nullopt.- Parameters:
cur – Cursor over the frame.
from – First byte after
src— where the frame’s trailing children begin.end – End of the FWD body.
want – The mint list’s type:
PATH_REFon a reply,PATH_REF_REVERSEon a mint-flagged request (RFC-0024 §7.1 amendment 2). A RUNTIME parameter, and deliberately so — see the note above on why a template one is not free here.
- Return values:
std::nullopt – No trailing child of type
wantat all, or a malformed tail. The frame carries no mint exchange in this direction and is forwarded untouched.
-
template<class Cursor, class MintFn>
std::size_t tr::net::rebuild_reply_mint(const Cursor &cur, std::size_t pos, std::size_t body_end, MintFn &mint_fn, fwd_rebuild_t &r)¶ The forward hop’s stack object stays at or under 256 bytes — a MEASURED ratchet (#1235), not a style rule.
This object is built on the stack of every forwarded frame, and its size is load-bearing on the shipped fast path: growing it to 272 by adding two
std::size_tfields costfwd-demux-fixed 79B/fan1/1epp50 +9.6% / throughput −9.3% on the pinned host, and an ablation that added the same 16 bytes and NEVER READ THEM cost exactly the same — so the price is the size, not the work. A 264-byte intermediate was still +9.5%. The regression shipped for 16 samples because it walked under every PR-gate threshold; this assert is what makes the next such growth a compile error instead. A field that will not fit belongs in an alignment hole, or packed into a word that is already there (see fwd_rebuild_t::ts_window).The mint accumulation half of a forwarded REPLY’s rebuild (RFC-0024 §7.1 step 2), deliberately kept OUT OF LINE.
noinlineagainst rebuild_fwd_forward’sflatten, and it is a measurement, not a preference.flattenpulls everything a function calls into it, so inlined this dragged peek_trailing_mint’s header loop into the rebuild’s body — on the branch a REQUEST hop never takes. The extra front end that bought costbench_forward_rope+13% at fan 2 in branch mispredicts (3x armA’s count underperf stat, on ~equal instructions): a shipped shape paying for a form its frames cannot be. Out of line, the request hop sees one not-taken branch and the rope arm measures at or belowmainat every fan.Writes
r'stail and mint fields;- Returns:
the bytes this hop’s element adds to the body, or 0 when it contributes nothing — which INCLUDES the strip cases (no element to give, or a list already at the cap). A reply with no mint answer at all leaves
runtouched.
-
template<class Cursor, class MintFn>
std::size_t tr::net::rebuild_request_reverse_mint(const Cursor &cur, std::size_t pos, std::size_t body_end, MintFn &mint_fn, fwd_rebuild_t &r)¶ The REVERSE-direction mint on a forwarded mint-flagged REQUEST (RFC-0024 §7.1 amendment 1) — the request-side mirror of rebuild_reply_mint, equally OUT OF LINE and for the same measured reason (its
noinlinenote applies verbatim: the tail walk must not be flattened into the hop every unflagged frame runs).Three outcomes, all normative:
Extend. The request’s last child is already a
PATH_REF_REVERSE(0x15, the reverse list’s OWN type since RFC-0024 §7.1 amendment 2 — never a position) andmint_fnyields this hop’s element for the identity the frame ARRIVED on — its connection vertex for a point-to-point link, or the accepted session’s identity vertex for a bus session. The element is PREPENDED (the list runs responder-first), exactly the fwd_rebuild_t::mint machinery the reply side uses.Create. No reverse child yet — this is the FIRST forwarding hop (the origin never emits the child) — and
mint_fnyields an element: a fresh one-elementPATH_REF_REVERSEis appended as the new last child. “A hop with no reverse child yet
MAY create it”; the reference core participates.
Strip. A reverse child exists but this hop cannot contribute (no identity vertex, a saturated generation, a full list): the WHOLE child is removed. Erratum 1’s rule direction-reversed and equally forced — a list that skips a hop is a wrong route, the §5.3 mis-route class, so it is all-or-nothing over the reverse list alone.
The unflagged request never reaches here (the caller gates on op bit 7), so the ordinary forward hop pays one not-taken branch, exactly as it does for the reply mint.
Writes
r'stail and mint fields;- Returns:
the bytes this hop’s element adds to the body (an extension adds the element; a creation is billed through the same fwd_rebuild_t::ref_body_len = 0 accounting), or 0 for strip/no-op.
-
template<class Cursor>
std::optional<std::pair<std::size_t, std::size_t>> tr::net::peek_fwd_first_dst_seg(const Cursor &cur)¶ The forward dispatch decision, read by OFFSET with no allocation (ADR-0038 inv. #1, ADR-0039).
A FWD whose first
dstsegment names a transport child is a forward hop that never needs the decoded tree. Returns the[body_off, body_len)of the first packed dst-segment record iff the frame is a structured FWD with an op VALUE + a non-empty dst PATH; nullopt otherwise (malformed, non-FWD, or empty dst ⇒ the caller falls back to the full-decode terminus path). Offsets, not a span, so the result is source-agnostic — the caller re-slices the segment bytes from its own cursor (contiguous or rope).- Template Parameters:
Cursor – A grammar byte-source cursor (span or rope).
- Parameters:
cur – The cursor positioned at the frame’s first byte.
-
template<class Cursor>
std::optional<graph::fwd_op_t> tr::net::peek_fwd_op(const Cursor &cur)¶ Read the FWD op discriminant (child[0], a VALUE u8) by OFFSET.
The terminus split (REPLY → originator sink vs request → arena resolve) without a decode.
- Template Parameters:
Cursor – A grammar byte-source cursor (span or rope).
- Parameters:
cur – The cursor positioned at the frame’s first byte.
- Return values:
std::nullopt – Not a structured FWD, or its op VALUE is missing/empty.
-
template<class Cursor>
std::optional<control_head_t> tr::net::peek_control(const Cursor &cur, wire::grammar::crc_check_t crc = wire::grammar::crc_check_t::DEFER)¶ Peek a control frame’s head (type + label + child[1] window) by OFFSET.
- Template Parameters:
Cursor – A grammar byte-source cursor (span or rope).
- Parameters:
cur – The cursor positioned at the frame’s first byte.
- Return values:
std::nullopt – Malformed, or not a structured ADVERTISE / COMPACT / HANDLE_NACK leading with a ≥2-byte VALUE label.
-
template<class Cursor, class MintFn = no_mint_t, class ReverseMintFn = no_mint_t>
std::optional<fwd_rebuild_t> tr::net::rebuild_fwd_forward(const Cursor &cur, std::span<const std::byte> mount_tlv, std::string_view extra_seg, std::size_t strip_k, const fwd_pre_t *pre = nullptr, MintFn mint_fn = MintFn{}, ReverseMintFn reverse_mint_fn = ReverseMintFn{}, std::span<const std::byte> reply_label = {})¶ The forward hop’s head rebuild, read entirely by OFFSET — no decoded tree (ADR-0038 inv. #1).
Layout:
FWD{ op VALUE, dst PATH, FIELD? sel, src PATH, tail }— stripsstrip_kleading dst segments (shrink), grows src byinbound_mount(unless the op is REPLY: a reply accumulates no return route, RFC-0004 §B), and synthesizes the two fresh stack heads. The caller scatter-gathers the result via fwd_rebuild_t::gather — no payload copy, zero heap.strip-K and the symmetric return route (ADR-0061 + its erratum). A mount is addressed by its full path
/net/<module>/<name>[/<peer>], so a hop consumes K segments rather than one, andsrcgrows by that SAME run — not by a single NAME. Growing by a bare name would make the return route ambiguous the moment connection names are per-module-scoped (/net/ws-client/foovs/net/tcp-client/foo), because a reply’sdstIS the accumulatedsrc. Prepending the whole mount keeps routing-address==vertex-path in BOTH directions, so a reply resolves through the identical descent as a forward.This is the one region a REPLY’s
srcmay grow by, and it exists because 6.1 requires the first reply to reach the original sender already minted: a hop’s local part is stripped from the reply’sdstand echoed nowhere else, so a label that replaced it would otherwise have no way home. What is prepended is strictly SHORTER than the string run it stands for (7 bytes against a mount run’s 10 and up), which is 6.1’s “replaces, never appends” in the accounting 6.1 itself uses — the comparison is against the string spelling of the same accumulation, not against a reply that accumulates nothing.It narrows RFC-0004 B’s “a reply accumulates no return route” to NON-MINTING hops, which is a normative tension RFC-0027 6.1 already decided at acceptance and which is recorded as an erratum in that RFC’s log rather than assumed here. Empty is the default and the conformant behaviour, so a node that never mints is byte-unchanged.
Note
A returned rebuild may still have
!ok()(an oversized op TLV overflowed a head) — the caller must check and drop, never overrun.Note
**
flatten, and it is measured.** read_fwd_header returnsstd::optional<fwd_hdr_t>— six words — so an OUT-OF-LINE call returns it through memory and the caller re-loads every field. Inlined it is registers. Which way the compiler goes is a budget decision it makes per caller, and it flipped the wrong way for the three header readers on the forward hop once the descent’s cold arm was moved out of line andon_frame_implshrank: aperfprofile of aW = 3hop put 58% of it inside an out-of-lineread_fwd_header, against 4% on the pre-lift build where the same calls were inlined.flattenon the three functions that read headers in a loop (here, peek_fwd_dst, and the router’sresolve_mount_at) is worth 6-8 ns per hop across everyWmeasured. It is deliberately NOTalways_inlineon read_fwd_header itself: that inlines it into the cold control-frame and terminus paths too and measured +16 to +24% — strictly worse than doing nothing.- Template Parameters:
Cursor – A grammar byte-source cursor (span or rope).
- Parameters:
cur – The cursor positioned at the inbound FWD frame’s first byte.
mount_tlv – This node’s mount path for the link the frame arrived on, ALREADY ENCODED as a run of NAME TLVs (precomputed per child, #508).
extra_seg – One further mount segment whose name is only known now — a bus PEER. Empty when the mount is fully precomputed.
strip_k – How many leading dst segments this hop consumes.
pre – The offsets the routing peek already read, or nullptr to re-parse.
reply_label – RFC-0027 6.1’s minted spelling of THIS hop’s own local part, as the already-encoded 7-byte label element, or empty to mint nothing. already-encoded 7-byte label element, or empty to mint nothing.
mint_fn – This hop’s mint contribution, supplied LAZILY: invoked at most once, and only on a forwarded REPLY that actually carries an extendable mint answer. Laziness is the whole point — deciding eagerly meant reading the op byte a second time on EVERY forwarded frame, including the request hops that can never mint, and that duplicate read is a rope-cursor byte walk on a fragmented frame. Defaults to no_mint_t, the hop that contributes nothing.
- Return values:
std::nullopt – The frame is not a well-formed forwardable FWD (wrong type/shape, or fewer than
strip_kdst segments) — the caller falls to its terminus path.
-
template<class Cursor>
std::optional<fwd_rebuild_t> tr::net::rebuild_fwd_forward(const Cursor &cur, std::string_view inbound_name)¶ Single-NAME convenience overload — a flat, one-segment mount (strip-1).
The pre-ADR-0061 shape, kept for callers whose link identity is a bare NAME.
-
inline std::optional<std::vector<std::byte>> tr::net::encode_mount_tlv(std::span<const std::string_view> segs)¶
Encode
segsas a run of packed PATH records — the precomputed mount prefix (#508).Built ONCE per child, at registration, and handed to every hop as fwd_rebuild_t::mount_tlv. Under RFC-0018 there is only ONE form to emit — a record is
[u8 len][bytes]with no option byte — so the ADR-0062 §”Considered options” caveat this used to carry (a peer may legally spell the same NAME withopt.LL = 1, so emitting and matching are different problems) simply no longer applies: emitting and matching are now the same bytes.- Returns:
The encoded run, or nullopt if a segment is empty (it would spell the §5.4 escape) or exceeds the record’s
u8length field.
-
constexpr std::size_t tr::net::kDstSegCacheSlots = graph::kCacheLineBytes / sizeof(std::pair<std::size_t, std::size_t>) < 2 ? std::size_t{2} : graph::kCacheLineBytes / sizeof(std::pair<std::size_t, std::size_t>)¶
A FORWARD-ONLY walker over a
dst’s leading NAME segments (#523).Hands out segment
ias[body_off, body_len)on demand and remembers where it stopped, so the descent’s natural ascending access pattern (0, 1, 2, …) costs ONE walk of the headers however many slots ask. A request for an index BEHIND the cursor restarts from thedstbody — which happens only when a narrower registry slot is tested after a wider one, and costs a handful of 4-byte header reads.Its whole state is three integers. That is what lets the mount width be unbounded: there is no array to size, so the router’s stack frame does not grow with the deepest
dstit may ever see, and no deep-peek scratch has to be drawn from the injectedflatbackend either.Offsets, not spans, for the reason every peek here uses them: the source may be a rope, so the caller re-slices from its own cursor.
How many segment offsets a dst_seg_walk_t keeps inline: ONE CACHE LINE’s worth.
NOT a width bound and not a new constant to raise — it is a CACHE. The walk is correct, and gives the same answers, at any width with any value here (including the structural floor of two); past the cached run it simply re-reads headers. Nothing about which mounts resolve depends on it, which is exactly what
kMountPeekMaxcould not say.Sized from
tr::kCacheLineBytes, the config quantity a target already declares (ADR-0068 §3;-DLIBTRACER_CACHE_LINE_BYTES), because the whole point is that the cached run costs no extra line fetch. A config that declares no cache line (0, the single-core profile) still gets the floor of TWO — the two the descent structurally needs, since it reads segmentkto see whether the address continues and then asks where segmentk-1ended.- Template Parameters:
Cursor – A grammar byte-source cursor (span or rope).
-
constexpr std::size_t tr::net::kFwdHead1Cap = 64¶
Capacity of the forward hop’s first head: FWD hdr(≤6) + op TLV(small) + PATH hdr(≤6).
-
constexpr std::size_t tr::net::kFwdSrcHdrCap = 6¶
Capacity of the forward hop’s second head: the grown src PATH header alone.
-
constexpr std::size_t tr::net::kFwdMaxIov = 10¶
Upper bound on the regions fwd_rebuild_t::gather emits for a CONTIGUOUS source.
Structural, and now counted from fwd_rebuild_t::gather’s actual emit sequence rather than budgeted — one region per
push, in wire order:head15.mount_tlv(ONE span, whatever the mount’s width)rem_dst6.extra_hdr_ at most one PAIR, for a dynamicallysel7.extra_seg/ named bus peerhead28.src_bodytailtrailer TS (the preserved stamp window, #1109)
A rope source may split any region further and so gathers into a growable container instead; this bound is the CONTIGUOUS arm’s, and only that arm uses a stack array.
It previously read
6 + 2 * kMountPeekMax= 14, describing a header-and-bytes pair per prepended mount segment. That emission has not happened since #508, which made the mount run one precomputed span — so the constant was over-provisioned by 5 and, worse, was the wrong SHAPE: tied to mount width when the region count has been independent of it for some time.The shape mattered. Had the 2026-07-30 mount-depth ruling been implemented by re-deriving this as
6 + 2 * depth, it would have crossed 17 at depth 6 — and 17 is exactly where both shipping transports fall back to a heap-allocated iovec table (transport_udp.cpp,transport_tcp.cpp,kMaxInlineIov = 16; measured bybench_transport_iov). That would have put a per-frame allocation on every deep-mount forward hop whilebench_forward_heapstill reportedallocs=0, because that gate drives a stub link which never assembles an iovec.At 10 the headroom to the transport spill is 7 regions. Keep the mount one span and this constant does not move when the descent is uncapped.
The bound-path mint accumulation (RFC-0024 §7.1) does not move it either, and this is counted rather than assumed. A forwarded REPLY’s mint adds two regions — this hop’s 12-byte head-plus-element, and the elements already on the wire — but a REPLY grows no
src, so the mount run and the bus peer’s header-and-segment pair (regions 5-7) are empty on exactly the frames that use them. The two sets are mutually exclusive byis_reply: a REQUEST emits at most head1, remaining dst, selector, head2, mount, peer header, peer segment,srcbody, tail and trailer TS = 10; a REPLY at most head1, remaining dst, selector, head2,srcbody, tail, mint head, mint elements and trailer TS = 9. It was briefly raised to 11 by adding the request and mint sets together, which is a bound no frame can reach — and the constant is measured, not defensive: that change moved code placement enough to costbench_forward_ropea disjoint +13% at fan 2 in branch mispredicts, on a shape that emits none of the regions it was raised for. (The +1 here, by contrast, is a region a stamped frame really emits — the #1109 trailer-TS window, region 10 above.)
The /net connection surface¶
-
class transport_vertex_t¶
Groups connection vertices under
/netand makes each a/vertex (ADR-0027).Construct over a live graph::graph_t and
fwd_router_t. Registers aclientandlistenerchild type on the graph (via the #82register_child_typeseam) so an in-bandwrite /net:children[] += SPEC{type, name, config}instantiates a connection vertex at/net/<name>and wires its transport into the router’s child_registry_t — the single NAME→link demux table.There are TWO creation doors, and they share one body below the module resolution. The RFC-0014 door is the per-module CREATOR ENDPOINT (S2b): register_module mints
<net_root>/<module>/conn(thekConnEndpointNameconstant), and aSPEC{ name, config }written there creates<net_root>/<module>/<name>while aNAME{ <name> }removes it — the module in the path fixes both the transport and the role, so the SPEC carries neither. The superseded:children[]door below remains until RFC-0014 S7 retires it.Creation resolves the MODULE first, then the transport — because the mount, the routing key and the staging key are all
<module>/<name>, and a NAME alone names none of them (#883). A configkinddecides the module, through the (kind, role) declaration register_module minted; a kind-less SPEC — the provide_link spelling — takes it from the staged set, and only when exactly one staging carries that leaf NAME (two do and the SPEC carries nokindto tell them apart ⇒ creation is refusedTYPE_MISMATCH, rather than one of them being picked by map order).The transport then comes from one of two sources, in precedence order within that module:
a link staged via provide_link under exactly this
<module>/<name>(borrowed; the caller owns it) — the test/manual seam for loopback channels and transports the catalog doesn’t cover;otherwise, the transport-factory catalog: the config’s
kindselects a factory (built-inudp/tcp/ws, or any registered via register_transport_type), which CONSTRUCTS the real socket from the parsed conn_settings_t; the connection vertex OWNS it, and its link state is written up on successful construction.
A staging under some OTHER module that happens to share the leaf NAME is a different connection: it is neither used nor consumed here.
Destruction semantics (honest): there is no child-removal / connection-teardown model yet (#66), so an owned transport lives as long as this
transport_vertex_t— its recv thread is joined when this object destructs. Declare thetransport_vertex_tAFTER the graph and router it binds (the usual stack order), so owned transports stop delivering frames before the router they feed is gone.Public Types
-
using transport_factory_t = std::function<graph::result_t<std::unique_ptr<transport_t>>(const conn_settings_t&, const wire::tlv_t *raw_config)>¶
Constructs an owning transport from a connection’s parsed settings plus the raw config TLV.
The shared conn_settings_t carries ONLY the universal keys (the ADR-0043 §5 leanness ruling);
raw_configis the SPEC’s config SETTINGS TLV as written (may be null when the SPEC carried none), from which a kind’s factory parses its own kind-private keys (e.g. quic’scert/key) — the factory’s business, module-side.Returns the live transport, or a status:
TYPE_MISMATCHfor a config missing the fields the kind requires (e.g. a DIAL withoutaddr/port),TRANSPORT_DOWNfor a socket that failed to come up (bind/dial/handshake failure).The did-not-come-up status is
TRANSPORT_DOWN, notNOT_FOUND(#929), and a factory written outside the library owes the same answer: the address the SPEC named RESOLVED — the failure is the LINK — andNOT_FOUNDgoes out astr::path::not_found, which the RFC-0002 registry marks PERMANENT, telling a peer to stop retrying a link that may well come back.TRANSPORT_DOWNcarries the TRANSIENT disposition the condition actually has.
Public Functions
-
transport_vertex_t(graph::graph_t &graph, fwd_router_t &router, std::string net_root = "/net", mem::mem_backend_t *rx_backend = &mem::heap_backend(), mem::block_source_t *egress_src = &mem::heap_source())¶
Bind to
graphandrouterand register theclient/listenercatalog types under the/netparent (which is registered if absent).Also registers the built-in transport factories:
udp(DIAL: bind an ephemeral port, peer =addr:port; LISTEN: bindport, peer learned from inbound datagrams) andws(DIAL:transport_ws_client(addr, port)— a synchronous connect + RFC 6455 handshake at creation time; LISTEN:transport_ws_server(port)— accepts MANY concurrent inbound peers (#362), with the ws-privatepeer_named/max_peersconfig keys selecting the ADR-0044 bus facet and the admission cap).- Parameters:
net_root – The parent path for connection vertices (default “/net”).
rx_backend – The RX memory seam config-constructed view-delivering transports draw their inbound frame segments from (ADR-0042 §2): the built-in
udpfactory passes it to every socket it constructs, so a:children[]-created connection participates in owning delivery. Default: the process heap; a bounded host injects its pool over its static slab. Must outlive this object (and thus every owned transport).egress_src – The EGRESS twin of
rx_backend(#873 family 1, ADR-0079’s net-plane failable store): theblock_source_tevery socket these built-in factories construct draws its per-send gather block from — the basetransport_t::send(iov)temporary and theiov_table_toverflow, both sized by the SENDING peer. Sizing it is what bounds this node’s egress allocation; exhaustion drops the frame and counts it, exactly as it already does. Default: the process heap, i.e. today’s behaviour unchanged. Must outlive this object (and thus every owned transport). A kind’s own factory registered later via register_transport_type reaches the same store through egress_source.
-
transport_vertex_t(graph::graph_t &graph, fwd_router_t &router, std::string net_root, mem::mem_backend_t *rx_backend, slim_net_t, mem::block_source_t *egress_src = &mem::heap_source())¶
SLIM ctor (slim_net_t): bind to
graph/routerand register theclient/listenercatalog types under/net, but DO NOT auto-register the built-in udp/tcp/ws factories.Identical to the default ctor except it omits the
register_builtin_transportscall, so a node that binds its links directly (provide_link) sheds the unused socket transports on a--gc-sectionstarget (see slim_net_t). The composition root registers whatever factories it does want afterward via register_transport_type. Every argument up to the tag is required — the tag keeps its overload-disambiguating position, so the one defaultable argument (egress_src) follows it.- Parameters:
graph – The graph the
/netsubtree is registered on.router – The forwarding router connections are wired into.
net_root – The parent path for connection vertices (e.g. “/net”).
rx_backend – The ADR-0042 §2 RX memory seam — as on the default ctor.
egress_src – The ADR-0079 net-plane EGRESS store this vertex REPORTS through egress_source
(#873). A slim node registers its own factories, so nothing here wires it into a link automatically — but the accessor is the documented way a factory reaches “this net plane’s
store”, and before this parameter existed it answered the process heap on a slim node no matter what the composition root had chosen.
nullptr(and the default) means the process heap, i.e. today’s behaviour unchanged. Must outlive this object.
-
void register_transport_type(std::string kind, transport_factory_t factory)¶
Register (or replace) the transport factory for config
kindkind.Mirrors the graph’s child-type catalog (#82): a subsequent
:children[]SPEC whose config carrieskind = <kind>constructs its transport viafactory. An unregistered kind fails creation withSCHEMA_NOT_FOUND(the same “unsupported
catalog entry” convention as an unknown SPEC
type). Call at setup, before frames flow (mirrorsregister_child_type’s thread contract).- Parameters:
kind – The config
kindselector (e.g. “udp”, “quic”).factory – Builds an owning transport from the parsed universal settings plus the raw config TLV (for its kind-private keys).
-
void register_transport_type(std::string kind, transport_factory_t factory, transport_kind_traits_t traits)¶
Register a transport factory WITH its kind capabilities (RFC-0014 §4 S5).
The traits overload: identical to the two-argument form, plus the transport_kind_traits_t row that opts the kind’s DIAL connections into the S5 liveness engine. The traits-less overload registers
{}— every existing caller keeps eager construction unchanged.- Parameters:
kind – The config
kindselector (e.g. “udp”, “quic”).factory – Builds an owning transport from the parsed universal settings plus the raw config TLV; with
traits.self_heal_dialset it is re-run once per dial attempt, off the creation path.traits – The kind’s capability row — see transport_kind_traits_t.
-
graph::result_t<void> acquire_link(std::string_view name)¶
A STANDING binding takes its hold on connection
name'slink (RFC-0014 §4).The S5 refcount seam: a standing subscription or
awaitrouted through the link holds it acquired for its lifetime (the routing-plane callers are S6’s wiring; an embedder may drive it directly). While the count is above zero the engine keeps the link’s steady-state targetUP— self-healing on loss withbackoff, forever — and the last release_link closes the socket back toDORMANT. Non-blocking; “bring it up and wait” isawaiton the connection vertex.A connection that is not engine-managed (a provided link, an eagerly-constructed kind, any LISTEN) answers success as a no-op — RFC-0014 §4: a LISTEN link ignores refcount, and a manual link’s liveness stays the caller’s.
- Parameters:
name – The connection’s qualified key
net/<module>/<name>(#605).- Returns:
NotFound if
namenames no created connection.
-
graph::result_t<void> release_link(std::string_view name)¶
The standing binding releases its hold — see acquire_link.
- Parameters:
name – The connection’s qualified key
net/<module>/<name>(#605).- Returns:
NotFound if
namenames no created connection.
-
inline mem::block_source_t &egress_source() const noexcept¶
This net plane’s EGRESS store — the one the built-in factories wire into every socket they construct (see the ctor’s
egress_src, #873 / ADR-0079).Exposed so a factory registered through register_transport_type (the out-of-tree kinds —
quic,can, an embedder’s own) can hand the SAME store to the link it builds, viatr::net::with_egress_source, rather than silently leaving that kind’s gather on the process heap while the built-ins are bounded. A deployer fanning the ADR-0079 NARROW shape ignores this and captures its own per-thread source instead.
-
graph::result_t<void> register_module(std::string module, std::string kind, conn_role_t role)¶
Declare the MODULE that connections of
kindandrolemount under.A creatable *(transport, role)* pair is a self-contained module under the net root (RFC-0014 §1), so a connection vertex lives at
<net_root>/<module>/<name>and its routing key is that same path (ADR-0061). Modules are declared-only, by the application (ADR-0073 §4): the library never derives or auto-registers a module name, so every module segment in the graph traces to an application decision. The mapping is not uniform either: a transport with both a dial and a listen shape is TWO modules (ws-client,ws-server), while a bus likecanis ONE for both roles — “a
bus has no dial/listen asymmetry” (
transport_can.hpp).Built-in transports ship suggested module names (e.g.
kWsClientSuggestedModuleintransport_ws.hpp) the application may pass here — but the call is always application code. Registration is a minting boundary, so the shared segment-validity predicate (ADR-0073 §1) gatesmodule:a name carrying a reserved character, empty, or over the segment byte cap answersINVALID_PATHand registers nothing.Declaring a module MINTS its creator endpoint
(RFC-0014 §1: “adding a module adds
its creator endpoint and catalog”): this registers the
<net_root>/<module>grouping vertex and, below it, the write-driven<net_root>/<module>/connendpoint whoseSPEC/NAMEwrites create and remove the module’s connections. Both are idempotent — a second declaration naming the same module (a module serving two kinds) finds them and mints nothing. The endpoint is minted HIDDEN from the module’s:children[](RFC-0014 §3, S4) — seekConnEndpointName.- Parameters:
module – The module segment (e.g.
"ws-client","can"); must satisfytr::graph::valid_segment.kind – The config
kindthis module constructs (e.g."ws").role – The role this module fixes positionally.
- Returns:
INVALID_PATHifmoduleis not a valid path segment; the graph’s own refusal (e.g.BACKPRESSURE) if the endpoint could not be registered — in which case nothing is declared either.
-
graph::result_t<std::string> module_for(std::string_view kind, conn_role_t role) const¶
The module a connection of
kindandrolemounts under (RFC-0014 §1).Declared-only (ADR-0073 §4): a *(kind, role)* pair the application never declared via register_module answers
SCHEMA_NOT_FOUND— the unsupported-catalog-entry convention an unknown SPECtypeuses — instead of a library-derived name.Note
Thread-safe (#881): this takes the control mutex, so it may run concurrently with register_module and with wire-driven connection creation. It used to read the declaration vector with no lock, which a concurrent register_module could reallocate out from under the walk — the declare-only-at-setup contract that papered over the gap is WITHDRAWN. The lock is control-plane; nothing on the forward or delivery path takes it.
-
bool is_structural(wire::key_view_t key) const¶
Is
keyone of THIS net plane’s structural vertices — the net root, or a<net_root>/<module>segment — rather than a connection or an application vertex (#1096)?transport_vertex_tmints two vertices nobody asked for: the net root (the:children[]creation target) and, lazily, each<net_root>/<module>segment a connection mounts under. Both are registeredrole_t::STORED_VALUEand carry no descriptor table, so an embedder walking graph::graph_t::for_each_vertex sees them as ordinary value vertices someone forgot to describe — byte-identical:schemashape to a real leaf, differing only in the NAME. This predicate is how an embedder tells them apart without re-typing the library’s own naming rule.The answer is deliberately scoped HERE and nowhere wider.
graph_tcannot answer it: an application’s own structural vertex (a/zoneholding nothing but children) is indistinguishable from a connection vertex on every graph-visible surface — same visit, same schema shape, same composed-branch read (RFC-0016) — so a graph-level predicate would be inventing an answer. What the LIBRARY minted, the library can report; what the APPLICATION minted stays the application’s business (ADR-0010: libtracer is a transport for application data, not a definer of application semantics). Seedocs/reference/11§structural vertices.Note
Name match, not provenance — a documented false positive. Creation deduplicates against
graph_.find, deliberately keeping no per-module minted set (commit221ed983deleted exactly that state). So a vertex an application registered at<net_root>or<net_root>/<module>before this object got there answerstruehere: the predicate says “this key names a structural
position of this net plane”, not “this object registered this vertex”.
Note
The RFC-0014 per-module creator endpoint
<net_root>/<module>/conn(implemented by S2b) is not structural and answersfalse: it is an addressable control surface — arole_t::HANDLERvertex that EXECUTES theSPEC/NAMEwrites reaching it — not a grouping segment, and it sits one level below the deepest structural position anyway.- Parameters:
key – The canonical PATH-payload key
for_each_vertexhands its callback (no handle unwrapping needed — the signature matches).- Returns:
true iff
keyis<net_root>, or<net_root>/<module>for a module of this plane — one declared through register_module, one staged through provide_link (the kind-less spelling never declares its module), or one carrying a live connection.
-
void provide_link(std::string module, std::string name, transport_t &link)¶
Supply a pre-built transport a subsequent SPEC of connection
namebinds.The test/manual seam: the link is not constructed from the config — it is handed in here (a loopback endpoint, a test channel, a transport the catalog doesn’t cover) and wired into the router when the matching
:children[]SPEC is created. The caller keeps ownership. Call at setup, before the SPEC write.The staging key is
<module>/<name>in BOTH halves (#883). A creating SPEC reaches this staging when it resolves to the same module — i.e. it carries nokind(and no second staging sharesname), or it carries akindwhose register_module declaration for the creation’s role namesmodule. Then the staged link takes precedence over config construction, and thekind’s factory does not run. Akinddeclared under a DIFFERENT module builds its own socket there and leaves this staging untouched — it no longer captures the creation by leaf NAME alone.- Parameters:
module – The module the connection mounts under (
/net/<module>/<name>). Required because a staged link may bypass the transport factory, so there is nokindto derive one from — the caller staging the link says where it mounts (RFC-0014 §1).name – The connection’s NAME (the
/net/<module>/<name>leaf segment).link – The transport carrying this connection’s bytes.
-
graph::result_t<void> set_link_state(std::string_view name, link_state_t state)¶
Report a connection’s link-liveness state — a write to the vertex value.
Writing the 1-byte link_state_t makes
await(/net/<name>)fire (ADR-0021:awaitis the vertex’spoll) and delivers to every subscriber (RFC-0008 §D assign-then-deliver). Eagerly-constructed transports are setUP/LISTENINGat creation; this remains the seam for later link events (and the only source for provided links). On an ENGINE-MANAGED connection (aself_heal_dialkind’s DIAL, RFC-0014 S5) the engine is the sole writer of the DIAL transitions — a manual write here still lands but is advisory-at-best and the next transition overwrites it; drive such a link through acquire_link / release_link instead.Note
Thread-safe (#881): this takes the control mutex, so the transport thread reporting a provided link’s liveness may run concurrently with the wire-driven create/remove that inserts into and erases from the connection table. It used to look the connection up with no lock, racing that map’s rebalance and the erase of the very node it returned. The vertex write happens inside the locked section, in the order the class documents (this → router → graph → stripe).
- Parameters:
name – The connection’s qualified key
net/<module>/<name>(#605).state – The link-liveness state to publish (see link_state_t).
- Returns:
NotFound if
namenames no created connection vertex.
-
graph::result_t<void> remove_connection(std::string_view name)¶
Remove connection
name— un-route it, retire its vertex, close its socket.The teardown counterpart of creation (#494), in the order the invariants require:
fwd_router_t::remove_childfirst (the name stops resolving, so no forward can reach the link), thengraph.retire()on the identity vertex (RFC-0009 §B.6 — the path re-virginizes), then the owned transport is destroyed (joining its recv thread). A connection whose link was staged via provide_link leaves that borrowed link alone; only the routing entry and the vertex go.This is the owner-internal operation the RFC-0014
NAME-write removal dispatch (S2b) will call; it is not itself reachable from the wire.- Parameters:
name – The connection’s qualified key
net/<module>/<name>— NOT the bare connection NAME. RFC-0014 S2a re-keyedconns_to the qualified form so the routing address equals the vertex path; these lookups moved with it and the doc did not, so a caller following the old wording got a silentNOT_FOUND/nullptr(#605).- Returns:
NotFound if
namenames no created connection.
-
const conn_settings_t *settings_of(std::string_view name) const¶
The parsed transport-private settings of connection
name(nullptr if none).- Parameters:
name – The connection’s qualified key
net/<module>/<name>(#605).
-
transport_t *link_of(std::string_view name) const¶
The OWNED transport of connection
name— the config-constructed socket.The seam for reaching a SPEC-constructed listener/server after creation (e.g. to enumerate its peers via
link_of(name)->bus()or close one vialink_of(name)->bus()->close_peer(peer)). Returns nullptr for a connection whose link was staged with provide_link (the caller already owns that link) and for an unknown NAME.- Parameters:
name – The connection’s qualified key
net/<module>/<name>— NOT the bare connection NAME. RFC-0014 S2a re-keyedconns_to the qualified form so the routing address equals the vertex path; these lookups moved with it and the doc did not, so a caller following the old wording got a silentNOT_FOUND/nullptr(#605).
-
struct conn_settings_t¶
One connection’s transport-private settings — a
tr::netrecord, not part of any vertex’s protocol:settingssurface.addr/port/role/keepalive_ms/kindare a device-private:settingsfacet of a connection vertex (ADR-0021: standard vs device-private fields), so they live here on thetr::netleaf record. They are reached through this transport’s own config door, never through the vertex:settingscore namespace — which RFC-0022 §3.B emptied outright, so there is no shared per-vertex policy record for these to be confused with or to leak into.kindselects the transport factory (e.g."udp","ws") used to construct the socket when no transport_vertex_t::provide_link was staged; empty = pre-supplied link only.This record carries ONLY the universal keys every transport kind shares (the ADR-0043 §5 leanness ruling): a kind’s PRIVATE config (e.g. quic’s
cert/keyPEM paths) never lands here — the kind’s own factory parses it from the raw config SETTINGS TLV it receives alongside these settings.Public Members
-
std::string addr¶
Peer IPv4 dotted-quad (DIAL).
-
std::uint16_t port = 0¶
Peer port (DIAL) / bind port (LISTEN);
0on a LISTEN is the EPHEMERAL request — see port_set.
-
bool port_set = false¶
Was a
portkey PRESENT in the config? Distinguishes an omitted required key (aTYPE_MISMATCHconfig error) from an explicitport = 0, which on a LISTEN asks the OS to pick the bind port (#1362). Read the granted port back off the constructed link (local_port()).
-
conn_role_t role = conn_role_t::DIAL¶
Link direction (type default; config
roleoverrides).
-
std::uint32_t keepalive_ms = 0¶
Keepalive interval (transport-specific; ignored by the built-ins).
-
std::uint32_t max_frame = 0¶
Per-connection receive frame cap for every framed transport — the length-prefix streams (
tcp,quic,webtransport) read it off their u32 prefix,wsoff the RFC 6455 frame header; 0 = the protocol default (kMaxFrame, 16 MiB). Only tightens, never raises.
-
std::string kind¶
Transport-factory selector (“udp”, “ws”, …); empty = provide_link only.
-
std::uint32_t backoff_ms = 0¶
DIAL self-heal retry interval (RFC-0014 §4); consumed by the S5 liveness engine (
), 0 = the engine’s default (self_heal_link.hppkDefaultBackoffMs).
-
std::uint32_t connect_timeout_ms = 0¶
DIAL connect-attempt deadline (RFC-0014 §4): how long one dial waits for
UPbefore it counts as failed; consumed by the S5 engine. 0 =kDefaultConnectTimeoutMs.
-
std::string addr¶
-
enum class tr::net::conn_role_t : std::uint8_t¶
The connection’s transport-private role (ADR-0027 §default link direction).
DIAL= this node opens the link (the consumer-dials default);LISTEN= this node accepts. A config-constructed socket transport acts on it (bind vs. connect).Values:
-
enumerator DIAL¶
-
enumerator LISTEN¶
-
enumerator DIAL¶
-
struct slim_net_t¶
Tag selecting the SLIM transport_vertex_t ctor — the one that does NOT auto-register the built-in udp/tcp/ws transport factories.
The default ctor is batteries-included: it registers the built-in socket factories so a full node can create udp/tcp/ws connections from a SPEC
kind. A slim node binds its links DIRECTLY instead — transport_vertex_t::provide_link stages a hand-constructed transport and a:children[]SPEC wires it in (the way the device/VB nodes stage their ws and CAN links) — so it never routes creation through the built-in factories. Yet while the ctor hard-referencesregister_builtin_transports, the linker must keep udp+tcp+ws (and their factory glue) even on a node that binds none of them: constructing the/netvertex pulls all three in. Passing slim_net_t selects a ctor whose translation-unit graph never namesregister_builtin_transports, so on a--gc-sectionstarget the unbound factories — and the transport TUs nothing else references — garbage-collect. A slim node re-adds exactly the factories it wants via transport_vertex_t::register_transport_type (or a hand-picked register_*_transport).NON-BREAKING: the default (full-node) ctor is unchanged, so existing consumers keep the auto-registered builtins; only a node that opts in with this tag sheds them. Compile-time (not a runtime flag) precisely so the reference is absent from the slim TU and the GC can fire.
See also¶
transport — the wire seam — what
fwd_router_tsends through.graph — vertices & dispatch — the terminus the router resolves against.
interface map — the net plane in the cross-cutting view.
Two nodes over a wire — a runnable two-node forward and reply.
Reference 13 — network formation and Reference 04 — communication flows — the same model described implementation-independently.
Failable allocation and backpressure — the drop rule and the block-source seam.
Concurrency and scaling — where this implementation’s measured costs live, with their conditions.