A container that fails by value (L0 substrate)

A bounded seam is only bounded if the containers above it can report a refusal. tr::mem::block_array_t<T> is that container: the same four-word footprint as a std::pmr::vector, drawing from an injected block_source_t, with two differences that carry the whole point.

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

  2. Relocation is a memcpy. T must be trivially copyable and trivially destructible, so growth needs no move loop and the vacated block needs no destruction.

What to notice

  • A refused push leaves the array unchanged, which is what lets every caller treat exhaustion as a clean reject rather than as a half-applied operation. The example asserts both the size and the surviving element after the refusal.

  • push_slot() is not a convenience. Claiming one uninitialized slot and filling it in place removes the temporary entirely; building a 48-byte element on the stack and copying it in writes it field-by-field and reads it back as wide loads, and the resulting store-forwarding stall made the first working migration of the terminus decode 45 % slower while executing fewer instructions (IPC 5.03 → 2.55). Hot paths use push_slot.

  • The two static_asserts are the seam’s edge, and they are where a migration stops being mechanical. An element type holding a std::string or a std::vector is rejected. The fix is not an owning-but-nothrow element — it is to make the public descriptor non-owning (std::string_view, std::span<const std::byte>) and let the store copy the bytes into its own blocks (reference 09 §migrating a STORE). Where the element type genuinely cannot be inverted, the std::pmr adapter is the documented escape — with its own, weaker, guarantee.

  • The array binds its source at construction. A block_array_t member takes its store in its owner’s constructor and keeps it for life, so a set_…_source setter cannot re-seat one. A brace-initialised default (mem::block_array_t<std::byte> buf_{mem::heap_source()};) is a hardcoded store wearing a member-initialiser, and it is invisible to every injection point the type otherwise offers.

  • block_array_t runs no destructors, and has no erase. That is the price of trivially copyable elements: a store built on it frees its byte blocks by hand through one helper, and erases by swapping the last element down.

  • 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 growable array whose growth FAILS BY VALUE.
 9 *
10 * A bounded seam is only bounded if the containers above it can report a refusal. That is
11 * `tr::mem::block_array_t<T>`: the same four-word footprint as a `std::pmr::vector`, drawing
12 * from an injected `block_source_t`, with two differences that carry the whole point.
13 *
14 * 1. **Growth returns `false` instead of throwing.** `std::pmr::vector::push_back` on an
15 *    exhausted resource throws, and on ESP-IDF that reaches the link-wrapped `__cxa_throw`
16 *    `abort()` stub — a peer-reachable reboot when the container sits on the RX path.
17 * 2. **Relocation is a `memcpy`.** `T` must be trivially copyable and trivially destructible,
18 *    so growth needs no move loop and the vacated block needs no destruction.
19 *
20 * A refused `push_back` leaves the array UNCHANGED, which is what lets every caller treat
21 * exhaustion as a clean reject rather than as a half-applied operation.
22 *
23 * Runs under ctest as `example_mem_block_array`; returns non-zero on any failed check.
24 */
25
26#include <array>
27#include <cstddef>
28#include <cstdint>
29#include <cstdio>
30
31#include "libtracer/mem_source.hpp"
32
33namespace {
34
35/** @brief Report expectation @p what and record a failure on @p ok. */
36void check(bool& ok, bool cond, const char* what) {
37    std::printf("  [%s] %s\n", cond ? "ok" : "FAIL", what);
38    ok = ok && cond;
39}
40
41/** @brief A plain aggregate — trivially copyable, so it can be relocated by `memcpy`. */
42struct entry_t {
43    std::uint32_t id = 0;  /**< @brief The element's identity. */
44    std::uint32_t len = 0; /**< @brief Whatever the caller records beside it. */
45};
46
47}  // namespace
48
49int main() {
50    bool ok = true;
51    // A hard bound the array cannot escape: a small buffer whose upstream serves nothing.
52    alignas(std::max_align_t) std::array<std::byte, 128> scratch{};
53    tr::mem::bump_source_t bounded{scratch, tr::mem::null_source()};
54    tr::mem::block_array_t<entry_t> entries{bounded};
55    check(ok, entries.empty(), "an array holds no block until something is put in it");
56
57    check(ok, entries.push_back(entry_t{.id = 1, .len = 10}), "the first push takes a block");
58    check(ok, entries.push_back(entry_t{.id = 2, .len = 20}), "and the next fits in it");
59    check(ok, entries.size() == 2 && entries[1].id == 2, "elements read back by index");
60
61    // The hot-path spelling: claim one uninitialized slot and fill it IN PLACE. Building the
62    // aggregate as a temporary and copying it in cost a measured ~45 % on a 48-byte element.
63    entry_t* const slot = entries.push_slot();
64    check(ok, slot != nullptr, "push_slot claims a slot without materializing a temporary");
65    slot->id = 3;
66    slot->len = 30;
67    check(ok, entries.back().id == 3, "written through the slot, not copied into it");
68
69    // Fill the bounded source. The array grows geometrically, so the refusal arrives at a
70    // growth boundary — and that is the only place it can arrive.
71    int pushed = 0;
72    while (entries.push_back(entry_t{.id = 99, .len = 99})) ++pushed;
73    const std::size_t held = entries.size();
74    std::printf("%zu entries in a %zu-byte bounded slab, then a clean refusal\n", held,
75                scratch.size());
76    check(ok, pushed > 0, "pushes keep succeeding until the source cannot grow the block");
77    check(ok, entries.size() == held, "the refused push left the array UNCHANGED — no half-write");
78    check(ok, entries[0].id == 1 && entries[0].len == 10,
79          "and everything already stored survived the relocations that got here");
80    check(ok, !entries.reserve(held + 1024), "reserve reports the same refusal, by value");
81    return ok ? 0 : 1;
82}

See also: backends · memory substrate reference · the failable block seam · the std::pmr adapter.