config — the build’s named traits type¶
In one paragraph
Every compile-time knob a libtracer build has is a member of one named type,
tr::graph::default_config_t — sizes, padding widths, and the two policies the
build selects (which ACL evaluator, which last-known-value slot). An application
picks its configuration by making tr::graph::config_t name its own traits type,
once, app-wide; every loose spelling in the library is derived from that alias.
A second reader, tr::wire::config_reader_t, is the runtime counterpart: typed
accessors over a config SETTINGS TLV that arrived from a peer.
What it does¶
Two different things are called “configuration” here, and keeping them apart is the point.
Build configuration is default_config_t. It is not a set of macros and not
a template parameter: it is a struct whose members are static constexpr values
and nested type aliases, bound once by an alias. Being one named entity is what
makes a configuration diffable, passable to a test, and assertable on — a scatter
of independent #defines can only be read one at a time. It is bound per build
rather than threaded through the API because threading it through produces
byte-identical machine code while forking the process-global stripe and hazard
tables, which costs exactly the RAM the configuration exists to save.
The knob-by-knob narrative — what each knob costs on which target, and which ones are optimization rather than correctness — is the configuration space. This page is the API surface.
Runtime configuration is config_reader_t. A connection’s settings arrive as
a SPEC config SETTINGS TLV: positional NAME-key / value pairs, string values as
NAME children and integers as VALUE children. All six transport-side consumers
used to hand-roll the same walk — the universal keys plus the tcp, ws, can, quic and
webtransport factories — and this class is their one home. Unknown keys are ignored so
a newer peer can send more than a receiver understands, a key whose value child
has the wrong type is ignored, and a repeated key resolves to its last
well-formed occurrence. Each factory still reads only its own keys — what is
shared is the walk, not the vocabulary.
The walk is pair-consuming: it advances a whole (key, value) pair at a
time, so an unknown key is skipped together with its value. That is what lets
the tolerance coexist with positional pairing. Scanning every offset instead made
the grammar ambiguous — a pair whose string value textually equalled a known key
(link_hint = "addr") had that value re-read as a key, binding the following
child as addr and, under last-wins, destroying a legitimate earlier one. A
child that is not a NAME where a key belongs desynchronizes the stream and the
walk stops there rather than guessing a resync.
Which keys those factories actually read — the universal set and each kind’s private one, with the wire value each takes — is connection config. This page is the walk; that page is the vocabulary.
config_reader_t is the one home for the pair walk itself, everywhere it is read
tolerantly. It lives in tr::wire
(#985) — the layer that owns
the grammar — and the transport plane names it there too, so
graph_t::create_child (the creation SPEC) and the SUBSCRIBER QoS SETTINGS parse at
L4 read through the same type rather than carrying hand-written copies of the rule.
The one deliberate exception is graph::parse_acl
(#906) — the same mechanics
under the opposite unknown-key ruling, which is to reject: an ACL is a security
document, and the two duplicate-key families
(#995) must not share code
that could drift one toward the other.
Declaring your own build configuration¶
Inherit, override what differs, and bind the alias in a header your build puts
ahead of libtracer/config.hpp on the include path:
struct my_node_config_t : tr::graph::default_config_t {
static constexpr std::size_t kCacheLineBytes = 0; // single-core: no false sharing
using guard_t = my_rtos_critical_section_t; // interrupt-masked, never spins
using lkv_slot_t = tr::graph::single_writer_slot_t;
};
using config_t = my_node_config_t;
Inheriting rather than copying means a knob added later inherits its new default instead of failing to compile.
The two policies¶
A policy is a type the configuration selects, not a runtime flag, so the branch it removes is not on the hot path at all.
ACL policy — acl_policy_t picks how an access decision is evaluated.
allow_only_policy_t is the MCU profile: ALLOW entries only, no ordering
question to answer. full_acl_policy_t is the host profile: ordered
first-match-per-bit, DENY included. See security & ACL for
what the entries mean.
LKV slot policy — lkv_slot_t picks how a vertex publishes and reads its
last-known value. single_writer_slot_t is the default: a plain shared_ptr
swapped and copied inside guard_t (an interrupt-masked critical section
on a chip, the address-striped mutex_guard_t on a host), with no registry and a
publish that cannot fail. (kSingleWriter, the one-publisher-per-vertex promise
an RTOS build used to state, was removed in
#1718: no code read it.)
hazard_slot_t is the lock-free host opt-in: an atomic<node_t*> reclaimed
with hazard pointers, which costs a fixed registry sized by kHazardReaderSlots,
a heap node per published value, and a publish that can fail under memory
exhaustion. Every
policy returns an owning handle from load() and declares may_spin, and a
build whose kSpinWaitSafe is false refuses a policy that can spin. The
refcount slot sp_atomic_slot_t was removed because libstdc++ spin-locks it
(#1618).
Pitfalls¶
Bind the alias, do not edit the struct. Overriding
config_tis a supported target-configuration change; editingdefault_config_tin place puts your build on a fork of the library.kCacheLineBytes = 0is a legal value. It means “this target has no second core to false-share with”, and it is an optimization knob in both directions — never a correctness one.A
config_reader_tborrows. The reader and everystd::string_viewit returns point into the bytes itstlv_node_tborrows; both die with those bytes.Ignoring an unknown key is deliberate. A reader that rejects settings it does not recognize breaks forward compatibility with a newer peer. This is the opposite ruling from
parse_acl, which rejects an unknown key: an ACL is a security document, where silently dropping an attribute widens access. The tolerance is safe here only because the skip takes the whole pair.
API reference¶
-
struct default_config_t¶
The target’s build configuration, as ONE named type (ADR-0070).
Every compile-time knob is a member here, and the loose names below are DERIVED from it. That ordering is the point: the configuration is a single diffable entity an application can name, pass to a test, and assert on — rather than a scatter of independent declarations that can only be read one at a time.
It is bound once per build, not threaded as a template parameter. ADR-0070 records why, with measurements: threading it produces byte-identical machine code (verified across eight knob combinations, five optimization levels and two targets), so it buys no latency; its one unique capability — two configurations in one binary — would FORK the process-global stripe and hazard tables, costing exactly the RAM the configuration exists to save; and an app-declared traits type cannot reach the library’s out-of-line translation units anyway, so it would layer on this header rather than replace it.
Declaring your own. Write a
libtracer/config_override.hppand put its directory ahead ofcore/includeon the include path. This header includes it — after this struct, so the defaults are already visible to inherit from — and uses whatever config_t it binds:// libtracer/config_override.hpp #pragma once namespace tr::graph { struct my_node_config_t : default_config_t { static constexpr std::size_t kCacheLineBytes = 0; // single-core: no false sharing using guard_t = my_rtos_critical_section_t; }; using config_t = my_node_config_t; } // namespace tr::graph
Inheriting from default_config_t means a knob added later does not break your preset — it inherits the new default instead of failing to compile. Stating only the differences is also what keeps the override honest: there is no second copy of the defaults to rot.
Public Types
-
using acl_policy_t = allow_only_policy_t¶
The target’s selected ACL policy (ADR-0047 §1 build-time module set).
Default: the ALLOW-only MCU profile. A host binds the full
security_aclpolicy (ordered first-match-per-bit with DENY) in its override fragment — a target-configuration change, never an edit tograph.cpp. Override fragment:using acl_policy_t = full_acl_policy_t;. (The-DLIBTRACER_ACL_FULLCMake option that used to write that line was removed in #1722; configuring with it is an error.)
-
using guard_t = ::tr::mutex_guard_t¶
The target’s ONE critical-section type (RFC 0028 §5.5): the guard
single_writer_slot_topens around its pointer swap and its handle copy, AND the defaultSyncpolicy oftr::mem::synchronized_pool_t.One trait, not two: before slice 10 the pool had its own
pool_sync_policyvocabulary (spin_sync_t/portmux_sync_t), bound separately from this one. It must modeltr::guard(guard.hpp):lock()/unlock(), a staticfor_address(const void*)the slot takes, and theis_isr_safe/is_nonblocking/may_spin/nametraits. It also serializes the write-sequence bump on a core with no atomic read-modify-write (tr::rmw_counter_t).Spelled
reader_guard_tuntil #1703. A fragment that still defines that name is refused at compile time (see the tripwire after config_t), because a fragment whose binding the library no longer reads would fall back tomutex_guard_twithout a word.mutex_guard_tby default: a table of address-striped one-word locks — one RMW to take, a release store to give back — whose contender re-reads briefly and then sleeps instead of spinning on a descheduled holder; unrelated vertices rarely share one. A single-core RTOS build binds an interrupt-masked critical section (the ESP-IDF component:tr::esp::critical_guard_t). The guard must never spin-wait where kSpinWaitSafe isfalse— that is the whole of #1618 — and a guard that declaresmay_spinis refused there by both the slot and the pool.
-
using lkv_slot_t = single_writer_slot_t¶
The target’s selected LKV slot policy (ADR-0069 §1).
How a vertex publishes and reads its last-known value. Default:
single_writer_slot_t, onevalue_t*swapped and retained inside guard_t. It has no registry, no deferred reclamation and a publish that cannot fail, and its one wait is the guard (#1618).hazard_slot_tis the lock-free alternative for a host whose reads of one shared vertex contend across many cores. Override fragment:using lkv_slot_t = hazard_slot_t;. The named type must satisfy the contract inlkv_slot.hpp— in particularload()returns an OWNING handle, and the policy declaresmay_spin, which is refused where kSpinWaitSafe isfalse.
-
using reclaim_policy_t = reclaim_local_t¶
The target’s selected RECLAMATION policy (ADR-0080) — WHEN the library may free the memory behind a retired subscription’s
{fn, ctx}pair.Default:
reclaim_local_t, whose grace point is “this thread’s dispatch stack unwinds
to depth 0”. It is the default because it makes the MCU and the host build behave IDENTICALLY:
reclaim_strict_tforbids unsubscribing from inside a delivery, which would make the same application code legal on a host and illegal on the constrained target — a portability bug that surfaces only where it is hardest to debug.What the default costs, and what rebinding buys, is one non-atomic increment, decrement and branch per
fan_outon a per-thread counter — once per publish, regardless of subscriber count, and nothing at all on a publish nobody subscribed to (the counter sits afterfan_out’s no-subscriber gate). Override fragment:using reclaim_policy_t = reclaim_strict_t;— worth taking only where the deployment can show that re-entrant unsubscribe does not occur, becausereclaim_strict_tcannot see it outside a debug build.reclaim_qsbr— ADR-0080’s third policy, whose grace point spans EVERY thread — is the one to bind when this node dispatches from several threads at once and unsubscribes from another, the case neither policy above covers. Override fragment:using reclaim_policy_t = reclaim_qsbr_t;. It prices one relaxed load and oneseq_cststore per OUTERMOST fan-out (not per edge), and it is the only policy whose release hook may run on a thread other than theunsubscribe()caller — seereclaim.hpp.
Public Static Attributes
-
static constexpr std::size_t kVertexLockStripes = 16¶
The number of lock stripes shared by every vertex in the process (#361 §2).
Override fragment:
static constexpr std::size_t kVertexLockStripes = 8;; ESP-IDF: menuconfigCONFIG_LIBTRACER_VERTEX_LOCK_STRIPES, which writes exactly that line. A small single-core node reclaims RAM at 4-8.What N costs, precisely:
N * sizeof(vertex_stripe_t)bytes of.bssreserved at LINK time (plus the same for the condvar table) — the table is not lazy, whatever the platform does. What IS lazy is the platform primitive behind each handle: on FreeRTOS a stripe’s mutex costs ~90 B of heap on its first lock, so an untouched stripe costs its struct and no heap. Most of the struct is padding, and kCacheLineBytes decides how much.
-
static constexpr std::size_t kCacheLineBytes = 64¶
The target’s cache-line size for false-sharing padding — or 0 where false sharing cannot happen, because the target has no second core to share with.
libtracer pads the shared tables whose slots unrelated threads hit concurrently (the vertex lock stripes; the hazard domain’s cells and retire lists) up to this boundary, so two threads working on two slots never fight over one line. On a single-core node that padding buys nothing and the bytes are pure loss: measured on rv32 (
-Os, realcore/src/graph.cpp, GCC 15.2), 16 stripes cost 1,024 B of at 64 and 128 B at 0 — 896 B of a single-core node’s static RAM spent against a hazard it does not have.This is an OPTIMIZATION knob, never a correctness one: 0 on a multi-core target costs throughput under concurrent control-plane verbs and changes no observable behaviour. Override fragment:
static constexpr std::size_t kCacheLineBytes = 0;. The ESP-IDF component derives it fromCONFIG_FREERTOS_UNICORE— a unicore build has no second core by construction, so the right value is not a question the integrator should be asked.Values below a padded type’s natural alignment are raised to it, not applied ([dcl.align]/5 makes a reduction ill-formed, and GCC ignores it silently); each padded type static_asserts that the alignment it asked for is the one it got.
-
static constexpr std::size_t kHazardReaderSlots = 64¶
How many threads may hold a hazard announcement at once (ADR-0069 §3).
Read this as “threads that concurrently touch a vertex’s LKV” — readers announce, writers park displaced nodes, and both claim one index for the life of the thread. A per-target knob rather than a thread ceiling picked out of the air (RFC-0006); override fragment:
static constexpr std::size_t kHazardReaderSlots = 24;.Sizing: one index per such thread, and nothing at all unless lkv_slot_t is bound to
hazard_slot_t— the default binding (single_writer_slot_t) never references the registry, so it is never emitted. Undersizing is not a correctness problem: threads past the bound share one reserved index under a spin lock, so they serialize with each other and with nobody else.What the domain costs when it IS bound, measured on rv32 at N = 64 (
-Os, realcore/src/graph.cpp, GCC 15.2) — the padding knob dominates it:registry
.bssTU
.bss+.sbss64
8,384 B
11,649 B
0
1,828 B
4,197 B
Note the third term the registry figure does not cover: binding the slot also pulls in roughly 2 KB of libstdc++
__waiter_pool_base.bss(theatomic::waitback-end), which is why the TU column is not just the registry plus the stripes.
-
static constexpr std::size_t kInlineFanout = 8¶
Subscribers a publish snapshots into its stack buffer before it falls back to the overflow vector (#1708) — a TUNING knob, never a limit.
A publish copies the vertex’s live edges into a fixed buffer of
edge_view_ton its own stack, so a fan-out up to this width reaches no allocator. A wider fan-out snapshots into the publishing thread’s reusable overflow vector instead (zero-alloc once warm) and delivers to every subscriber exactly as before: the width selects a strategy, it refuses nothing and reports nothing.The buffer is reserved on EVERY publish frame whether the vertex has one subscriber or eight, at
kInlineFanout * sizeof(edge_view_t)bytes: 384 B on a 64-bit host (48 B a view) and 224 B on rv32 (28 B). The host default keeps the allocation-free fast path for the widths the benches gate; a NARROW node whose vertices carry one or two subscribers spends that stack on every task that publishes. Measured on rv32 (-Os, GCC 15.2, realcore/src/graph.cpp,-fstack-usage):graph_t::fan_out’s frame is 304 B at 8 and 128 B at 2 — the 168 B of six views plus 8 B of 16-byte frame rounding. Override fragment:static constexpr std::size_t kInlineFanout = 2;— the ESP-IDF component sets 2 on a chip target. At least 1.
-
static constexpr std::size_t kEdgePinSlots = 32¶
How many threads may hold an EDGE PIN at once (#635) — the per-participant announcement the fan-out snapshot claims while it copies a vertex’s published edge array out.
Read this as “threads that may publish (`graph_t::write`) concurrently”. Each claims one index for the life of the thread; the pin itself is held only across the copy-out, never across a dispatch. A per-target knob rather than a thread ceiling picked out of the air (RFC-0006); override fragment:
static constexpr std::size_t kEdgePinSlots = 24;.Correctness never depends on this number — only scaling does. A thread that finds every index taken falls back to copying the CURRENT array under the vertex stripe mutex, which is safe for the reason the mutex existed in the first place: displacing an array requires that same lock, so the current array cannot be retired underneath the fallback reader. Undersizing costs those threads exactly what every thread paid before #635.
Unlike kHazardReaderSlots this registry is ALWAYS emitted — the publish path is not a policy binding. Its
.bssisN * max(kCacheLineBytes, alignof(void*))bytes: at N = 32 that is 2,048 B on a host (64-byte padding) and 256 B on a single-core MCU profile, which sets kCacheLineBytes to 0 and has no second core to false-share against.
-
static constexpr std::size_t kMaxVertexBytes64 = 88¶
The RAM-diet RATCHET on
sizeof(vertex_t), 64-bit targets (#361 §8).Pinned to the size actually measured, with NO headroom, so the next byte added is a build failure. That is the point: the goal is a lean vertex, and a ceiling held above the measured size cannot express it. A ceiling only answers “did you regress past a
fixed point”; both 112 B and 96 B satisfied the old 120 B ceiling, so the 16 B the diet won after #380 §1 were invisible to the build and free to be spent again by anyone. Pinned to the measurement, every byte reclaimed is kept by construction.
History, newest first: 88 unchanged by #1621 (the 32-bit write sequence frees 4 B that the member order’s tail padding absorbs on a 64-bit host), 88 (RFC-0028 slice 3 — the LKV slot is one
value_t*word, where thestd::shared_ptr<const rope_t>it replaced was two; bothlkv_slot_tbindings agree), 96 (measured across all three CI legs —acl_fullOFF/ON and bothlkv_slot_tbindings agree), 112 post-#380 §1, 144 post-packing, 168 post-§3, 160 post-§2, 248 post-§1, 536 pre-split.It lives HERE, in the configuration, because it is a per-target budget — and it is enforced in
vertex.hppbeside the type it constrains, so every build on every target evaluates it under its own binding. It used to sit in a test, which meant it gated exactly one configuration and never the 32-bit one at all (no CI leg compiled that test cross-target, while the ESP-IDF legs compiledvertex_titself on every PR).Two rules follow from pinning it. RAISING one is a reviewed decision, never a way to make a build pass. LOWERING one is the routine half: a change that shrinks
vertex_tlowers the number in the same commit, or the gain is handed back to the next author.
-
static constexpr std::size_t kMaxVertexBytes32 = 64¶
The RAM-diet RATCHET on
sizeof(vertex_t), 32-bit (MCU) targets.Pointer-halved, and pinned to the measurement on the same terms as kMaxVertexBytes64 — measured 64 B on rv32 (
-Os -fno-exceptions -fno-rtti,rv32imac_zicsr_zifencei/ilp32).Lowered 72 -> 64 by #1621 (RFC-0028 D6):
write_seq_became a 32-bit atomic. The 64-bit one was 8 B wide AND 8-aligned on rv32, so it cost 4 extra bytes plus 4 of alignment padding, and a libatomic call (__atomic_fetch_add_8, which masks interrupts on ESP-IDF) on every publish.This number was 80 with a note claiming rv32 was “exactly 80” and had zero headroom. It was 72 by then: the struct had shrunk and the prose had not, which is exactly the drift a pinned number cannot have — the assert is re-derived from a measurement, while a sentence describing the size is only as true as the day it was written.
Earlier: raised 72 -> 80 with the #380 §2 name-key SBO, whose inline buffer adds <= 8 struct bytes on 32-bit but deletes a ~32 B heap block per named vertex — and on this target the heap is what is actually scarce.
The copy-or-share threshold (RFC-0028 §5.3, D3): a written value of AT LEAST this many bytes is SHARED, one below it is COPIED into the value’s own block.
The default every vertex answers until its
vertex_policy_t::share_threshold_bytesgives it its own. At ingress, “shared” means the stored value links the inbound receive segment (refcount, zero copy) and “copied” means the bytes land inline in the onevalue_tblock the publish costs (value_t::make_inline: one allocation, onememcpy). A payload whose TLV carries a CRC/TS trailer is always copied — a shared frame’s opt byte cannot be patched — and so is one whose reader cannot share (a borrowed, span-delivered frame).0shares always;SIZE_MAXcopies always. The two ends are the oldkPinNeverand “pin unconditionally”, now edge cases of one comparison on the absolute payload size — the variable that was actually measured (RFC-0028 R6); the retired ratio was the measurement rig’s rotation knob (#774).
-
static constexpr auto kSizeClasses = size_class_ladder_t<16, 8, 65536>::kTable¶
The size-class table of the host slab pool (ADR-0083 Decisions 4 and 5, #1775, #1777): the block sizes a class serves, ascending.
The host default root (
mem_slab_pool.hpp,tr::mem::host_root_t) carves every block it serves from a slab of one class: a request takes the smallest class that holds it, and the host allocator sees only whole slabs. A size-class boundary is therefore a row in this one table, not a property of the platform allocator. That is what removes the 1 KiB cliff #1768 measured: a 1024 B value’s 1072 B one-block segment is a 1152 B block here, from the same per-thread cache a 1000 B one comes from, where glibc served it on a slower path than a 1032 B request.Default: 16 B to 64 KiB, 8 classes per doubling. Eight classes of 16 B up to 128 B, then eight evenly spaced classes in every doubling above it (144 … 256, 288 … 512, … 61440, 65536): 80 rows, so no block wastes more than one eighth of its size. A request above the last row, or one aligned past 64 B, is drawn from the root as whole slabs of its own (kSlabBytes multiples) and returned there when freed.
Every row must be a multiple of 16 (
alignof(std::max_align_t)on the reference hosts), so every block keeps the platform allocator’s alignment, and the table must be non-empty and strictly ascending, with at most 255 rows; the pool asserts all three. A node whose payloads cluster (mostly 4 KiB frames) states its own table, and pays no slab for a class it never draws: a class takes its first slab on its first request.Read only where kSlabPool is
true. The ESP-IDF component binds thatfalse, so the table is not read on that target.Override fragment:
static constexpr std::size_t kSizeClasses[] = {64, 256, 1024, 4096};.
-
static constexpr bool kSlabPool = true¶
Whether this build’s default root is the host slab pool (ADR-0083 Decision 4, #1777).
true(the host default): agraph_tbuilt without a source derives its value, table and net sub-pools fromtr::mem::host_root(), andtr::mem::heap_backend(),value_source(),table_source(),net_source()andnet_backend()all draw from them. The host allocator then serves whole slabs only (kSlabBytes), never a request per value.false: those defaults are the platform heap directly, one request per block, which is what every build did before #1777. A target with no small-block fast path and a few hundred KiB of RAM gains nothing from 64 KiB slabs, so the ESP-IDF component bindsfalse; its static-arena default is ADR-0083 Decision 12’s step 6. No sub-pool is derived there, and the:stats.mem.values,.tablesand.netseams answerSCHEMA_NOT_FOUND.Override fragment:
static constexpr bool kSlabPool = false;.
-
static constexpr std::size_t kSlabBytes = 65536¶
The slab the host slab pool draws for a class, in bytes: the smallest unit it asks the root for (#1777). A power of two.
A class draws a slab of this size, or the next power of two that holds 8 of its blocks if that is larger, aligned to its own size, so a block finds its slab by masking its address. A slab is carved lazily, one block at a time, so its untouched pages cost the node address space and not resident memory. Default 64 KiB: one slab holds 8 or more blocks of every class up to 7 KiB.
Override fragment:
static constexpr std::size_t kSlabBytes = 16384;.
-
static constexpr std::size_t kSlabClassCap = 2¶
The fully free slabs a host slab-pool class keeps (ADR-0083 Decision 6, #1777).
A slab whose last block comes back is RELEASED to the root when its class already keeps this many fully free slabs, and kept for the next burst otherwise, however many slabs of the class are live, so a long-running service gives back what a burst took instead of holding its peak forever. Nothing is released on a timer (there is none in the library):
tr::mem::host_root_t::trim()releases every fully free slab on the application’s own schedule. At least 1, so a class that alternates one allocation and one free does not draw and release a slab each time.Override fragment:
static constexpr std::size_t kSlabClassCap = 1;.
-
static constexpr std::size_t kGuardStripes = 64¶
Locks in the host guard’s process-wide stripe table (#1716); a power of two.
Applies when the build keeps the default guard_t, and then the library then binds
tr::basic_mutex_guard_t<kCacheLineBytes, kGuardStripes>, so the table costskGuardStripes * max(kCacheLineBytes, 1)bytes of.bss— 4 KB at the defaults. Two vertices share a lock only when their addresses hash to one stripe, and a shared stripe costs a contended section, never correctness. Override fragment:static constexpr std::size_t kGuardStripes = 16;. Ignored when guard_t is bound to anything else (an interrupt-masked guard has no table).
-
static constexpr bool kForceGuardedRmw = false¶
Force the guarded binding of every
tr::graph::bound_rmw_counter_tthe library owns — the write sequence and the router’s labelled-hop count — even where the counter’s width is natively lock-free (#1715, #1697).A test knob: it lets a host build exercise the path a target without a native atomic RMW takes, where the write-sequence bump runs under guard_t and is fused into the LKV publish section. Production code leaves it
false; the native binding is then selected exactly as before and its generated code is unchanged.
-
static constexpr std::size_t kDeferredReleaseSlots = 16¶
How many retired
{ctx, release}pairs ONE THREAD may hold parked at once, when reclaim_policy_t defers (ADR-0080).Read this as “subscriptions unsubscribed from INSIDE a single delivery stack”. The ordinary unsubscribe — from outside any callback — parks nothing at all, so on most nodes this storage is touched zero times; it exists for the re-entrant case
reclaim_local_tsupports andreclaim_strict_tforbids.Sizing: one retired_callback_t per slot, in per-thread storage that is plain bytes with no destructor and no allocation — 256 B on a 64-bit host at the default, 128 B on a 32-bit MCU, and only on a thread that actually dispatches. Nothing at all under
reclaim_strict_t, which never defers.Overflow is a REFUSAL, not a failure: a pair that finds every slot taken is DROPPED and its hook is never run — a leak, deliberately, because the alternative is running a release hook while the fan-out that is still walking the snapshot names that context.
graph_t::deferred_release_drops()counts every one, so an undersized bound is observable rather than silent. Override fragment:static constexpr std::size_t kDeferredReleaseSlots = 64;Under read it differently.
There the pairs are held across a cross-thread GRACE PERIOD rather than a dispatch stack, in ONE shared table rather than per-thread storage, so the quantity it bounds is “retirements in flight process-wide
while some participant has yet to quiesce” — a larger and less predictable number. Raise it (64 is a sane starting point) on any node that unsubscribes in bulk while other threads dispatch. The overflow rule is identical, and so is its observability: a QSBR build retries the scan once after publishing the pair, so a drop means a participant thread genuinely never reached a quiescent state — an embedder defect
tr::graph::graph_t::deferred_release_drops now names.
-
static constexpr std::size_t kQsbrParticipants = kEdgePinSlots¶
How many threads may participate in the
reclaim_qsbr_tgrace period at once (#1376) — the per-thread quiescent-state announcement a retiring thread scans.Read this as “threads that may dispatch (`fan_out`, or an ADR-0049 durability latch)
concurrently”. Derived from
kEdgePinSlots rather than picked out of the air, the way’slkv_slot.hppkRetireBatchis derived from kHazardReaderSlots — the two sets are the same threads, since a thread that publishes is a thread that dispatches.**Unlike kEdgePinSlots, correctness is not indifferent to this number** — but it fails SAFE, not silently. A thread that finds every index taken cannot announce itself, and a thread a scan cannot see is one no grace period may conclude past; so it counts itself into an overflow tally instead, and any non-zero reading blocks all reclamation until it clears. The failure mode is therefore deferred frees (visible as tr::graph::graph_t::deferred_release_drops rising), never a use-after-free.
Its
.bssisN * max(kCacheLineBytes, alignof(std::uint64_t))bytes — at N = 32 that is 2,048 B on a host — plus the shared retired table. It is emitted only in a build that actually binds :graph.cppreaches the domain exclusively fromif constexprbranches a non-QSBR build discards, and GCC emits nothing at all for those — 0 symbols and 0 B of.bss, verified. Override fragment:static constexpr std::size_t kQsbrParticipants = 64;
-
static constexpr std::size_t kDeviceBackendSlots = 2¶
How many
DEVICE-space memory backends may register a transfer hook at once (#1381) — the bound ontr::mem::register_device_backend’s table.An L0 (
tr::mem) fact, here for the same reason kSpinWaitSafe is: ADR-0070’s rule is that the configuration is ONE named type. tr::mem::kDeviceBackendSlots is its spelling for the memory layer.Read it as “vendor accelerator backends this process binds” — a GPU tier module, an NPU one, a dmabuf one. Two is the honest default: a host that talks to one accelerator family needs one slot, and nothing in-tree registers at all.
Its
.bssisN * (sizeof(void*) + sizeof(void(*)()))— 32 B on a 64-bit host at the default — and it is emitted only in a build that linksdevice_backend.cpp. A single-backend (LIBTRACER_BACKEND_SET_POOL_ONLY) target has no device arm and never links that TU, so the cost there is 0 B rather than “small”. Override fragment:static constexpr std::size_t kDeviceBackendSlots = 4;Overflow is a REFUSAL, not a failure:
register_device_backendreturnsfalseand registers nothing, so a backend that could not claim a slot moves no bytes at all (tr::mem::transferanswersfalsefor its segments) rather than silently sharing another vendor’s hook.
-
static constexpr bool kSpinWaitSafe = true¶
Whether a task on this target may SPIN-WAIT for a lock another task holds (#1158).
An L0 (
tr::mem) fact, but a member HERE because ADR-0070’s rule is that the configuration is ONE named type: a knob that lives outside it cannot be set by an override fragment, which is exactly the defect that kept this one in the build system. tr::mem::kSpinWaitSafe is its spelling for the memory layer, derived like every other loose name below.True on a multi-core host: the holder runs on a different core, so a spinner makes progress possible and the O(1) section costs less than a mutex round-trip. FALSE on a priority-preemptive scheduler, where a spinner that outranks the holder never yields the CPU the holder needs to release the lock — the wait becomes unbounded priority inversion and the board hangs in the watchdog rather than merely running slowly. That is true of a single-core chip and equally of an SMP chip whose spinner and holder share a core.
Asserted against the pool’s sync policy (
mem_pool.hpp) and against every LKV slot policy’smay_spin(vertex.hpp, #1618).
-
static constexpr bool kBusLinks = false¶
Whether this target carries the ADR-0044 BUS facet at all — peer-named links, per-peer addressing, in-band peer enumeration (#375 deliverable 3).
A
tr::netfact, and a member HERE for the reason kSpinWaitSafe and kDeviceBackendSlots are: ADR-0070’s rule is that the configuration is ONE named type, so a knob that lives outside it cannot be reached by an override fragment. tr::net::kBusLinks is its spelling for the transport plane.What it closes. A bus link reaches MANY peers and names each of them, so the routing plane carries a second addressing tier for it: the registry stamps a mount’s bus SHAPE and resolves a residual segment as a peer (
child_registry_t::resolve_peer,by_name’s peer fallback),fwd_router_t::add_childwires the peer-named receiver and both peer-lifecycle notifiers, and a connection vertex synthesizes its:children[]from the link’s live peer table. Boundfalse, every one of those consumers folds to the point-to-point answer at COMPILE time throughtr::net::bus_of(transport.hpp), and the peer-named machinery behind them is never reached — ADR-0047 §1 link-time module selection, expressed as a configuration member rather than as a TU list, because whether a tcp/ws listener is peer-named is a WIRING-time choice inside a TU that a bus-less target still compiles for its point-to-point half.What it costs to carry and what the default saves. Measured on rv32 (
-Os -fno-exceptions -fno-rtti,rv32imac_zicsr_zifencei/ilp32, GCC 15.2, per-TU.text), closing it removes 1,400 B of flash fromfwd_router.cppand 678 B fromtransport_vertex.cpp— 2,078 B — and 0 B of.bss, because the tier is code and per-instance state, not a static table. ALIBTRACER_NET_PLANE=OFFbuild gains nothing: it never compiled those TUs in the first place.Default — the lean choice (#1670, v0.17.0). A node whose links are point-to-point — one dial upstream, or a listener that serves its peers as one broadcast link (ADR-0001’s originating firmware shape) — pays nothing for a tier it never uses.
Who opts in. A node that runs a peer-named listener (
peer_named=truetcp/ws), the ESP-IDF WS serverhttpd_ws_link_t, or ANY CAN link — the last two are buses by construction. Override fragment:static constexpr bool kBusLinks = true;— the core test build, thebench/build and the ESP-IDFCONFIG_LIBTRACER_BUS_LINKS(whichCONFIG_LIBTRACER_WS_SERVERselects) do exactly that.It is a REFUSAL, never a silent downgrade. A build that binds it
falseand then asks for a bus is rejected, loudly and at the earliest door that can speak: compilingLIBTRACER_TRANSPORT_CAN(a bus by construction) is astatic_assert, and apeer_named=truetcp/ws listener is refused by its SPEC factory and reportstransport_t::ok() == falsewhen constructed directly. Quietly serving such a configuration as FLAT would be worse than either: the listener’s own per-frame tier select would keep delivering peer-named into a sink the router never installed.
-
static constexpr bool kSelfHealLinks = false¶
Whether this target carries the RFC-0014 §4 S5 LINK-LIVENESS ENGINE at all —
self_heal_link_t, its worker thread and its backoff/redial state machine (#1470).A
tr::netfact, and a member HERE for the reason kBusLinks is: ADR-0070’s rule is that the configuration is ONE named type, so a knob that lives outside it cannot be reached by an override fragment. tr::net::kSelfHealLinks is its spelling for the transport plane.What it closes. The engine is minted on exactly one path — a
config-kindDIAL whose kind was registered withtransport_kind_traits_t::self_heal_dial(transport_vertex.cpp,create_connection_locked). Boundfalse, that mint is discarded at COMPILE time, nothing referencesself_heal_link_t, and the TU is dropped from the archive by the build lists that also gate it (LIBTRACER_SELF_HEAL_LINKSincore/CMakeLists.txt,CONFIG_LIBTRACER_SELF_HEAL_LINKSin the ESP-IDF component).What it costs to carry and what the default saves. Measured by the reporter on an ESP32-C6 image (riscv32,
-Os -fno-exceptions -fno-rtti, same sdkconfig) across the release that made the TU unconditional:nmon the linked ELF finds 4,336 B of reachableself_heal_link_tsymbols (worker_main,attempt_locked,reap_locked, …) out of a +6,224 B image bump, and 0 B of.dram0.bss— the engine is code and per-instance state, not a static table.Default — the lean choice (#1670, v0.17.0; re-rules #1548’s default). The built-in
udp/tcp/wsDIAL kinds then dial EAGERLY, exactly as they did before #1548 made them engine-managed: the connection comes up at creation, with no redial, no backoff and no liveness publishing beyondUP.Who opts in. A node that wants its config-created DIAL connections minted
DORMANTand self-healing, or that registers its ownself_heal_dialkind. Override fragment:static constexpr bool kSelfHealLinks = true;— AND compile the TU (-DLIBTRACER_SELF_HEAL_LINKS=ON; the ESP-IDFCONFIG_LIBTRACER_SELF_HEAL_LINKSdoes both from one symbol). The core test build and thebench/build opt in.It is a REFUSAL, never a silent downgrade. A build that binds it
falseand then registers aself_heal_dialkind is rejected atregister_transport_type: the kind is NOT catalogued, so aSPECnaming it answersSCHEMA_NOT_FOUND— the same answer any unregistered kind gets — and a debug build asserts at the registration itself. Quietly registering it with the trait CLEARED would be worse than either: the connection would come up eagerly, with no redial and no liveness publishing, and nothing would say so.The built-ins are not subject to that refusal, and are not downgraded by it (maintainer ruling 2026-08-25, #1548): they read this knob at their OWN registration site (
kBuiltinPointToPointTraitsinbuiltin_transports.hpp) and declareself_heal_dial = falseon a closed-out build, keeping the eager dial they always had. The refusal above targets a kind that CLAIMS an engine the image does not carry; a build-conditioned declaration claims nothing it cannot have. Making the refusal fire for the built-ins instead would dropudp/tcp/wsout of the catalog and turn the lean default into a broken build.
-
static constexpr std::size_t kSelfHealWorkerStackBytes = 0¶
Stack bytes for the link-liveness engine’s worker thread;
0= the platform default (#1470).The worker is the sole dialer and the sole liveness publisher, so its stack has to carry a
connect()and the publish fan-out beneath it. On a POSIX host the platform default is megabytes of lazily-committed address space and nobody has to think about it. On an RTOS target it is a REAL allocation from the heap, per link: ESP-IDF’sstd::threadis a pthread, and a pthread takesCONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULTfrom a board that may have ~76 KB free — 12,288 B per link on the sdkconfig #1470’s reporter measured. There was no way to size it, which made the engine unusable on such a target INDEPENDENTLY of whether the flash was affordable — the second half of #1470.Applied through
pthread_attr_setstacksizeat the spawn, which is the same mechanismposix_endpoint_t::startandsocketcan_link_t::startalready use for their receive threads, and which IDF’s pthread honours. So it is not an ESP-only knob with an inert body elsewhere: glibc honours it too. A value below the platform floor makespthread_attr_setstacksizereturnEINVALand leaves the default in place — the spawn still happens, with the default stack, exactly as the two precedents behave.Left at
0— every host build, and the default everywhere — nothing is set and the spawn is byte-for-byte the one that shipped. Override fragment:static constexpr std::size_t kSelfHealWorkerStackBytes = 4096;
-
static constexpr std::size_t kRxDrainFrames = 32¶
Frames a link’s receive context may consume back to back before it waits for its core to go idle once;
0= no frame budget (ADR-0085).A receive context that always finds more bytes waiting never blocks, so on a core it shares with the idle task a peer that keeps sending keeps that core busy for as long as it likes. On ESP-IDF that is the IDLE task’s watchdog: the board resets. The budget bounds one DRAIN, which is the run of frames a receive context consumes without its core going idle in between. When it is spent, the link stops reading and waits until the core’s idle task has run. The unread bytes stay in the socket, the TCP window closes and the peer’s own stack throttles it (ADR-0081 §2): no frame is dropped, none is buffered by the library, and nothing measures time. The drain counter restarts whenever the core idles on its own, so a link that is not saturated never waits.
Its one reader is the ESP-IDF
httpd_ws_link_t, whose receive context is theesp_http_servertask. Hosted links read the socket from their own thread under a preemptive OS scheduler and do not consult it.Default 32 — sized against the watchdog, not against throughput. The longest stretch the idle task can now be kept out is one budget of frames plus whatever higher-priority work follows it. At the ~7.3 ms of board time per small request measured on an ESP32-C6, 32 frames is about 0.23 s, under a quarter of the shortest task watchdog IDF offers (1 s) and a twentieth of its default (5 s). Each wait costs the time until the core idles, which on a link that is the only load is the time the Wi-Fi and TCP/IP tasks need to settle; per 32 frames that is a small share of the burst. A target whose frames are much dearer than that lowers it.
A non-zero budget requires that the core idles. The wait ends only when the idle task runs, and the idle task runs only once every other task on the core has blocked. So after one budget of back-to-back frames the receive context yields to EVERY lower-priority ready task on its core until all of them block: a priority inversion that bounds the link’s read rate under sustained load by the longest run of lower-priority work. A build whose core may never idle (one that runs a low-priority task that never blocks, and has turned the idle-task watchdog check off for it) must bind
0here and in kRxDrainBytes, or the link stops reading for good after one budget. The ESP-IDF component defaults both to0unless the task watchdog watches the idle task of every core. Override fragment:static constexpr std::size_t kRxDrainFrames = 16;
-
static constexpr std::size_t kRxDrainBytes = 32768¶
Payload bytes a link’s receive context may consume back to back before it waits for its core to go idle once;
0= no byte budget (ADR-0085).The byte half of kRxDrainFrames, with the same reader, the same wait and the same reset. Frames are what a small-write flood costs; bytes are what a large-frame flood costs, where every frame is copied and decoded. A drain ends when EITHER budget is spent. The frame that crosses the line is read whole (a frame is never split), so one drain consumes at most this many bytes plus one frame. The same requirement holds: a non-zero value needs a core that idles (see kRxDrainFrames).
Default 32,768 — the ESP-IDF link’s own per-frame cap, so one maximum-size frame is one drain, and about six default lwIP receive windows. Override fragment:
static constexpr std::size_t kRxDrainBytes = 16384;
-
static constexpr bool kInstrumentCounters = false¶
Whether tr::graph::graph_t carries its two INSTRUMENTATION counters —
ancestor_walks()andtarget_canonical_resolves()(#1664).Both are relaxed 64-bit
fetch_adds on a node-wide counter that nothing in the library reads: no:statsnoun, no wire surface, no decision. Only tests and benches consume them, as ablations proving a write took the leg its name claims — the bubbling walk (RFC-0005) and the target-edge canonical fallback (#830). Paying for them on a shipped node is pure loss, and the loss is not uniform: on rv32 a 64-bit atomic RMW is a libatomic call, taken once per write that has an ancestor subscriber and once per unbound target-edge delivery.Default — the lean choice. Closed out, the two members occupy no bytes of
graph_t([[no_unique_address]]over an empty type), the two increment sites compile to nothing, and the accessors answer0. It also arms the RFC-0022 §6 pin/copy branch counters ofpin_instrument.hpp, which were theLIBTRACER_PIN_INSTRUMENTmacro until #1722; that macro is now refused at compile time. A compile-time member rather than a macro for ADR-0068’s reason: a macro can differ per TU, and these counters changegraph_t’s layout.Who sets it. The core test build and the
bench/build’sLIBTRACER_INSTRUMENT_COUNTERSoption opt in through the checked-in preset fragmentcore/tests/instrumented/libtracer/config_override.hpp. A test that asserts on either counter gates the assertion on this knob, so a CI leg binding its own fragment (which inheritsfalse) still runs the rest of the test. Override fragment:static constexpr bool kInstrumentCounters = true;
-
static constexpr bool kFaultInjection = false¶
Whether the TEST-ONLY fault-injection hooks are compiled in (#1719):
tr::detail::probe_fail_hook,tr::detail::write_fault_inject_hook,tr::net::detail::ws_peer_published_hookandtr::net::detail::tcp_peer_publishing_hook.Each hook is a process-wide function pointer that a test arms to drive a path a healthy host never takes: a nothrow draw the heap refuses, a write errno glibc never returns, a handshake instant held open. Nothing in a shipped node ever arms one, yet each check it guards was a load and a branch on that node — the
probe_fail_hookone on EVERY draw from the process-default block source and every nothrow growth.Default — the lean choice. Closed out, every check is an
if constexprbranch that compiles to nothing: the default allocation path carries no hook load, and no hook variable is odr-used, so none is emitted into the library. The declarations stay visible either way, so a test that arms a hook still compiles under a fragment that leaves thisfalse; arming one there simply changes nothing.Who sets it. The core test build, through its checked-in preset fragment
core/tests/instrumented/libtracer/config_override.hpp, the fragmentcore/CMakeLists.txtwrites for the deprecated-Dknobs when that same test build passes them, and every CI leg that binds a fragment of its own and runs the fault-injection tests. Override fragment:static constexpr bool kFaultInjection = true;
-
static constexpr bool kAllowInsecureTls = false¶
Whether a connection SPEC may carry the
insecurekey of thequicandwebtransportkinds — the dial-side switch that skips server-certificate verification.insecureis a DEV-ONLY convenience for reaching a self-signed peer. A SPEC is a wire write, and on a build without an ACL policy any connected peer may write one, so honouring the key unconditionally would let a peer make this node dial with peer authentication switched off. Whether that key is honoured at all is therefore a decision the BUILD states, not the SPEC.Default — the lean and safe choice. Closed out, a SPEC whose config carries
insecure= nonzero is REFUSED at creation: the factory answersgraph::status_t::PERMISSION_DENIEDand counts the refusal (tr::net::quic_insecure_refusals()/tr::net::webtransport_insecure_refusals(), in the respective module header). It is never silently ignored — a dial that asked for no verification and quietly got verification would fail later for a reason nobody wrote down — and never honoured.insecure=0is the explicit “verify” spelling and is accepted either way. An app TLS profile whoseca_filecertifies the peer (selected by the SPEC’stlskey, seetr::net::tls_profile_t) is the way to reach a privately-issued or self-signed peer on a closed-out build.Only the SPEC path is gated. An application that constructs a transport itself with
quic_dial_tls_t{.insecure_no_verify = true}has made that choice in its own C++; no peer can reach that field.Who sets it. A development or test build that dials self-signed peers through a SPEC. The
quicCI workflow runs the QUIC and WebTransport tests a second time under the checked-in fragmentcore/tests/insecure-tls/libtracer/config_override.hpp. Override fragment:static constexpr bool kAllowInsecureTls = true;
-
using acl_policy_t = allow_only_policy_t¶
-
using tr::graph::config_t = default_config_t¶
THE configuration this build uses — the one binding, and the one thing to override.
An override fragment binds this alias to its own traits type; with no fragment present it names default_config_t. Everything below is derived from it, so nothing else changes.
-
constexpr std::size_t tr::graph::kVertexLockStripes = config_t::kVertexLockStripes¶
default_config_t::kVertexLockStripes for this build.
-
constexpr std::size_t tr::graph::kCacheLineBytes = config_t::kCacheLineBytes¶
default_config_t::kCacheLineBytes for this build.
-
constexpr std::size_t tr::graph::kGuardStripes = config_t::kGuardStripes¶
default_config_t::kGuardStripes for this build.
-
constexpr std::size_t tr::graph::kHazardReaderSlots = config_t::kHazardReaderSlots¶
default_config_t::kHazardReaderSlots for this build.
-
constexpr std::size_t tr::graph::kInlineFanout = config_t::kInlineFanout¶
default_config_t::kInlineFanout for this build.
default_config_t::kShareThresholdBytes for this build.
-
constexpr bool tr::graph::kInstrumentCounters = config_t::kInstrumentCounters¶
default_config_t::kInstrumentCounters for this build.
-
constexpr bool tr::graph::kFaultInjection = config_t::kFaultInjection¶
default_config_t::kFaultInjection for this build.
-
using tr::graph::acl_policy_t = config_t::acl_policy_t¶
default_config_t::acl_policy_t for this build.
-
using tr::graph::lkv_slot_t = config_t::lkv_slot_t¶
default_config_t::lkv_slot_t for this build.
-
using tr::graph::guard_t = detail_guard::sized_guard<config_t>::type¶
The critical-section guard for this build: default_config_t::guard_t, with the inherited host guard sized by
kCacheLineBytesandkGuardStripes(#1716).
-
using tr::graph::reclaim_policy_t = config_t::reclaim_policy_t¶
default_config_t::reclaim_policy_t for this build.
-
constexpr std::size_t tr::graph::kDeferredReleaseSlots = config_t::kDeferredReleaseSlots¶
default_config_t::kDeferredReleaseSlots for this build.
-
constexpr std::size_t tr::graph::kQsbrParticipants = config_t::kQsbrParticipants¶
default_config_t::kQsbrParticipants for this build.
The derived constants that are not in tr::graph: the memory layer and the transport
plane read their own spellings, so that neither L0 nor tr::net has to name an L4 type.
-
constexpr bool tr::mem::kSpinWaitSafe = tr::graph::config_t::kSpinWaitSafe¶
Whether a task on this target may SPIN-WAIT for a lock another task holds.
The memory layer’s spelling of tr::graph::default_config_t::kSpinWaitSafe, which carries the full rationale. Derived from tr::graph::config_t exactly as the
tr::graphloose names are, so an override fragment sets it in the one place every knob is set.A target fact, so the BUILD states it and nothing asks the integrator (the same reasoning that derives tr::graph::kCacheLineBytes rather than exposing it). Its one consumer is the guard in
synchronized_pool_t, which refuses to instantiate the spinlock policy where spin-waiting is unsafe.
-
constexpr std::size_t tr::mem::kDeviceBackendSlots = tr::graph::config_t::kDeviceBackendSlots¶
How many
DEVICE-space backends may register a transfer hook at once.The memory layer’s spelling of tr::graph::default_config_t::kDeviceBackendSlots, which carries the full rationale. Derived from tr::graph::config_t exactly as kSpinWaitSafe is, so an override fragment sets it in the one place every knob is set. Its one consumer is the bounded table behind register_device_backend (
device_backend.cpp).
-
constexpr bool tr::net::kBusLinks = tr::graph::config_t::kBusLinks¶
Whether this target carries the ADR-0044 BUS facet at all (peer-named links).
The transport plane’s spelling of tr::graph::default_config_t::kBusLinks, which carries the full rationale, the measured saving and the refusal rule. Derived from tr::graph::config_t exactly as tr::mem::kSpinWaitSafe is, so an override fragment sets it in the one place every knob is set. Its consumers reach it through
tr::net::bus_of(transport.hpp) rather than reading it directly.
-
constexpr bool tr::net::kSelfHealLinks = tr::graph::config_t::kSelfHealLinks¶
Whether this target carries the RFC-0014 §4 S5 link-liveness engine at all.
The transport plane’s spelling of tr::graph::default_config_t::kSelfHealLinks, which carries the full rationale, the measured saving and the refusal rule. Derived from tr::graph::config_t exactly as kBusLinks is, so an override fragment sets it in the one place every knob is set.
-
constexpr bool tr::net::kAllowInsecureTls = tr::graph::config_t::kAllowInsecureTls¶
Whether a connection SPEC may carry the dial-side
insecurekey.The transport plane’s spelling of tr::graph::default_config_t::kAllowInsecureTls, which carries the rationale. Derived from tr::graph::config_t exactly as kBusLinks is, so an override fragment sets it in the one place every knob is set. Its consumers are the
quicandwebtransportfactories.
-
constexpr std::size_t tr::net::kSelfHealWorkerStackBytes = tr::graph::config_t::kSelfHealWorkerStackBytes¶
Stack bytes for the link-liveness engine’s worker thread;
0= platform default.The transport plane’s spelling of tr::graph::default_config_t::kSelfHealWorkerStackBytes, which carries the rationale. Its one consumer is the
pthread_attr_setstacksizeat the worker spawn (self_heal_link.cpp).
-
constexpr std::size_t tr::net::kRxDrainFrames = tr::graph::config_t::kRxDrainFrames¶
Frames a receive context consumes back to back before it waits for its core to idle;
0= no frame budget.The transport plane’s spelling of tr::graph::default_config_t::kRxDrainFrames, which carries the rationale. Its one consumer is the ESP-IDF
httpd_ws_link_t.
-
constexpr std::size_t tr::net::kRxDrainBytes = tr::graph::config_t::kRxDrainBytes¶
Payload bytes a receive context consumes back to back before it waits for its core to idle;
0= no byte budget.The transport plane’s spelling of tr::graph::default_config_t::kRxDrainBytes, which carries the rationale. Its one consumer is the ESP-IDF
httpd_ws_link_t.
The selectable policies¶
-
struct allow_only_policy_t¶
The required-modules MCU profile policy (ADR-0020 core subset): ALLOW-only.
Any applicable ACE grants — order is irrelevant because DENY does not exist in this profile (a
:aclwrite carrying one is rejected at parse time).Public Static Functions
-
static inline acl_verdict_t allows(std::span<const std::byte> subject, std::uint32_t bit, std::span<const ace_t> aces, std::uint64_t now, std::uint8_t required_flags = 0) noexcept¶
Evaluate one ACE list — pure: no locks, no clock, no graph access.
- Parameters:
subject – The resolved subject token bytes (ADR-0018).
bit – The requested right (one
acl_right_tbit).aces – One vertex’s stored ACEs, in stored order.
now – Check-time wall clock, ns since the UNIX epoch.
required_flags – ACEs lacking these flag bits are skipped —
0for the target’s own list,kAceInheritfor an ancestor’s.
- Returns:
ALLOWorNO_MATCH(this profile never returnsDENY).
Public Static Attributes
-
static constexpr bool kAcceptsDeny = false¶
This profile rejects DENY ACEs at parse time.
-
static inline acl_verdict_t allows(std::span<const std::byte> subject, std::uint32_t bit, std::span<const ace_t> aces, std::uint64_t now, std::uint8_t required_flags = 0) noexcept¶
-
struct full_acl_policy_t¶
The
security_aclhost policy (ADR-0020 full model): ordered first-match-per-bit with DENY.For the requested bit, the FIRST applicable ACE in stored order decides —
ALLOWorDENYper its type (NFSv4 evaluation). The graph calls this per effective-ACE list, own list before ancestors, so cross-list ordering follows the effective-ACL definition of ADR-0020.Public Static Functions
-
static inline acl_verdict_t allows(std::span<const std::byte> subject, std::uint32_t bit, std::span<const ace_t> aces, std::uint64_t now, std::uint8_t required_flags = 0) noexcept¶
Evaluate one ACE list — pure: no locks, no clock, no graph access.
- Parameters:
subject – The resolved subject token bytes (ADR-0018).
bit – The requested right (one
acl_right_tbit).aces – One vertex’s stored ACEs, in stored order.
now – Check-time wall clock, ns since the UNIX epoch.
required_flags – ACEs lacking these flag bits are skipped —
0for the target’s own list,kAceInheritfor an ancestor’s.
- Returns:
ALLOWorNO_MATCH(this profile never returnsDENY).
Public Static Attributes
-
static constexpr bool kAcceptsDeny = true¶
The full model stores and evaluates DENY ACEs.
-
static inline acl_verdict_t allows(std::span<const std::byte> subject, std::uint32_t bit, std::span<const ace_t> aces, std::uint64_t now, std::uint8_t required_flags = 0) noexcept¶
-
class single_writer_slot_t : public tr::graph::basic_single_writer_slot_t<guard_t>¶
basic_single_writer_slot_t over this build’s
tr::graph::guard_t— the name an override fragment binds.A class rather than an alias so
config.hppcan forward-declare it: the fragment names the slot before this header has been seen, and the guard it will use is a member of the very traits type the fragment is defining.
-
template<::tr::guard G>
class basic_single_writer_slot_t¶ The slot for a single-writer build (RFC 0028 §5.5): one
value_t*, exchanged and retained inside the guardG, which never spins.Why it exists (#1618). The refcount slot this replaced,
std::atomic<std::shared_ptr>, is spin-locked in libstdc++:loadandstoretake a pointer-lock bit and a contender spins on it withsched_yield. On a priority-preemptive single-core scheduler,sched_yieldyields only to equal or higher priority, so a high-priority reader that preempts a low-priority writer inside that window spins until the task watchdog fires. Here the window is a guard that cannot be spun on: an interrupt-masked critical section cannot be preempted at all, and the host’s mutex_guard_t puts a contender to sleep once it has spun out.What the guard covers, and what it does not.
storeswaps the pointer inside the guard and releases the displaced value AFTER leaving it, so a value’s link destructors and its block source never run with interrupts masked.loadretains inside the guard, so the refcount increment cannot interleave with the writer’s release. Both sections are a handful of instructions and call nothing that can block.Writers are serialized too. Nothing here relies on a single publisher: two writers serialize on the guard like a writer and a reader do, so a build that publishes one vertex from two threads is still memory-safe. The name comes from RFC 0028 §5.5, where the single-writer build is the one that must bind it.
Why one publisher would not let the writer skip the guard, even now the slot is one word. It is tempting: one publisher, so nothing to exclude on the write side, publish with a single atomic
exchangeand let readersacquire. The slot IS one word since RFC 0028 slice 3 (an intrusivevalue_t*), so the torn two-word swap theshared_ptrslot had is gone — but the race that matters never needed two words. A reader inside its guard has loaded the pointer and not yet retained it; a writer that exchanges outside the guard goes on toreleasethe displaced value, and if that was the last reference the block is freed under the reader’sretain. The reader’s guard excludes the writer only if the writer’s exchange is inside a guard too: exclusion is pairwise, and the single-writer contract says nothing about readers. So the writer keeps the guard on every target, which is why the per-buildkSingleWriterpromise was removed (#1718): it had nothing left to unlock. (RFC 0028 §5.5’s sentence that a reader’sretaininside its guard “cannot interleave with the writer’s release” once the slot is one word is the claim this paragraph corrects.)lkv_slot_test’s one-writer / N-reader run is the test that bites when this is tried: with the writer’s guard removed, a reader reads a value after its free (ASan: heap-use-after-free).- Template Parameters:
G – A
tr::guard; the slot takesG::for_address(this). The bound slot usesconfig_t::guard_t; tests instantiate this template directly with a guard of their own.
Public Types
Public Functions
-
inline bool store(value_t *v, std::memory_order = std::memory_order_seq_cst) noexcept¶
Publish. The swap happens inside the guard; the displaced value is released after it, outside.
No fence follows the guard. What
vertex_t::storeneeds from the slot is that the value is visible to anyone who observes the nextwrite_seq_bump, and the bump is aseq_cstread-modify-write sequenced after the guard’s release, so it already carries the swap. The waiterless-publish argument (#555) is aboutwrite_seq_and the waiter count only.- Parameters:
v – The value to publish; the slot adopts the caller’s reference. Null clears.
- Returns:
Always
true. A swap allocates nothing, so there is no failure to report.
-
template<class F>
inline bool store(value_t *v, F &&in_section) noexcept¶ Publish, and run
in_sectioninside the SAME section as the swap (#1715).The fused publish of a core with no atomic read-modify-write: there the write sequence’s bump is itself a section of
G(tr::rmw_counter_t’s guarded binding), so publishing through store and then bumping opened two sections back to back. Passing the bump in here (rmw_counter_t::bump_in_section) opens one. The caller must name this slot’s address as the anchor of every OTHER bump of that counter, so that every bump still serializes on one guard.Publication, restated for the fused form. store’s note relies on the bump being a
seq_cstRMW sequenced after the guard’s release. Here the bump is a plain load and aseq_cststore, made inside the guard, after the swap. A reader that observes the new sequence value and then reads the slot still sees the swap: itsload()takes the same guard, and its section cannot come first, because the reader’s read of the sequence is sequenced before its section opens, while the store it read is sequenced after this section opened. A load cannot read a store that happens after it. So the reader’s section follows this one, and the guard’s release/acquire carries the swap to it. The writer’s Dekker pair is unchanged: the bump is still aseq_cststore, sequenced before thewaitersload the vertex makes after this returns.- Parameters:
v – The value to publish; the slot adopts the caller’s reference.
in_section – Called once, inside the guard, after the swap. Must not throw, block or allocate: it runs with interrupts masked on an RTOS chip.
- Returns:
Always
true, as store.
-
inline void clear(std::memory_order = std::memory_order_seq_cst) noexcept¶
Drop the published value. Releases a reference outside the guard; cannot fail.
-
inline value_ref_t load() const noexcept¶
Read the published value: one guarded
retain.The retain is the refcount increment that lets the handle outlive the guard, and it is the only work inside it.
-
inline ~basic_single_writer_slot_t()¶
Release whatever is still published. No reader can be inside the guard by now.
-
template<std::size_t LineBytes, std::size_t Stripes>
struct basic_mutex_guard_t¶ The host guard: a one-word lock, and a table of them striped by address.
On a single-core RTOS the guard is an interrupt-masked critical section (the ESP-IDF component binds
tr::esp::critical_guard_t), which makes the window unpreemptable. A host process cannot mask interrupts, so this is the host’s spelling of the same promise: a contender that finds the window held gives the CPU back to whoever holds it, instead of spinning until the holder is scheduled again.Public Functions
-
inline void lock() noexcept¶
Take the lock: one RMW, and the out-of-line wait only when it was held.
-
inline void unlock() noexcept¶
Give the lock back: a release store, and nobody to notify (see the class).
Public Static Functions
-
static inline basic_mutex_guard_t &for_address(const void *at) noexcept¶
The stripe the address
athashes to: the top bits of a Fibonacci hash.
Public Static Attributes
-
static constexpr std::size_t kAlign = std::max(LineBytes, alignof(std::atomic<bool>))¶
Each lock’s alignment:
LineBytes, raised to the flag’s natural alignment.
-
static constexpr std::size_t kStripes = Stripes¶
Stripes in the process-wide table for_address draws from.
-
static constexpr unsigned kSpinsBeforeNap = 128¶
Re-reads of a held flag before a contender sleeps; covers a cross-core release.
-
static constexpr std::chrono::microseconds kNap = {20}¶
How long a contender sleeps between looks once it has spun out.
Only a holder descheduled inside its few-instruction window makes anyone sleep, so this bounds the extra latency of that rare case, not the common one. Bounded from below by what one
nanosleepcosts anyway.
-
static constexpr bool is_isr_safe = false¶
A host thread lock, never an ISR’s.
-
static constexpr bool is_nonblocking = false¶
A contender that spins out SLEEPS — an OS wait (#928).
-
static constexpr bool may_spin = false¶
The wait naps rather than spins, so it is safe on any target (see the class).
-
static constexpr const char *name = "mutex_guard"¶
Census name.
-
inline void lock() noexcept¶
-
typedef basic_mutex_guard_t<64, 64> tr::mutex_guard_t¶
The host guard at its default sizing: 64 stripes, each padded to a 64-byte line.
default_config_t::guard_tnames it; a build that inherits that binding gets the guard re-sized from its ownkCacheLineBytesandkGuardStripes(seeconfig.hpp).
-
struct no_guard_t¶
The guard that guards nothing — for a build that is single-threaded by contract, and the default policy of a pool one thread owns.
A build that binds
hazard_slot_tnever opens a guard, so this is never taken there. A build that bindssingle_writer_slot_t(or a pool) over this guard is asserting that no other thread ever touches the guarded state concurrently — true of a single-threaded program, and of atr::mem::pool_source_twhose one owner is the only thread that draws from it (ADR-0067). It is empty, so[[no_unique_address]]erases it.Public Functions
-
inline void lock() noexcept¶
No section to open.
-
inline void unlock() noexcept¶
No section to close.
Public Static Functions
-
static inline no_guard_t &for_address(const void*) noexcept¶
The one instance every address shares; it holds nothing.
Public Static Attributes
-
static constexpr bool is_isr_safe = false¶
Guards nothing, so promises nothing.
-
static constexpr bool is_nonblocking = true¶
Does nothing, so never waits.
-
static constexpr bool may_spin = false¶
Never waits at all.
-
static constexpr const char *name = "no_guard"¶
Census name.
-
inline void lock() noexcept¶
-
struct reclaim_strict_t¶
**
reclaim_strict** — the grace point is the momentunsubscribe()returns.The opt-in ZERO-COST mode (ADR-0080 §Decision 2), for an MCU deployment that provably never unsubscribes from inside a dispatch. Nothing is tracked on the dispatch path, no state is held anywhere, and
unsubscribe()runs the release hook inline before it returns — so the caller may free its context on that return with no further ceremony.Re-entrant unsubscribe is FORBIDDEN, not merely discouraged: unsubscribing from inside a delivery would retire a pair the running fan-out’s snapshot still names. A debug build asserts on it (see
graph.cpp); anNDEBUGbuild cannot see it, which is the trade this policy exists to make. Select it only where the deployment can show that re-entrant unsubscribe does not occur — otherwise take the default, which supports it.Selecting it is one line in
libtracer/config_override.hpp:using reclaim_policy_t = reclaim_strict_t;Public Static Attributes
-
static constexpr std::string_view kName = "reclaim_strict"¶
The policy’s name, for a diagnostic that must say which one is bound.
-
static constexpr bool kDefersToDispatchExit = false¶
Whether a retired pair may be DEFERRED past
unsubscribe()’s return.False here: there is no grace period to defer into, so
unsubscribe()releases inline and the dispatch path carries nothing at all.
-
static constexpr bool kReentrantUnsubscribe = false¶
Re-entrant
unsubscribe()(from inside a delivery) is not supported.
-
static constexpr bool kGraceSpansThreads = false¶
Whether the grace point is stated over EVERY thread rather than one.
False here: there is no grace period at all, so there is nothing for a second thread to be inside. See reclaim_qsbr_t for the policy that answers true and what changes.
-
static constexpr std::string_view kName = "reclaim_strict"¶
-
struct reclaim_local_t¶
**
reclaim_local** (the DEFAULT) — the grace point is the moment this thread’s dispatch stack unwinds to depth 0.It is the default because it makes the MCU and the host build behave IDENTICALLY (ADR-0080 §Decision 1).
reclaim_strictwould make the same application code legal on a host that tolerates re-entrant unsubscribe and illegal on the constrained target — a portability bug that surfaces only where it is hardest to debug.What a caller gets¶
Exactly one of two things, and the library decides which — the caller never asks:
**
unsubscribe()was called from outside any delivery** (dispatch depth 0 — the ordinary case). No delivery to that context can be in flight on this thread, so the release hook runs INLINE andunsubscribe()returns already quiescent. This isreclaim_strict’s guarantee, delivered atreclaim_strict’s cost, for the case that dominates.**
unsubscribe()was called from INSIDE a delivery** (a subscriber callback unsubscribing itself or a sibling). The running fan-out is walking a snapshot that still names the retired pair, so the pair is PARKED and the hook runs when the outermost delivery on this thread returns — i.e. before thewrite()/propagate()that started it hands control back.
In both cases the signal is the hook. There is no poll, no wait, and no verb the embedder must remember to call.
The scope of the guarantee¶
It is stated over one thread’s dispatch domain, which is the WIDE / MCU target this policy is for: a single-threaded node, where publish and
unsubscribe()cannot overlap because there is no second thread to overlap with. An embedder that dispatches from several threads concurrently and unsubscribes from another needs a grace period spanning every thread — that is reclaim_qsbr_t, ADR-0080’s third policy (#894, #1376), not this one.What it costs¶
One non-atomic increment, decrement and branch per
fan_out— regardless of subscriber count — on a per-thread counter, so no cache line is ever shared and no atomic is involved. Parking allocates nothing: the retired pairs sit in a bounded per-thread array sized by tr::graph::default_config_t::kDeferredReleaseSlots.Public Static Attributes
-
static constexpr std::string_view kName = "reclaim_local"¶
The policy’s name, for a diagnostic that must say which one is bound.
-
static constexpr bool kDefersToDispatchExit = true¶
Whether a retired pair may be DEFERRED past
unsubscribe()’s return.True here: a re-entrant unsubscribe parks its pair and the outermost dispatch’s exit releases it. The deferral happens ONLY at depth > 0 — at depth 0 the release is inline.
-
static constexpr bool kReentrantUnsubscribe = true¶
Re-entrant
unsubscribe()(from inside a delivery) is supported.
-
static constexpr bool kGraceSpansThreads = false¶
Whether the grace point is stated over EVERY thread rather than one.
False here, and it is the ONE limitation of this policy: the grace point is this thread’s dispatch stack, so a sibling thread’s in-flight fan-out is invisible to it. See The scope of the guarantee.
-
struct reclaim_qsbr_t¶
**
reclaim_qsbr** — the grace point is the moment EVERY dispatching thread has passed a quiescent state (#1376).ADR-0080’s third policy and the MID / NARROW many-core one: bind it when this node dispatches from several threads at once and may unsubscribe from a thread other than the one delivering. That is the single case neither shipped policy covers — reclaim_local_t’s grace point is one thread’s dispatch stack, so a sibling thread’s live snapshot is invisible to it, and reclaim_strict_t has no grace period at all.
Selecting it is one line in
libtracer/config_override.hpp:using reclaim_policy_t = reclaim_qsbr_t;What a caller gets¶
A retired
{fn, ctx}pair is released once no thread can still be walking a snapshot that names it. As under reclaim_local_t the caller never polls and never waits; it registers a subscriber_release_fn_t and is told. Two cases, and again the library picks:No participant is mid-dispatch — every ordinary unsubscribe on a node that is not concurrently publishing. The scan concludes immediately, the hook runs INLINE, and
unsubscribe()returns already quiescent. This is the same property (a) the other two policies deliver, and it still dominates.Some participant IS mid-dispatch. The pair is deferred and released by whichever participant next completes the grace period.
The one API difference, stated rather than hidden¶
In case 2 the hook runs on a thread other than the caller — specifically on whichever participant’s quiescence completed the grace period, or on the caller’s own next dispatch exit, whichever comes first. Both other policies promise the caller’s own thread; a grace period that spans threads structurally cannot, because the only alternatives are to block the caller (ADR-0080 §Decision 4 rejects waiting outright) or to leak the pair.
So a release hook under this policy must be thread-safe with respect to its own context. That is a real widening of the contract and it is why this is an opt-in policy rather than the default: on the single-threaded target reclaim_local_t serves, the distinction does not exist, and ADR-0080 §Decision 1’s parity argument keeps the default where it is.
What it costs¶
Per OUTERMOST
fan_out— never per edge, and nothing at all on a publish nobody subscribed to, because the bracket sits belowfan_out’s no-subscriber gate:entry: one RELAXED load of a read-mostly shared line (the epoch, bumped only on the control plane) and one
seq_cststore to this thread’s own cache-line-isolated cell. No atomic read-modify-write;exit: one
releasestore to that same cell, plus one relaxed load of a read-mostly count that is 0 on any node not mid-teardown, and the predictable branch it guards.
The
O(kQsbrParticipants)scan is on the RECLAIM path only — that is the precise difference from the shape #635 rejected, which put a hazard scan on the READ path. Storage is tr::graph::default_config_t::kQsbrParticipants cache-line-isolated cells plus one shared table of tr::graph::default_config_t::kDeferredReleaseSlots retired pairs, all.bss, and none of it emitted into a build that binds a different policy.What it discharges for #897¶
ADR-0080 §”#897 maps onto the same seam” asks that each thread self-drain its own retired LKV list at its own quiescent point, so
~hazard_slot_tnever has to reach across a live thread’s private list. Under this policy that is exactly what happens, and it costslkv_slot.hppno code at all: the quiescent point calls the already-shippedtr::graph::detail_hp::retire_and_flush(nullptr), whose cheap early-out makes it free on a thread that parked nothing. Nostore()-path atomic is added.Public Static Attributes
-
static constexpr std::string_view kName = "reclaim_qsbr"¶
The policy’s name, for a diagnostic that must say which one is bound.
-
static constexpr bool kDefersToDispatchExit = true¶
Whether a retired pair may be DEFERRED past
unsubscribe()’s return.True: this policy needs the very same dispatch bracket reclaim_local_t does — the transition to depth 0 IS the quiescent state it announces — and defers whenever the scan finds a participant that has not yet reached one.
-
static constexpr bool kReentrantUnsubscribe = true¶
Re-entrant
unsubscribe()is supported — subsumed by the grace period.
-
static constexpr bool kGraceSpansThreads = true¶
The grace point is stated over EVERY dispatching thread. See The one API difference, stated rather than hidden for what that changes for a release hook.
-
struct retired_callback_t¶
One retired subscription’s release obligation — the
{ctx, hook}pair a policy that defers must hold until its grace point.Trivially copyable and free of any owning member, so a bounded array of these is plain storage with no destructor and no initialization guard (which is what lets reclaim_local_t’s per-thread parking allocate nothing, ever).
Public Members
-
void *ctx = nullptr¶
The subscriber’s own context.
-
subscriber_release_fn_t release = nullptr¶
What to call on it, exactly once.
-
void *ctx = nullptr¶
-
class hazard_slot_t¶
The host slot (ADR-0069 §1): a lock-free
atomic<node_t*>reclaimed with hazard pointers, returning the same owningvalue_ref_tsingle_writer_slot_t does.Why this exists: today’s slot INVERTS under concurrent readers — measured through the real path,
graph_t::readon one shared LKV falls from 21.1 M/s at one reader to 1.7 M/s at twenty-four, because bothloadandstoretake libstdc++’s_Sp_lockerpointer-lock bit. Hazard reclamation deletes that lock; what it cannot delete is the control-block increment an owning read still owes. End to end that is worth 4.2× at twenty-four readers (7.4 M/s) — see the table in this file’s header, and ADR-0069 §6 for why the model bench’s 20.8× did not survive contact with the whole read path.A host opt-in. The gain over the refcount slot it replaced is a concurrency gain, so a single-core node buys nothing from it and still pays
(kHazardReaderSlots + 1) * 128bytes of registry, a deferred-reclamation lifetime rule (seeretire_and_flush), and a publish that can fail. Such a node binds single_writer_slot_t.Publish can fail under memory exhaustion, which single_writer_slot_t cannot: an empty free list makes the first publish per participant allocate a 16-byte node. It is reported, not silent —
storereturnsfalseandvertex_t::storeturns that into the samenullptr→BACKPRESSUREsoft-fail an LKV allocation failure already produces (#477), so no write is ever reported as taken when it was not. Every later publish reuses the node its own displacement recycled, so the window is a warm-up one — but it is still a real difference in the policy’s failure surface, and a third reason the MCU does not bind this slot. Note also that the node comes from the global heap, not from a graph’s injectedblock_source_t: the slot policy is never handed one, and a bounded target that needs every byte accounted for is another target that should bind single_writer_slot_t.It does not spin-wait. The one loop in the domain that waits on another thread is the overflow index’s lock in
detail_hp::ticket_t, and it waits withatomic_flag::wait, which blocks (a futex, or libstdc++’s pooled condition variable) after a bounded spin. The read’s announce-and-revalidate loop retries only when a publish moved the slot, which is progress.Public Functions
-
inline ~hazard_slot_t()¶
Retire the published node rather than free it — a reader may still be pinning it — and flush, so the value cannot outlive the memory it was allocated from.
A slot that was never written costs nothing here: no node, no flush, no domain access.
-
inline bool store(value_t *v, std::memory_order order = std::memory_order_seq_cst) noexcept¶
Publish, sequentially consistent unless the caller says otherwise.
A null value is not a publish — use clear. On success the node adopts the caller’s reference; on
falsethe reference is still the caller’s.- Returns:
falseif no node could be obtained for the value, in which case nothing was published and the previous value still stands. Only a participant’s first publish can reach that: every later one reuses the node its own displacement recycled, so the free list makes the steady state allocation-free.
-
inline void clear(std::memory_order order = std::memory_order_seq_cst) noexcept¶
Drop the published value. Cannot fail — it publishes
nullptr, which needs no node, so a clear allocates nothing even on a cold participant.
-
inline value_ref_t load() const noexcept¶
Read the published value.
Announce, re-read, then
retainthe value out of the pinned node — the retain is the promotion that lets the handle outlive the pin, and it is the one shared-cache-line RMW this scheme cannot remove. A slot nobody has written costs a single acquire load and never touches the domain at all.The announce and the re-read are both
seq_cstso that both sit in one total order with the publisher’sexchangeand the reclaimer’s fence: if a scan did not observe this announcement, then in that order the scan’s read precedes it, the displacement precedes the scan, and so this re-read must observe the displacement and retry.acquireon the re-read is the usual spelling and is believed sound, but it leaves the argument resting on coherence rather than on the total order — and it costs nothing to close, since aseq_cstload is a plainmovon x86-64.Reusing a node is deliberately allowed to ABA: a reader can pin
n, have it reclaimed and republished, and revalidate against the same address. That is not a bug —nis live and holds a value some writer published, which is all a read promises.
Public Static Attributes
-
static constexpr bool may_spin = false¶
No operation spin-waits on another thread; see the class comment.
-
inline ~hazard_slot_t()¶
The runtime settings reader¶
-
class config_reader_t¶
Typed accessors over a positional-pair TLV’s children — a SPEC
configSETTINGS, a SUBSCRIBER QoS SETTINGS, or the creation-SPEC envelope itself.The layout is positional NAME-key / value pairs: a
NAMEchild carrying the key string, immediately followed by the value child — aNAMEfor string values, aVALUEfor integers/flags, or a nestedSETTINGSfor a module namespace. Unknown keys are ignored (forward-compat), a key whose value child has the wrong type (or aVALUEpayload that is not EXACTLY the accessor’s width — a u32 asked of a 2-byte payload is absent, not zero-extended, #928) is ignored too, and when a key appears more than once the LAST well-formed occurrence wins.The walk is pair-consuming: it advances a whole pair at a time, so an unknown key is skipped together with its value and no value child can ever be re-read as a key (#927). Forward-compat tolerance is deliberate here, and is the OPPOSITE ruling from the
graph::parse_aclwalk (#906), which is to REJECT an unknown key: config is where a newer peer legitimately sends more than a receiver understands, whereas an ACL is a security document in which a silently dropped attribute widens access.Note
The returned string_views, spans and nodes (and the reader itself) borrow the frame bytes the node was validated over (tlv_node_t::over) — use them while those bytes are alive. The reader walks the children in place and allocates nothing (#1829).
Public Functions
-
inline explicit config_reader_t(const tlv_node_t *config) noexcept¶
Construct over
config'schildren.- Parameters:
config – The validated pair-container node; nullptr = no config (every accessor returns nullopt). The node is copied (it is two words of borrowed bytes); the bytes it borrows must outlive the reader.
-
inline std::optional<std::string_view> name(std::string_view key) const noexcept¶
The string value of
key(aNAMEvalue child), if present.
-
inline bool has(std::string_view key) const noexcept¶
Whether
keyappears as a key in any well-paired position, whatever its value’s type or width.The presence test for a key a reader must REFUSE rather than skip (a retired key whose silent omission would change behaviour): unlike the typed accessors, a value of the wrong type still counts. Same pair-consuming walk as find(), so a value child spelling
keyis never mistaken for it.
-
inline std::optional<std::span<const std::byte>> name_bytes(std::string_view key) const noexcept¶
The raw payload bytes of
key(aNAMEvalue child), if present.The byte-span twin of name() for a value that is a wire segment rather than text —
graph_t::create_childreads the creation SPEC’s “name” this way, because the child name is appended to the parent’s key verbatim.
-
inline std::optional<tlv_node_t> settings(std::string_view key) const noexcept¶
The nested
SETTINGSvalue child ofkey, or nullopt.A module namespace (“config” in the creation SPEC, a per-transport block in a connection config). A node over the same borrowed bytes, same as every accessor.
-
inline std::optional<std::uint8_t> u8(std::string_view key) const noexcept¶
The u8 value of
key(a 1-byteVALUEchild), if present.
-
inline std::optional<std::uint16_t> u16(std::string_view key) const noexcept¶
The u16 value of
key(a 2-byteVALUEchild), if present.
-
inline std::optional<std::uint32_t> u32(std::string_view key) const noexcept¶
The u32 value of
key(a 4-byteVALUEchild), if present.
-
inline std::optional<bool> flag(std::string_view key) const noexcept¶
The boolean value of
key:a 1-byteVALUEchild read as u8, nonzero = true.
-
inline explicit config_reader_t(const tlv_node_t *config) noexcept¶
See: the configuration space (what each knob costs), security & ACL, graph, fwd-router (which consumes the settings reader).