backends — the allocator seam (L0)

In one paragraph

tr::mem::mem_backend_t is a small, user-implementable interface: subclass it to bind libtracer to any memory — a heap, a fixed arena, live registers, a DMA ring. libtracer never allocates payload bytes on its own; it asks a backend. (Its own bookkeeping — a rope’s spilled link chain, the CAN splitter’s window vector, the route-handle label tables — allocates from the global heap or from an injected std::pmr::memory_resource; those seams are failable allocation and backpressure.) Three backends are provided: mem_heap (owns malloc’d bytes), mem_borrowed (wraps your bytes, frees nothing), and mem_pool (a bounded fixed-slab, alloc-or-null).

What it does

The protocol treats application data as opaque, and mem_backend_t extends that to the memory plane: libtracer is a transparent byte router (ADR-0012 — memory binding is a modular spectrum). A backend declares its own per-architecture contract (alignment, cache hooks, ISR-safety) and owns reclamation; the layers above see only segment_ts. The interface deliberately makes allocation optional (alloc may return nullptr) because many substrates — MMIO, hardware FIFOs — cannot allocate at all.

Backend

Owns

destroy does

Use

mem_heap

malloc’d bytes

frees bytes + control block

hosted targets

mem_borrowed

nothing (your bytes)

frees only the control block

live/raw, MMIO header, ROM

mem_pool

a caller slab

returns the slot to a free list

bounded / MCU / deterministic

A fourth, mem_cuda, is not part of core at all: it is a tier module under backends/cuda/, its own CMake project that consumes core (backends/cuda/CMakeLists.txt:36). It needs the CUDA toolkit and a GPU, so it is never built in CI. Core carries no vendor name and no #ifdef for it — a DEVICE-space backend plugs in by registering a transfer hook (below).

mem_pool is the bounded “custom allocator”: it carves a caller-owned slab into fixed slots with the free list threaded through the slab (no auxiliary heap), and returns nullptr when full — the BACKPRESSURE signal. pool_t is not synchronized; synchronized_pool_t<Sync> (core/include/libtracer/mem_pool.hpp:194) composes over it and guards the free list with a compile-time synchronisation policy, which is what any shared seam needs — a segment self-routes its reclaim on whatever thread drops the last reference, concurrent with a writer’s alloc. Two policies ship: the spinlock spin_sync_t for a multi-core host (sync_pool_t is the alias for that pairing) and the interrupt-disable tr::esp::portmux_sync_t for a single-core, priority-preemptive MCU (integrations/esp-idf/libtracer/include/libtracer_esp/critical_pool.hpp, aliased tr::esp::critical_pool_t — it needs FreeRTOS headers, so it ships with the ESP-IDF component rather than in core/). The target picks; nothing defaults to either.

Each concrete backend also carries four compile-time traits the module set reads without a virtual call — needs_cache_ops, is_isr_safe, is_nonblocking, owns_bytes (ADR-0047 — build-time closed module sets §2). owns_bytes is the one a caller must respect: mem_borrowed sets it false, so a segment it produced must not be stored durably.

The seam lives at L0 (tr::mem); the segments it produces are owned at L1 (tr::view). A backend constructs and reclaims tr::view::segment_t — the one sanctioned L0↔L1 boundary type, and the only tr::view symbol the L0 interface is permitted to name (ADR-0016 — substrate, zero-copy, layer namespaces §2). alloc returns a raw segment_t* with a refcount of 1; the caller adopts it with tr::view::segment_ptr_t::adopt (core/include/libtracer/segment.hpp:116). The handle-producing conveniences heap_alloc / borrow / borrow_const therefore live in tr::view, not here.

Interface

class mem_backend_t

A memory backend: the L0 seam libtracer binds any substrate behind.

Subclass this to bind libtracer to any allocator — a heap, a fixed caller-owned arena, live registers, lwIP pbufs, DMA descriptors. The interface deliberately does not make allocation mandatory: many substrates cannot allocate (MMIO, hardware FIFOs), so alloc may return nullptr.

Note

Each backend declares its own concurrency/ISR-safety contract; the protocol mandates none (docs/adr/0012).

Subclassed by tr::mem::detail::borrowed_backend_t, tr::mem::detail::borrowed_device_backend_t, tr::mem::heap_backend_t, tr::mem::pool_t, tr::mem::synchronized_pool_t< Sync >

Public Functions

inline explicit mem_backend_t(const char *name) noexcept

Construct a backend with a stable, human-readable name (e.g. “mem_heap”).

inline virtual view::segment_t *alloc(std::size_t size, alloc_hint_t hint = alloc_hint_t::NONE)

Allocate a fresh segment of at least size bytes (refcount = 1).

The returned segment is the caller’s to adopt via tr::view::segment_ptr_t::adopt. A raw segment_t* is returned, not a segment_ptr_t, to keep L0 from naming L1’s owning handle (docs/adr/0016 §2). Allocation-incapable substrates (MMIO, FIFOs) leave this default and return nullptr.

Parameters:

hint – Backend-private allocation hint; NONE for “don’t care”.

Return values:

nullptr – Backpressure (pool exhausted / OOM) or allocation unsupported.

virtual void destroy(view::segment_t *seg) noexcept = 0

Reclaim a segment whose refcount has reached zero (the only reclaim path).

Frees whatever the backend owns (the bytes and/or the segment_t control block) and nothing it does not — a borrowed backend never frees the user’s bytes. Invoked by segment_ptr_t at zero, never by user code.

Warning

Never called on a live segment.

inline virtual void before_io(view::segment_t*, io_dir_t) noexcept

Cache prep before handing the segment to a DMA transfer.

Clean or invalidate per dir so the device sees coherent memory. No-op by default and on cacheless cores (Cortex-M0/M3/M4); only DMA-class backends override it (docs/reference/09 §cache coherency).

inline virtual void after_io(view::segment_t*, io_dir_t) noexcept

Cache reconcile after a DMA transfer completes.

Invalidate per dir so the next CPU reader sees HW’s writes. No-op by default and on cacheless cores.

inline virtual std::size_t alignment() const noexcept

The alignment (bytes) this backend guarantees for allocated bytes.

inline virtual std::size_t max_segment_size() const noexcept

The largest single segment this backend can produce.

inline virtual mem_space_t space() const noexcept

The address space this backend’s segments live in (default HOST).

A DEVICE backend (one from the backends/ tier) must override this; segments inherit it (segment.hpp), and the codec uses it to skip CPU access to device links.

inline virtual backend_tag tag() const noexcept

The build-time-closed module-set tag (default UNKNOWN, ADR-0047 §2).

A backend that participates in the fast destroy dispatch overrides this to return its backend_tag; segments read it once at construction (like space). A backend that leaves the default is dispatched through its virtual destroy.

inline const char *name() const noexcept

The backend’s stable identifier (e.g. for introspection / metrics).

The DMA/allocation enums the seam uses:

enum class tr::mem::io_dir_t : std::uint8_t

Direction of a DMA / cache-coherency transfer, for the cache hooks.

The hook method carries the timing (before/after the transfer); this enum carries the direction; the backend maps the pair to clean/invalidate.

Values:

enumerator DEVICE_TO_CPU

After DMA-in: invalidate so the CPU reads HW’s writes.

enumerator CPU_TO_DEVICE

Before DMA-out: clean so HW reads the CPU’s writes.

enum class tr::mem::alloc_hint_t : std::uint32_t

Opaque, backend-private allocation hint.

A hint’s meaning is private to the backend that defines it: there is no cross-backend hint registry, no two backends share a value’s meaning, and a hint-ignoring backend accepts any value (docs/adr/0016 §”Considered options”). This strong typedef also stops a hint being swapped for a size argument.

Values:

enumerator NONE

“Don’t care” — the default for every alloc call.

The failable-block seam — block_source_t

The second L0 seam, and a distinct one (ADR-0065 — failable allocation gets its own seam, reference/09). mem_backend_t above vends refcounted segments for payload bytes; this one vends raw single-owner blocks and reports exhaustion by value, because std::pmr::memory_resource structurally cannot — its allocate signals failure only by throwing, and on a -fno-exceptions target that lowers to the toolchain’s abort() stub, which a peer can provoke.

The policy the seam exists to serve — which allocations a peer can reach, which status each exhaustion answers with, and how to size a bounded source — is described in failable allocation and backpressure. This page documents only the API.

class block_source_t

The nothrow block seam every FAILABLE allocation draws from — the ones a PEER can provoke (#551, ADR-0065; ADR-0039 erratum 5/6).

RFC-0014 made vertex registration a runtime, wire-driven operation: a peer’s CREATE frame reaches register_vertex_key. Every allocation on that path is an unguarded throwing one, and ESP-IDF link-wraps __cxa_throw / __cxa_allocate_exception to abort() stubs — so on the shipping profile a peer can reboot the node by exhausting the heap. This seam is the failure-by-value answer: exhaustion returns nullptr and the operation answers BACKPRESSURE.

Nor does a budget-tracking variant fix it — one that counts its own bytes and answers nullptr at the ceiling before delegating. Tracking a budget does not make the adapter honest: a FRAGMENTED pmr resource can throw well BELOW the budget, so the adapter is correct except exactly when the underlying resource is in the state the bound was supposed to protect against. Such an adapter is deliberately not offered and must not be added.

**The supported answer for “reuse my existing arena” is pool_source_t’s span constructor**, which carves from a caller-provided slab with caller-provided size classes and is not pmr at all — point it at the same storage the pmr resource was partitioning, rather than at the resource. The one direction that IS offered is the opposite one: tr::mem::source_resource_t (mem_source_pmr.hpp) serves a std::pmr container FROM a block_source_t.

Note

“Failable”, not “control-plane”: CONTEXT.md already binds control plane to the : field-write addressing plane, and this seam is orthogonal to that axis — a DATA-plane branch write is one of its first consumers.

Note

Deliberately NOT a std::pmr::memory_resource, and not derived from one. That type’s allocate is annotated __attribute__((__returns_nonnull__)) (libstdc++ bits/memory_resource.h), so a caller’s if (p == nullptr) is undefined-behaviour-deletable. Measured on riscv32-esp-elf-g++ 15.2.0 with the deployment flags: the soft-fail branch survives at -O0/-O1/-O2/-O3 and is GONE at -Os/-Oz — the level the reference node ships at (CONFIG_COMPILER_OPTIMIZATION_SIZE), and the level at which no job exercises an allocation-failure path (see ADR-0065 §1). Inheriting would keep that allocate() publicly callable on this object, one token away from every correct try_alloc call site, with no diagnostic at any warning level. A separate type makes the slip a compile error.

Warning

DO NOT WRAP A std::pmr::memory_resource BEHIND THIS SEAM. The note above says why this type is not a pmr resource; this one is about the REVERSE adaptation, which is the mistake a host migrating an existing pmr arena actually makes (#1493). The obvious adapter compiles, looks correct and passes review:

void* try_alloc(std::size_t n, std::size_t a) noexcept override {
    return mr_->allocate(n, a);   // <-- CANNOT report exhaustion
}
std::pmr::memory_resource::allocate signals exhaustion only by THROWING and has no nothrow form, so this try_alloc either succeeds or never returns — it never answers nullptr. Under -fno-exceptions the throw reaches ESP-IDF’s link-wrapped __cxa_throwabort() stub, which is exactly the reboot-a-node-by-exhausting-the-heap failure this seam exists to remove, reintroduced by a class whose declaration promises the opposite. A comment on the caveat does not fix it; it labels the landmine.

Note

Also distinct from mem_backend_t, which vends a refcounted view::segment_t. Control-plane blocks have a single owner and no header; a refcount on them is pure overhead (a segment_t measures 20 B on rv32 / 40 B on x86-64 against an 80 B vertex_t).

Note

Blocks are host-owned storage: the source MUST outlive the graph_t and every object built in its blocks. Teardown is driven by whoever holds the source, never by the object itself — a vertex_t has no room for the pointer (core/tests/vertex_size_test.cpp).

Note

Each source declares its own concurrency contract, exactly as mem_backend_t does (ADR-0012). The RFC-0014 wire-driven registration path runs on a transport thread, so an injected source must be thread-safe on that target. heap_source_t is.

Subclassed by tr::mem::bump_source_t, tr::mem::heap_source_t, tr::mem::null_source_t, tr::mem::pool_source_t< Sync >

Public Functions

inline explicit constexpr block_source_t(const char *name) noexcept

Construct a source with a stable, human-readable name (e.g. "heap").

virtual ~block_source_t() = default

Sources are held by pointer and outlive their users; virtual teardown.

block_source_t(const block_source_t&) = delete

Non-copyable — a source is an identity, not a value.

block_source_t &operator=(const block_source_t&) = delete

Non-assignable.

virtual void *try_alloc(std::size_t bytes, std::size_t align = alignof(std::max_align_t)) noexcept = 0

Obtain bytes of storage aligned to at least align — NOTHROW.

Parameters:
  • bytes – Size of the block; a zero-sized request is implementation-defined and callers do not make one.

  • align – Minimum alignment, a power of two.

Return values:

nullptr – Exhaustion. The caller answers BACKPRESSURE; it never falls back to the global heap and never aborts.

virtual void release(void *p, std::size_t bytes, std::size_t align = alignof(std::max_align_t)) noexcept = 0

Return a block previously handed out by try_alloc.

Warning

bytes and align MUST match the originating try_alloc call (sized reclaim), so a bump or pool source needs no per-block header.

inline const char *name() const noexcept

The source’s stable name, for census and diagnostics.

inline virtual source_stats_t stats() const noexcept

This source’s census — the interface-level introspection seam (#1492, #1503).

The whole vocabulary of this seam used to be name, so a host holding a block_source_t& could introspect NOTHING: not the ceiling it injected, not how much of it was gone, and above all not whether anything had been refused — try_alloc nullptr was uncounted at every implementation in the tree.

Optional, in the tr::net::transport_t::drop_stats mould (#932): the DEFAULT is all-zero, which is the honest answer for a source that counts nothing, never a fabricated number. Concrete sources override it — bump_source_t and pool_source_t do; heap_source_t does not (the platform heap’s ceiling is not this seam’s to report), and neither does null_source_t, whose refusals are its whole contract and are the CALLER’s to count.

Counted, never enforced, and never on the hot arm: the refusal counters are bumped only where try_alloc is already returning nullptr, so a successful allocation pays nothing at all (core/STYLE.md §Introspection, counting doctrine 1 — ADR-0039’s bench_forward_heap == 0 hop and ADR-0067’s rv32 text figure are the standing referees).

The seam’s census block — the one introspection vocabulary every bounded resource in the tree answers with (core/STYLE.md §Introspection).

struct source_stats_t

One block source’s census, in the unified introspection vocabulary (core/STYLE.md §Introspection; #1503).

The five nouns every bounded resource answers with, spelled the same way here as at every other seam: an effective ceiling, used-polarity occupancy, a high-water mark, and the two numbers a sizing operator actually needs — how often a request was refused, and how big the biggest refused one was (#1492: the TAIL is what refuses, so a median request size tells the operator nothing).

All-zero is the honest default for a source that counts nothing, exactly as tr::net::transport_drop_stats_t is for a link that counts nothing (#932) — never a fabricated number. A field a particular source cannot answer stays 0; capacity == 0 means “unbounded, or not reported”, never “a zero-byte ceiling”.

Snapshot coherence is the core/STYLE.md §Introspection clause: monotonic since construction, sampled unsynchronized, and the intended reading is the DIFFERENCE between two snapshots rather than an instant.

Public Members

std::size_t capacity = 0

The effective byte ceiling this source serves from — the caller’s injected slab, not a compile-time constant. 0 = unbounded / not reported.

std::size_t in_use = 0

Bytes handed out and not returned to this source, USED-polarity (free is capacity - in_use, and is deliberately not the primary).

std::size_t peak = 0

High-water mark of in_use since construction.

std::size_t refused = 0

block_source_t::try_alloc calls this source answered nullptr — requests refused BY VALUE, so the caller was told (it answered BACKPRESSURE). Distinct from a dropped, where nobody was told.

std::size_t largest_refused = 0

Bytes of the LARGEST request in refused — the number a deployment grows its slab to. 0 iff refused is 0.

class heap_source_t : public tr::mem::block_source_t

The default source: the platform heap, nothrow.

Behaviour is byte-identical to today for a host that injects nothing, EXCEPT that exhaustion returns nullptr instead of reaching the ESP-IDF __cxa_throw abort stub. Thread-safe: the global nothrow operator new is.

Public Functions

inline constexpr heap_source_t() noexcept

Constant-initializable, so the process-wide default costs no dynamic init.

inline virtual void *try_alloc(std::size_t bytes, std::size_t align) noexcept override

Nothrow aligned heap allocation; nullptr on exhaustion.

inline virtual void release(void *p, std::size_t bytes, std::size_t align) noexcept override

Sized, aligned reclaim matching try_alloc.

block_source_t &tr::mem::heap_source() noexcept

The process-wide default block_source_t (the platform heap).

A namespace-scope constinit object behind a function, NOT a function-local static: the latter costs a __cxa_guard word in .bss and an acquire fence on every call.

class null_source_t : public tr::mem::block_source_t

The source that serves nothing — every request is exhaustion.

The upstream to give a bump_source_t when its buffer must be the HARD bound, so a frame that outgrows it is rejected rather than reaching the global heap. This is the bounded-node composition, and the honest replacement for std::pmr::null_memory_resource(), which signals the same thing by throwing.

Public Functions

inline constexpr null_source_t() noexcept

Constant-initializable, like heap_source_t.

inline virtual void *try_alloc(std::size_t, std::size_t) noexcept override

Always nullptr.

inline virtual void release(void*, std::size_t, std::size_t) noexcept override

Unreachable — this source hands out nothing to return.

block_source_t &tr::mem::null_source() noexcept

The process-wide null_source_t (serves nothing; see the class docs).

class bump_source_t : public tr::mem::block_source_t

A caller-owned buffer handed out by bump, falling back to upstream once full.

The nothrow twin of std::pmr::monotonic_buffer_resource over a fixed span. Blocks carved from the span are never individually reclaimed (release is a no-op for them, exactly as a monotonic resource behaves); blocks that came from the upstream are returned to it, so a decode that outgrows the buffer still frees what it borrowed.

Note

The upstream is what keeps this a capability-preserving substitution: a monotonic_buffer_resource also spills past its buffer, but it spills to a THROWING default resource, which on -fno-exceptions is the abort() this whole seam exists to remove. Pass a bounded source (or a null-serving one) to make the buffer the hard limit instead.

Note

Single-threaded by contract — a bump cursor is not synchronized. Its intended use is a function-scoped buffer on the calling thread’s stack.

Warning

SCOPE-LIFETIME USE ONLY. A bump block is never reclaimed, so a source that outlives one burst of work monotonically fills and then refuses everything. Construct it per operation (as the branch-write decode does), or reset it between operations. It is NOT a long-lived seam: an 8 KiB bump source wired as a router’s rx decoded 6 frames and rejected the next 194 — measured. A long-lived bounded seam wants pool_source_t, which recycles.

Public Functions

inline explicit bump_source_t(std::span<std::byte> buffer, block_source_t &upstream = heap_source()) noexcept

Carve from buffer; once it cannot serve a request, draw from upstream.

inline virtual void *try_alloc(std::size_t bytes, std::size_t align) noexcept override

Bump-allocate, aligned; falls back to the upstream when the buffer cannot fit.

inline virtual void release(void *p, std::size_t bytes, std::size_t align) noexcept override

No-op for a bump block; a sized return to the upstream otherwise.

inline void reset() noexcept

Hand the whole buffer back for reuse — the next try_alloc starts at 0.

For a source reused across scopes (a terminus decoding frame after frame into the same slab). Deliberately NOT called release: on a std::pmr::monotonic_buffer_resource that name means exactly this, while on block_source_t it means “return one block”, and the two must not be confusable at a call site.

Warning

Every block previously carved from the buffer dangles afterwards. Blocks that came from the upstream are NOT reclaimed by this — return those first.

inline std::size_t used() const noexcept

Bytes carved from the buffer so far (diagnostics; excludes upstream blocks).

inline virtual source_stats_t stats() const noexcept override

This bump source’s census (source_stats_t; core/STYLE.md §Introspection).

capacity/in_use/peak describe the CALLER’S BUFFER — the span size this source was handed, how much of it the cursor has carved, and the deepest any reset cycle got. Upstream spill is deliberately outside all three: those bytes are the upstream’s census to report, and folding them in here would make in_use exceed capacity on the very source whose ceiling the number exists to describe.

refused counts what a caller experienced: a try_alloc that answered nullptr, which for this source means the buffer could not fit the request AND the upstream refused it too. Against a bounded upstream (null_source() — the composition that makes the buffer a hard limit) that is exactly “the buffer overflowed”; against heap_source() it stays 0 until the platform heap is gone, which is the honest reading in both cases.

Plain counters, no atomics: this source is single-threaded BY CONTRACT (see the class note), so the ownership discipline that already protects used_ protects these (core/STYLE.md §Introspection, counting doctrine 5).

template<class Sync = sync_none_t>
class pool_source_t : public tr::mem::block_source_t

A BOUNDED, RECYCLING source: segregated exact-size free lists over a caller slab.

The long-lived counterpart to bump_source_t, and the source a node with a RAM ceiling injects. Exhaustion is nullptr — never the platform heap, never an abort.

Public Functions

inline pool_source_t(std::span<std::byte> slab, std::span<size_class_t> classes) noexcept

Serve allocations from slab, recycling through classes.

This is also the supported “reuse my existing arena” path (#1493). A host that already partitions a static slab with std::pmr — the monotonic_buffer_resource under a synchronized_pool_resource shape ADR-0039 describes — points this constructor at the SAME STORAGE rather than at the resource. There is no pmr in the result and nothing to adapt, so exhaustion stays a nullptr all the way down; see block_source_t’s warning for why wrapping the resource instead cannot work.

Parameters:
  • slab – Caller-owned storage; must outlive every block carved from it.

  • classes – Caller-owned free-list slots. Running out is safe but lossy — see overflowed.

inline virtual void *try_alloc(std::size_t bytes, std::size_t align) noexcept override

Pop a recycled block of this exact shape, else carve a fresh one; nullptr when full.

inline virtual void release(void *p, std::size_t bytes, std::size_t align) noexcept override

Return a block to its class’s free list.

bytes and align MUST match the originating try_alloc, per the seam’s sized contract — that is what lets a block carry no header. A pointer from outside the slab is ignored rather than trusted, mirroring bump_source_t — two compares are cheaper than the corruption a foreign pointer would cause.

inline std::size_t used() const noexcept

Bytes carved from the slab so far, recycled blocks included (diagnostics).

inline std::size_t classes_used() const noexcept

Class slots in use — the number to size the injected span against.

inline std::size_t overflowed() const noexcept

Blocks lost because the class table was full; non-zero means the span is too small.

Note

A RECYCLING DEGRADE, not an allocation refusal, and the two are deliberately separate counters (core/STYLE.md §Introspection): the block stays carved — bounded and safe — and the caller that freed it was never refused anything. The refusal number is refused.

inline std::size_t refused() const noexcept

try_alloc calls this pool answered nullptr — primary slab exhaustion (#1492). Also reachable via stats.

inline std::size_t largest_refused() const noexcept

Bytes of the largest request in refused — the number to grow the slab to (#1492: the tail is what refuses).

inline virtual source_stats_t stats() const noexcept override

This pool’s census (source_stats_t; core/STYLE.md §Introspection).

capacity is the injected slab, and in_use is used — bytes CARVED, recycled blocks sitting on a free list included, because a carved block is never returned to the slab and so is not available to a different size class. That makes carving monotonic, and peak therefore equals in_use by construction: the high-water mark costs this source not one instruction.

Plain counters under the existing Sync section, not atomics: the refusal bump sits inside the same guard_t try_alloc already holds, so a shared pool’s counters are as synchronized as its free lists are and nothing new is locked. On rv32 an atomic wide enough to matter is not lock-free anyway — it takes a hidden libatomic lock per access (core/STYLE.md §Introspection, counting doctrine 5).

Note

overflowed is NOT in this block. It counts a recycling degrade rather than a refusal, so it is neither refused nor dropped in the shared vocabulary, and it stays this type’s own named accessor.

struct size_class_t

One recycling free-list, keyed by the exact (bytes, align) pair it serves.

Caller-supplied storage: a pool_source_t is handed a span of these, so the number of classes is a deployment property rather than a constant in this header (ADR-0065’s injected-bounds rule). Sizing it is measurable — see pool_source_t::classes_used.

Public Members

std::size_t bytes = 0

Normalized payload size; 0 marks an unused slot.

std::size_t align = 0

Normalized alignment this class’s blocks satisfy.

void *head = nullptr

Intrusive free-list head; the link lives in the block.

The std::pmr adapter — one direction only, and outside the library. It lets an embedder point a std::pmr container at an injected block_source_t; it does not make that container failable. See the memory substrate reference for when to reach for it and when to migrate the store instead.

class source_resource_t : public std::pmr::memory_resource

Serve a std::pmr container from an injected block_source_t.

The adapter ADR-0079 ends on: *”`std::pmr` survives only as a thin adapter for

std-container interop on non-failable paths.”* It exists for the one case a store migration cannot solve by retyping — a

std::pmr container whose element type is neither trivially copyable nor trivially destructible, so block_array_t’s two static assertions reject it (tr::net::can_reassembly_t’s slice map holds a refcounted tr::view::view_t; #873 family 5). Pointing such a container at a bounded pool_source_t is strictly better than leaving it on the process heap.

Note

Stateless beyond the one pointer. There is deliberately NO refusal counter here: a non-atomic one races on a shared adapter and an atomic one puts a shared RMW on an allocation path, which #873’s cadence rules a reject. Counting belongs to the injected source, which already has the vocabulary (pool_source_t::used, pool_source_t::classes_used, pool_source_t::overflowed).

Note

Concurrency is entirely the injected source’s contract — the adapter adds no shared state of its own. Own one source per receiver: ADR-0060 erratum 1 measured a shared free-list pool collapsing to ~1/15 of its single-thread rate on a 12-core host. Wrapping it in a memory_resource does not change that.

Note

Blocks are host-owned: both the source and this adapter must outlive every container built over them.

Warning

THIS DELIVERS PLACEMENT AND BOUNDING, NOT FAILABILITY. std::pmr’s only exhaustion signal is a throw, so this adapter’s boundary is a std::bad_alloc on a hosted build and a std::abort() under -fno-exceptions — byte-for-byte the behaviour libstdc++ itself produces for the same throw on that profile. A peer-provoked path must therefore NOT be moved onto a std::pmr container just because this exists: a store that has to SURVIVE exhaustion migrates via the route-handle pattern (docs/reference/09-memory-substrate.md) onto block_array_t and fails by value. What this buys is that the bytes come from the deployer’s slab instead of the global heap, and that the slab’s size is the bound.

Public Functions

inline explicit source_resource_t(block_source_t &src) noexcept

Serve every request from src; src must outlive this adapter.

source_resource_t(const source_resource_t&) = delete

Non-copyable — a resource is an identity, exactly as a source is.

source_resource_t &operator=(const source_resource_t&) = delete

Non-assignable.

inline block_source_t &source() const noexcept

The source the bytes come from — for census and for sizing its slab.

Protected Functions

inline void *do_allocate(std::size_t bytes, std::size_t alignment) override

Draw bytes aligned to alignment from the source.

Throws:

std::bad_alloc – The source refused. This is the adapter’s boundary and the reason it is not a failable seam; under -fno-exceptions the refusal is std::abort() instead, which is what libstdc++ does with the same throw.

inline void do_deallocate(void *p, std::size_t bytes, std::size_t alignment) override

Return a block to the source.

std::pmr’s deallocate carries the original size and alignment, which maps 1:1 onto block_source_t::release’s sized-reclaim contract — that is what lets a pool_source_t recycle these blocks with no per-block header.

inline bool do_is_equal(const std::pmr::memory_resource &other) const noexcept override

Identity comparison — two adapters are equal only when they are the SAME object.

Address identity rather than a dynamic_cast on the source pointer: the reference node ships -fno-rtti, so a cross-type dynamic_cast is not available to this header at all. libstdc++’s own monotonic_buffer_resource answers the same way.

Note

The consequence is worth stating: two source_resource_ts over the SAME block_source_t compare unequal, so containers built over them will copy rather than steal storage on a container move-assign. Construct one adapter per source and pass it around, rather than one per container.

struct sync_none_t

The no-op synchronization policy — the default, and the one the hot seam wants.

A pool_source_t owned by exactly one thread needs no synchronization at all, and that is the intended shape for a per-receiver source: ownership removes the race instead of guarding it. See pool_source_t’s threading note for why this matters more than the choice of free-list algorithm.

A policy is anything with lock()/unlock(); a target supplies its own where it needs one (an interrupt-disable critical section on single-core FreeRTOS, tr::mem::sync_mutex_t from mem_source_sync.hpp on a host). This header stays freestanding-clean, so it pulls in no threading facility of its own.

Public Static Functions

static inline void lock() noexcept

No-op — this policy exists to compile to nothing.

static inline void unlock() noexcept

No-op.

template<class T>
class block_array_t

A nothrow growable array of trivially-copyable T drawn from a block_source_t.

The container a failable path uses where a std::pmr::vector would otherwise sit (#551 Q2, #588). Two differences carry the whole point:

  1. Growth returns instead of throwing. std::pmr::vector::push_back on an exhausted resource throws, which on ESP-IDF reaches the link-wrapped __cxa_throw abort() stub — a peer-reachable reboot when the container sits on the RX decode path.

  2. Relocation is a . T is required trivially copyable, so growth needs no move loop and the vacated block needs no destruction. Both current users (wire::arena_tlv_t, the walk’s open-node record) are span/enum aggregates.

Same footprint as std::pmr::vector (four words), one virtual call per growth instead of the allocator’s two.

Public Functions

inline explicit block_array_t(block_source_t &src) noexcept

An empty array that will draw its storage from src.

inline ~block_array_t()

Returns the block, if one was taken.

block_array_t(const block_array_t&) = delete

Non-copyable — one array, one block.

block_array_t &operator=(const block_array_t&) = delete

Non-assignable.

inline block_array_t(block_array_t &&o) noexcept

Move-constructible so a decode can return its arena by value.

inline block_array_t &operator=(block_array_t &&o) noexcept

Move-assignable (releases this array’s block first).

inline bool reserve(std::size_t n) noexcept

Ensure room for n elements without growing again.

Return values:

false – The source is exhausted — the array is unchanged.

inline bool push_back(const T &v) noexcept

Append v.

Return values:

false – The source is exhausted — the array is unchanged (BACKPRESSURE).

inline T *push_slot() noexcept

Claim one uninitialized slot at the end and return it — fill it IN PLACE.

The form the hot paths use. push_back(T{...}) has to materialize the aggregate on the stack and copy it in, and for a 48-byte T written field-by-field then read back as wide loads that is a store-forwarding stall on every element: measured on the terminus decode, ~45 % slower with FEWER instructions executed. Writing through this slot removes the temporary entirely.

Return values:

nullptr – The source is exhausted — the array is unchanged (BACKPRESSURE).

inline void pop_back() noexcept

Drop the last element. Precondition: not empty.

inline T &back() noexcept

The last element. Precondition: not empty.

inline const T &front() const noexcept

The first element. Precondition: not empty.

inline T &operator[](std::size_t i) noexcept

Element i, unchecked.

inline const T &operator[](std::size_t i) const noexcept

Element i, unchecked (const).

inline std::size_t size() const noexcept

Element count.

inline bool empty() const noexcept

True when no elements are held.

inline T *data() noexcept

First element, or nullptr when empty — the contiguous block.

For handing the array to an API that takes a pointer/length pair, e.g. building a std::span over an egress iov table. The pointer is invalidated by any growth.

inline const T *data() const noexcept

First element (const), or nullptr when empty.

The bounded reference backend:

class pool_t : public tr::mem::mem_backend_t

A fixed-slot allocator over a caller-owned slab; alloc-or-nullptr.

Carves the slab into equal slots with the free list threaded through the slab (no auxiliary heap), so memory use is exactly the caller’s slab and exhaustion is a return value, not an OOM. The deterministic MCU choice.

Public Functions

pool_t(std::span<std::byte> slab, std::size_t slot_payload, std::size_t align = alignof(std::max_align_t)) noexcept

Carve slab (caller-owned; must outlive the pool) into slots.

Each slot holds a segment_t control block plus slot_payload usable bytes, payload aligned to align (a power of two). The slot count is whatever fits after aligning the slab base.

virtual view::segment_t *alloc(std::size_t size, alloc_hint_t hint = alloc_hint_t::NONE) override

Hand out the next free slot as a segment_t of size bytes.

Return values:

nullptrsize exceeds the slot payload, or the pool is exhausted.

virtual void destroy(view::segment_t *seg) noexcept override

Return seg's slot to the free list (placement-destroying it).

inline virtual std::size_t alignment() const noexcept override

The alignment (bytes) this backend guarantees for allocated bytes.

inline virtual std::size_t max_segment_size() const noexcept override

The largest single segment this backend can produce.

inline virtual backend_tag tag() const noexcept override

The build-time-closed module-set tag (default UNKNOWN, ADR-0047 §2).

A backend that participates in the fast destroy dispatch overrides this to return its backend_tag; segments read it once at construction (like space). A backend that leaves the default is dispatched through its virtual destroy.

inline std::size_t capacity() const noexcept

Total slots — the effective ceiling, i.e. whatever fitted in the caller’s slab, never a constant.

inline std::size_t available() const noexcept

FREE slots.

Note

The one shipped free-polarity accessor, kept for compatibility. New code reads in_use — the unified vocabulary is used-polarity throughout (core/STYLE.md §Introspection), and available is the derived legacy name (capacity() - in_use()).

inline std::size_t in_use() const noexcept

Slots handed out and not yet returned — occupancy in the used-polarity vocabulary (core/STYLE.md §Introspection).

Public Static Attributes

static constexpr bool needs_cache_ops = false

No DMA cache maintenance (plain RAM slab).

static constexpr bool is_isr_safe = false

Unsynchronized free-list RMW — NOT safe concurrent with an ISR.

static constexpr bool is_nonblocking = true

alloc/destroy are O(1) free-list ops — no heap, no syscall.

static constexpr bool owns_bytes = true

Bytes are backend-managed (freed only on destroy) — durably storable.

The heap backend and the space tags

class heap_backend_t : public tr::mem::mem_backend_t

The host allocator backend: owns operator new’d bytes, frees them and the segment_t control block on destroy.

Exposed here (rather than TU-local) so the module-set destroy dispatch (backend_set.cpp, ADR-0047 §2) can devirtualize its release; a final class, so the qualified call in that switch is a direct call.

Public Functions

inline virtual view::segment_t *alloc(std::size_t size, alloc_hint_t) override

Allocate a fresh segment of at least size bytes (refcount = 1).

The returned segment is the caller’s to adopt via tr::view::segment_ptr_t::adopt. A raw segment_t* is returned, not a segment_ptr_t, to keep L0 from naming L1’s owning handle (docs/adr/0016 §2). Allocation-incapable substrates (MMIO, FIFOs) leave this default and return nullptr.

Parameters:

hint – Backend-private allocation hint; NONE for “don’t care”.

Return values:

nullptr – Backpressure (pool exhausted / OOM) or allocation unsupported.

inline virtual void destroy(view::segment_t *seg) noexcept override

Reclaim a segment whose refcount has reached zero (the only reclaim path).

Frees whatever the backend owns (the bytes and/or the segment_t control block) and nothing it does not — a borrowed backend never frees the user’s bytes. Invoked by segment_ptr_t at zero, never by user code.

Warning

Never called on a live segment.

inline virtual backend_tag tag() const noexcept override

The build-time-closed module-set tag (default UNKNOWN, ADR-0047 §2).

A backend that participates in the fast destroy dispatch overrides this to return its backend_tag; segments read it once at construction (like space). A backend that leaves the default is dispatched through its virtual destroy.

Public Static Attributes

static constexpr bool needs_cache_ops = false

No DMA cache maintenance (host RAM).

static constexpr bool is_isr_safe = false

alloc/destroy call operator new/delete — not ISR-safe.

static constexpr bool is_nonblocking = false

operator new/delete may lock or syscall (#928).

static constexpr bool owns_bytes = true

Owns the operator new’d bytes — durably storable.

mem_backend_t &tr::mem::heap_backend() noexcept

The process-wide heap backend (function-local static — no init-order trap).

enum class tr::mem::mem_space_t : std::uint8_t

The address space a backend’s bytes live in.

HOST bytes are CPU-addressable; DEVICE bytes are not (e.g. GPU/accelerator device memory — docs/adr/0024). The codec must never CPU-dereference a DEVICE link: such a segment may back only an opaque VALUE payload, with the header/trailer kept in a HOST segment (a heterogeneous host+device rope).

Values:

enumerator HOST

CPU-addressable bytes.

enumerator DEVICE

Non-CPU-addressable bytes (GPU/accelerator); codec must not deref.

enum class tr::mem::backend_tag : std::uint8_t

Which build-time-closed backend a segment came from — the module-set tag (ADR-0047 §2).

A segment carries its backend’s tag so the per-segment-release destroy dispatch (segment_ptr_t::resetdestroy_dispatch) is a switch → devirtualized direct call rather than a vtable indirect — foldable to a single direct call when a target links only one backend. An unrecognized tag (UNKNOWN, or any backend outside the fast set — every out-of-core device backend is) routes to the backend’s virtual destroy, so dispatch is correct regardless.

The set is closed over the backends core/ itself compiles. A vendor backend from the backends/ tier does not get an enumerator: it is identified by its mem_backend_t object (register_device_backend), which is what destroy already routes on, so core never has to name it.

Values:

enumerator UNKNOWN

No fast-path tag → virtual destroy fallback.

enumerator HEAP

mem_heap (mem_heap.hpp).

enumerator POOL

mem_pool (mem_pool.hpp).

enumerator BORROWED

mem_borrowed (mem_borrowed.hpp).

enumerator BORROWED_DEVICE

mem_borrowed device-space variant.

void tr::mem::destroy_dispatch(view::segment_t *seg) noexcept

Reclaim seg through its backend — the module-set destroy dispatch (ADR-0047 §2), called by segment_ptr_t::reset at refcount zero.

Switches on the segment’s backend_tag to a devirtualized direct call for a linked fast-set backend, and falls back to the backend’s virtual destroy for any other tag, so the result is identical to seg->backend->destroy(seg) for every backend. Defined in backend_set.cpp (the one TU that sees the concrete backend types), keeping this L0 seam free of an upward dependency.

bool tr::mem::transfer(view::segment_t *seg, std::span<std::byte> host, io_dir_t dir) noexcept

Move host.size() bytes between segment seg and host memory host in direction dir — the module-set host↔device transfer (ADR-0047 §2), bracketed by the backend’s cache hooks.

The single tag-dispatched byte-mover the codec routes a copy through, which replaced the vendor-named per-device copy pair the module set retired:

A host-addressable backend transfers with a memcpy, bracketed by before_io/after_io only when its static constexpr needs_cache_ops trait is set — so a cacheless backend (every one today) folds the hooks away at compile time (they are the traits’ first in-tree consumer, review finding #8). A DEVICE-space segment takes the registry arm instead: its backend’s register_device_backend hook, or false when nothing is registered for it — no host arm ever sees a pointer the CPU may not dereference (#928). That is where after_io gets its first caller, in the backends/ tier module that owns the device copy (docs/adr/0024). Defined in backend_set.cpp (the module-set TU).

Parameters:
  • seg – The segment to read from or write to; nullptr yields false.

  • host – CPU-addressable bytes; a .size() larger than seg's yields false.

  • dir – Which way the bytes move (also the cache-hook direction).

Return values:

false – Null segment, an over-long host, or a device copy failure.

Shared pools and their synchronization policy

A pool shared by more than one thread needs a synchronization policy, and the policy is a compile-time parameter rather than a runtime flag so a single-threaded target pays nothing for it. spin_sync_t is the multi-core host policy; sync_none_t is the unsynchronized one; a bare-metal target supplies an interrupt-disable critical section of its own. sync_pool_t is the spelling for the common host case.

The pool_source_t seam has its own policy question, and one deliberate non-answer: sync_mutex_t lives in a separate header because the L0 seam is compiled into a freestanding footprint sentinel where <mutex> does not exist, and because a mutex is the right instrument only for a source shared at wiring frequency. It is not a way to make a per-frame source thread-safe — see failable allocation and backpressure for what a shared free list costs under contention.

template<class P>
concept pool_sync_policy
#include <mem_pool.hpp>

The compile-time synchronisation seam of synchronized_pool_t (ADR-0047 §2 module-set trait, ADR-0068 compile-time doctrine).

A policy owns ONE critical-section mechanism: lock() / unlock() around the pool’s O(1) free-list ops, plus the facts the seam publishes upward — whether the section is ISR-safe, whether acquiring it can block (heap/syscall/OS wait — distinct from ISR safety, #928), and what the resulting backend is called. The target knows its concurrency model at BUILD time (a single-core priority-preemptive MCU never becomes a multi-core host), so the choice is a template argument, not a runtime knob: no branch, no vtable, no per-alloc indirection on a ~120 ns operation.

struct spin_sync_t

The MULTI-CORE HOST policy: an std::atomic_flag spinlock.

Negligible contention on an O(1) section, and it avoids the ~2 µs OS-mutex round-trip that would dominate the ~120 ns free-list op. NOT ISR-safe, and wrong for a single-core priority-preemptive target, where a lower-priority holder cannot run while a higher-priority task spins (ADR-0060 §2) — such a target supplies the interrupt-disable critical-section policy instead (tr::esp::portmux_sync_t for ESP-IDF).

Public Functions

inline void lock() noexcept

Acquire the flag, spinning (the guarded section is O(1)).

inline void unlock() noexcept

Release the flag.

Public Static Attributes

static constexpr bool is_isr_safe = false

Spin => not ISR-safe.

static constexpr bool is_nonblocking = true

No heap, no syscall, no OS wait — it spins on an O(1) section.

static constexpr const char *name = "mem_sync_pool"

Backend name.

template<pool_sync_policy Sync>
class synchronized_pool_t : public tr::mem::mem_backend_t

A thread-safe pool_t whose SYNCHRONISATION IS A COMPILE-TIME POLICY (ADR-0060 §2), guarding the O(1) free-list with Sync.

Any mem_backend_t injected at a shared seam MUST be thread-safe: a segment self-routes its reclaim on whatever thread drops the last ref — typically a reader/subscriber or transport receive thread, concurrent with a writer’s alloc (ADR-0060 §2; the same obligation holds for graph_t’s value_backend, the router’s flat, and transport_vertex_t’s rx_backend). A single thread-safe pool (never per-stripe sharding, which removes no race and adds partition imbalance) is the answer.

The mechanism is the target’s to pick, because only the target knows its concurrency model: spin_sync_t on a multi-core host, an interrupt-disable critical section on a single-core priority-preemptive MCU (tr::esp::portmux_sync_t, shipped by the ESP-IDF component — it needs FreeRTOS headers, so it lives outside core/). The many-core lock-free index+tag CAS upgrade (the free list is already index-based) stays the recorded ADR-0060 §2 follow-up.

This is opt-in construction only — no seam defaults to it. heap_backend() remains the default everywhere; a target that wants its receive/value bytes inside its own slab constructs one of these and injects it.

Composition over pool_t: a freshly-alloc’d segment is re-pointed to this with a UNKNOWN tag, so destroy_dispatch routes reclaim through the virtual (locked) destroy here instead of the devirtualized POOL fast path (which would bypass the lock). pool_t::destroy recovers the slot from the segment’s slab offset, so the re-point is invisible to the inner pool. The re-point touches only the just-allocated segment, which no other thread can observe until the caller publishes it.

Public Functions

inline synchronized_pool_t(std::span<std::byte> slab, std::size_t slot_payload, std::size_t align = alignof(std::max_align_t)) noexcept

Carve slab into slot_payload-byte slots (see pool_t), thread-safe.

inline virtual view::segment_t *alloc(std::size_t size, alloc_hint_t hint = alloc_hint_t::NONE) override

pool_t::alloc inside Sync's critical section; reclaim re-routed here.

inline virtual void destroy(view::segment_t *seg) noexcept override

pool_t::destroy inside Sync's critical section.

inline virtual std::size_t alignment() const noexcept override

The alignment (bytes) this backend guarantees for allocated bytes.

inline virtual std::size_t max_segment_size() const noexcept override

The largest single segment this backend can produce.

inline virtual backend_tag tag() const noexcept override

UNKNOWN so destroy_dispatch takes the virtual (locked) destroy, not the devirtualized POOL fast path that would bypass the critical section.

inline std::size_t capacity() const noexcept

Total slots (delegated to the inner pool_t).

inline std::size_t in_use() const noexcept

Slots handed out and not yet returned (delegated to the inner pool_t).

The wrapper used to forward capacity and STOP there, so wrapping a bounded resource in its thread-safe form lost half its census: a host could read the ceiling it had injected but not how much of it was gone — on precisely the pool that is shared, i.e. the one whose occupancy is hardest to reason about (#1503 finding 4).

Read without taking Sync, per the snapshot-coherence clause (core/STYLE.md §Introspection): locking here would put a critical section on a ~120 ns free-list op in order to serve a diagnostic, and the intended reading is the difference between two samples, not an instant.

inline std::size_t available() const noexcept

FREE slots (delegated). Free-polarity legacy spelling — new code reads in_use; see pool_t::available.

Public Static Attributes

static constexpr bool needs_cache_ops = false

Plain RAM slab.

static constexpr bool is_isr_safe = Sync::is_isr_safe

Whatever the sync policy guarantees.

static constexpr bool is_nonblocking = Sync::is_nonblocking

The pool’s section is O(1); the WAIT is the policy’s fact, so this forwards it rather than asserting it (#928).

static constexpr bool owns_bytes = true

Backend-managed, durably storable.

using tr::mem::sync_pool_t = synchronized_pool_t<spin_sync_t>

The multi-core-host spelling of synchronized_pool_t — a spinlock-guarded pool.

The name predates the policy seam and is kept as the host default (ADR-0060 §2’s spinlock variant); a single-core MCU wants the critical-section policy instead — and because the short name is the discoverable one, a build that sets kSpinWaitSafe to false rejects this instantiation outright rather than shipping a hang.

class sync_mutex_t

The hosted synchronization policy — a plain std::mutex.

For a source shared across threads at wiring frequency: a graph’s control source, where registration runs once per connection and an uncontended mutex is unmeasurable.

Note

Also not for a single-core FreeRTOS target’s per-frame path: a blocking mutex there invites the priority inversion ADR-0063 erratum 1 records. Such a target supplies an interrupt-disable policy of its own; the seam only asks for lock()/unlock().

Warning

Do NOT reach for this to make a per-frame source thread-safe. ADR-0060 erratum 1 measured a shared free-list pool collapsing to ~1/15 of its single-thread rate on a 12-core host while the platform heap scaled; guarding the shared list is the problem, not the guard’s flavour. Give each receiver its own pool_source_t with the default sync_none_t instead.

Public Functions

inline void lock() noexcept

Acquire. noexcept by policy: a mutex that cannot lock is a programming error.

inline void unlock() noexcept

Release.

Device memory — the registration seam

Core keeps the interface; the vendor backend lives in the backends/ tier. A DEVICE-space backend registers the pair {backend, transfer hook} and tr::mem::transfer routes that backend’s segments to it — the L0 mirror of tr::net::transport_vertex_t::register_transport_type (ADR-0024 Amendment 1). The key is the backend object, not the space tag, so a second vendor (ROCm, an NPU, dmabuf) plugs in without adding a name — or an enumerator — to core. A backend nobody registered gets a clean false, exactly as every unrecognized device segment did before the registry existed.

using tr::mem::device_transfer_fn_t = bool (*)(view::segment_t *seg, std::span<std::byte> host, io_dir_t dir) noexcept

The device byte-move a DEVICE-space backend registers with register_device_backendtransfer’s out-of-core arm.

Same contract as transfer, narrowed to one backend’s segments: move host.size() bytes between seg and host in direction dir, false on refusal. A plain function pointer, not a std::function: the seam must cost a pointer and never allocate (ADR-0047 §2).

bool tr::mem::register_device_backend(const mem_backend_t &backend, device_transfer_fn_t fn) noexcept

Register fn as the byte-mover transfer routes backend's DEVICE-space segments through.

The L0 mirror of tr::net::transport_vertex_t::register_transport_type: a module outside core supplies the value, the composition root wires it in, and core never names the module (docs/adr/0024 Amendment 1; the module seam is docs/adr/0043 §1). The backends/ tier holds the first in-tree caller.

Keyed by the backend object, not by mem_space_tDEVICE is one enumerator shared by every accelerator, and the segment’s backend pointer is already the identity destroy routes on — so a second vendor plugs in without adding a name to core.

Bounded and allocation-free: the table holds tr::mem::kDeviceBackendSlots entries (config.hpp), so registration cannot fail for lack of heap, only for lack of a slot. Registering the same backend twice replaces its hook (insert_or_assign semantics), so a backend and its hook can never disagree.

Note

Call at setup, before frames flow, from one thread — the same contract register_transport_type carries. Concurrent lookups by transfer are safe against a completed registration.

Return values:

falsefn was null, or the table is full — nothing was registered.

The GPU module’s own entry points (built only in the tier):

mem_backend_t &tr::mem::cuda_backend() noexcept

The process-wide CUDA device backend (cudaMalloc/cudaFree; space() == DEVICE).

Constructing it also registers it (see register_cuda_backend), so a caller that only ever allocates — tr::view::cuda_alloc — can never meet a tr::mem::transfer that does not know where to route its segments.

bool tr::mem::register_cuda_backend() noexcept

Register cuda_backend with core’s device-backend registry, so tr::mem::transfer routes its DEVICE segments to cuda_transfer.

The tr::net::quic_transport_factory of this tier: the module supplies the value, the composition root (or, here, cuda_backend’s own first construction) wires it in, and core never names CUDA. Idempotent — calling it twice replaces the same slot’s hook.

Return values:

false – Core’s bounded table is full (tr::mem::kDeviceBackendSlots).

bool tr::mem::cuda_transfer(view::segment_t *seg, std::span<std::byte> host, io_dir_t dir) noexcept

The device byte-move behind tr::mem::transfer for a CUDA (DEVICE) segment: cudaMemcpy in direction dir, bracketed by the backend’s cache hooks (after_io == the CUDA stream barrier).

Declared here but defined in mem_cuda.cpp so cudaMemcpy stays TU-local. It is what register_cuda_backend hands core. Not called directly — use tr::mem::transfer, which routes this backend’s segments here.

The seam

        classDiagram
    class mem_backend_t { <<interface>> +alloc() +destroy() +before_io() +after_io() +alignment() }
    mem_backend_t <|-- heap_backend_t
    mem_backend_t <|-- borrowed_backend_t
    mem_backend_t <|-- pool_t
    mem_backend_t <|-- YourBackend
    segment_t --> mem_backend_t : backend*
    note for YourBackend "bind a DMA ring,\nlwIP pbuf, MMIO, …"
    

Consequences

  • The same protocol runs against a heap, a caller-sized MCU slab or a live register, because the substrate is selected by binding a backend rather than by a build variant of the core.

  • Memory use with mem_pool is exactly the caller’s slab: the free list is threaded through the slab, so there is no auxiliary heap allocation, and exhaustion is an alloc returning nullptr rather than an OOM.

  • mem_borrowed puts a segment over bytes the caller already holds, so live data reaches the wire with no copy and no CRC imposed; the cost is that those bytes are outside libtracer’s lifetime control.

  • A substrate that cannot allocate at all is still bindable, because alloc is permitted to return nullptr unconditionally — MMIO windows and hardware FIFOs bind as read-only borrowed segments.

  • Two seams rather than one means two failure contracts to hold in mind: a mem_backend_t failure is a refcounted-segment allocation that failed, a block_source_t failure is a single-owner block that failed. Neither throws.

Pitfalls

  • A raw segment_t* that is never adopted leaks. alloc hands back a pointer at refcount 1 and the backend does not track it; the value is only safe once segment_ptr_t::adopt owns it. The tr::view helpers (heap_alloc, borrow, borrow_const) exist so that the common paths cannot get this wrong.

  • Borrowed bytes must outlive every segment over them. borrowed_backend_t::destroy deletes the control block and nothing else (core/include/libtracer/mem_borrowed.hpp:39), so a borrow over a stack buffer or a scratch frame becomes a dangling read the moment that storage goes away. Durable storage of a value wants an owning backend.

  • bump_source_t is scope-lifetime only. Blocks carved from its buffer are never individually reclaimed, so a bump source wired as a long-lived seam fills monotonically and then refuses everything. Construct it per operation, or reset it between operations; a long-lived bounded seam wants pool_source_t, which recycles.

  • A bump_source_t buffer is not a hard bound by default. Its upstream defaults to heap_source(), so overflow spills to the platform heap. Passing null_source() as the upstream is what makes the buffer the limit and turns overflow into a rejection.

  • source_resource_t is placement, not failability. The std::pmr adapter draws its bytes from an injected block_source_t, but std::pmr’s only exhaustion signal is a throw — so its boundary is std::bad_alloc, and std::abort() under -fno-exceptions. Do not move a peer-provoked store onto a std::pmr container because the adapter exists; migrate it onto block_array_t instead. The adapter runs one direction only, and the reverse (a memory_resource used as a block_source_t) must never be added.

  • block_source_t::release is sized. The bytes and align passed to release must match the originating try_alloc call — that is what lets a bump or pool source carry no per-block header. A mismatched pair corrupts the source’s accounting rather than failing loudly.

See: segment, views, interface map, failable allocation and backpressure.