A bounded backend: exhaustion by value (L0/L1 substrate)

tr::mem::pool_t carves a caller-owned slab into equal slots, threading its free list through the slab itself. There is no auxiliary heap allocation, so total memory use is exactly the array the caller declared — and running out is nullptr, not a throw and not an abort() (reference 09 §pressure). That is what makes it the deterministic MCU choice: a node’s memory ceiling is a std::array a reader can point at.

What to notice

  • Two refusals, two meanings, and the API keeps them apart. An oversize request is permanentmax_segment_size() says what a slot can hold and no retry changes it. An exhausted pool is transient backpressure, which is why rope_t::try_flatten answers flatten_err_t::NO_MEMORY there and the very same rope flattens once a slot comes back. The example drains the pool, watches the refusal, returns one slot, and retries successfully.

  • Conflating them is a real defect, not a style point. Before #917 both collapsed into an empty view — indistinguishable from each other and from a legitimately empty rope — so a router reported a local OOM to a peer as a permanent malformed-frame error. The other refusal, NOT_HOST, is on the device rope page.

  • A slot is held by the last reference, not the first. The example keeps its handles in a vector; a slot comes back only when every view naming it is gone — the refcount rule, seen from the allocator’s side.

  • This pool is not internally synchronized. Each backend declares its own concurrency contract, and this one’s is “single-threaded reclamation”. A shared seam wants sync_pool_t.

  • Nothing here is conditional — the target builds and runs under every CI leg.

Source

 1/*
 2 * SPDX-License-Identifier: Apache-2.0
 3 * SPDX-FileCopyrightText: Copyright 2026 avatarsd LLC
 4 */
 5
 6/**
 7 * @file
 8 * @brief ONE CONCEPT — a bounded backend: exhaustion is a return value, not an OOM.
 9 *
10 * `tr::mem::pool_t` carves a CALLER-OWNED slab into equal slots, threading the free list
11 * through the slab itself — so total memory use is exactly the caller's array, and running
12 * out is `nullptr` rather than a throw or an `abort()` (`docs/reference/09-memory-substrate.md`
13 * §pressure). That is what makes it the deterministic MCU choice: the node's memory ceiling
14 * is a `std::array` a reader can point at.
15 *
16 * The refusals are two different facts and the API keeps them apart. An oversize request is
17 * a PERMANENT no — the slot payload is what it is, `max_segment_size()` says so, and no
18 * retry helps. An exhausted pool is TRANSIENT backpressure, which is why
19 * `rope_t::try_flatten` answers `flatten_err_t::NO_MEMORY` there: the same rope flattens once
20 * a slot comes back (#917). Conflating the two is how a local OOM gets reported to a peer as
21 * a malformed frame.
22 *
23 * Runs under ctest as `example_view_pool_backend`; returns non-zero on any failed check.
24 */
25
26#include <array>
27#include <cstddef>
28#include <cstdio>
29#include <optional>
30#include <vector>
31
32#include "libtracer/tracer.hpp"
33
34namespace {
35
36/** @brief Report expectation @p what and record a failure on @p ok. */
37void check(bool& ok, bool cond, const char* what) {
38    std::printf("  [%s] %s\n", cond ? "ok" : "FAIL", what);
39    ok = ok && cond;
40}
41
42}  // namespace
43
44int main() {
45    bool ok = true;
46    alignas(std::max_align_t) std::array<std::byte, 512> slab{};
47    tr::mem::pool_t pool{slab, 32};  // 32 usable payload bytes per slot
48    std::printf("pool over a %zu-byte stack slab: %zu slots of %zu bytes\n", slab.size(),
49                pool.capacity(), pool.max_segment_size());
50    check(ok, pool.capacity() > 0 && pool.available() == pool.capacity(), "a fresh pool is empty");
51
52    check(ok, !tr::view::segment_alloc(pool, pool.max_segment_size() + 1),
53          "an oversize request is refused permanently — no slot can ever serve it");
54
55    // Drain it. Each handle holds one slot until the LAST reference to it drops.
56    std::vector<tr::view::segment_ptr_t> held;
57    while (auto seg = tr::view::segment_alloc(pool, 8)) held.push_back(std::move(seg));
58    std::printf("drained: %zu segments held, %zu slots free\n", held.size(), pool.available());
59    check(ok, held.size() == pool.capacity(), "every slot handed out exactly once");
60    check(ok, pool.available() == 0, "and the pool is dry");
61    check(ok, !tr::view::segment_alloc(pool, 8), "a dry pool answers nullptr, never an OOM");
62
63    // A multi-link rope needs one fresh segment to flatten into; the dry pool cannot give it.
64    tr::view::rope_t rope;
65    rope.append(tr::view::view_t::over(held[0]));
66    rope.append(tr::view::view_t::over(held[1]));
67    const auto refused = rope.try_flatten(pool);
68    check(ok, !refused && refused.error() == tr::view::flatten_err_t::NO_MEMORY,
69          "try_flatten names its cause: NO_MEMORY is TRANSIENT backpressure");
70
71    held.pop_back();  // give one slot back
72    check(ok, pool.available() == 1, "dropping the last reference returns the slot");
73    check(ok, rope.try_flatten(pool).has_value(), "and the very same rope now flattens");
74    return ok ? 0 : 1;
75}

See also: backends · views module · memory substrate reference.