ESP-IDF: one task, no pool locks

The component’s default root locks its arena and each of its sub-pools with the build’s guard_t, an interrupt-masked critical section on a chip, because a default must be safe for any number of tasks. A node whose graph is touched by one task only declares its own root with tr::no_guard_t as the lock and injects it into its graph_t. The lock is a template argument, so every lock and unlock compiles to nothing.

What to notice

  • It is a type, not a flag. tr::mem::arena_root_t<tr::no_guard_t, N> is the default root’s own template with a different lock. A static_assert in the example pins that the lock is an empty type and that the root is never larger than tr::mem::mcu_root_t; the app prints both sizes. On a chip the build’s lock holds a portMUX_TYPE per sub-pool and per arena, so the difference shows; on the linux target’s unicore build both locks are one byte.

  • The root is the app’s. Its region and free-list heads are the app’s constinit arrays, so the root is constant-initialized and the linker map shows it as the app’s .bss. The graph is built over it (graph_t g(g_root)), and the default arena is not touched.

  • An injected root serves every purpose itself. The graph derives no sub-pools from it (derives_sub_pools() is false), so :stats.mem.values, .tables and .net answer SCHEMA_NOT_FOUND on this node; the root’s own census is the number to read.

  • This covers the pool, not the whole build. The graph’s vertex locks and value slot still use the build’s guard_t. A single-threaded core build binds that to tr::no_guard_t in its libtracer/config_override.hpp; the ESP-IDF component generates that file and offers no Kconfig option for it.

  • One task means one task. Anything else that reaches the graph, a link’s receive task included, makes tr::no_guard_t a data race. This node has no link.

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 node that runs on one task compiles its pool's locks away: an arena
 9 *        root over `tr::no_guard_t`, injected into the `graph_t`.
10 *
11 * The component's default root (`tr::mem::mcu_root_t`) locks its arena and each sub-pool with
12 * the build's `guard_t`, an interrupt-masked critical section on a chip, because a default has
13 * to be safe for any number of tasks. A node whose graph is touched by one task only declares
14 * its own root with `tr::no_guard_t` as the lock: each lock is then an empty type, every
15 * lock and unlock compiles to nothing, and the root is never larger. The lock is a template
16 * argument, so the choice costs nothing at run time and cannot be changed by mistake there.
17 *
18 * The root is the application's, over its own static region, so the linker map shows it as the
19 * app's `.bss`. An injected root serves every purpose itself: the graph derives no sub-pools
20 * from it. This covers the pool. The graph's own vertex locks and value slot still use the
21 * build's `guard_t`; a single-threaded core build binds that to `tr::no_guard_t` in its
22 * `libtracer/config_override.hpp`, which this component generates and does not expose.
23 */
24
25#include <array>
26#include <cstddef>
27#include <cstdio>
28#include <cstdlib>
29#include <span>
30#include <type_traits>
31
32#include "libtracer/guard.hpp"
33#include "libtracer/mem_arena.hpp"
34#include "libtracer/tracer.hpp"
35#include "sdkconfig.h"
36
37namespace {
38
39using tr::graph::path_t;
40using tr::graph::role_t;
41
42/** @brief Rows in the build's size-class table. */
43constexpr std::size_t kClasses = std::size(tr::graph::config_t::kSizeClasses);
44
45/** @brief The single-task root: the default root's shape with `tr::no_guard_t` as its lock. */
46using st_root_t = tr::mem::arena_root_t<tr::no_guard_t, kClasses>;
47
48static_assert(std::is_empty_v<tr::no_guard_t> && sizeof(st_root_t) <= sizeof(tr::mem::mcu_root_t),
49              "the lock is an empty type: it holds no state and its calls compile to nothing");
50
51/** @brief The app's arena region and the root's free-list heads, both zero-filled `.bss`. */
52alignas(64) constinit std::array<std::byte, 16384> g_region{};
53constinit std::array<void*, st_root_t::kHeads> g_heads{};
54
55/** @brief The root, constant-initialized over them. */
56constinit st_root_t g_root(
57    g_region, std::span<const std::size_t, kClasses>(tr::graph::config_t::kSizeClasses), g_heads);
58
59/** @brief Failed checks so far. */
60int g_failures = 0;
61
62/** @brief Print @p what with its verdict and count a failure. */
63void check(bool ok, const char* what) {
64    std::printf("  [%s] %s\n", ok ? "ok" : "FAIL", what);
65    if (!ok) ++g_failures;
66}
67
68/** @brief Print the verdict; on the `linux` target also exit with it, so CI can run this. */
69void finish() {
70    std::printf("RESULT %s\n", g_failures == 0 ? "ok" : "FAIL");
71#if CONFIG_IDF_TARGET_LINUX
72    std::exit(g_failures == 0 ? 0 : 1);
73#endif
74}
75
76}  // namespace
77
78extern "C" void app_main(void) {
79    std::printf("root object: %zu B with tr::no_guard_t, %zu B with the build's guard_t\n",
80                sizeof(st_root_t), sizeof(tr::mem::mcu_root_t));
81
82    tr::graph::graph_t g(g_root);  // every block this graph draws comes from g_root
83    const auto count = g.register_vertex(path_t("/counter"), role_t::STORED_VALUE);
84    for (std::uint32_t i = 1; i <= 100; ++i) {
85        const auto value = tr::view::over_bytes(std::as_bytes(std::span(&i, 1)), g.value_backend());
86        if (!value || !g.write(count, *value)) check(false, "write /counter");
87    }
88    const auto read = g.read(count);
89    check(read && (*read)->only().bytes().size() == sizeof(std::uint32_t),
90          "a hundred writes from one task, and the last one reads back");
91    check(!g.derives_sub_pools(), "an injected root serves every purpose itself");
92
93    const tr::mem::source_stats_t s = g_root.stats();
94    std::printf("app root: %zu of %zu bytes carved, %zu refusals\n", s.in_use, s.capacity,
95                s.refused);
96    check(s.in_use > 0 && s.refused == 0, "the graph drew from the app's root alone");
97    check(tr::mem::default_root().stats().in_use == 0, "and nothing from the default arena");
98    finish();
99}

Build and run

$ cd integrations/esp-idf/examples/concepts/single_threaded
$ idf.py set-target esp32c6 build
$ idf.py flash monitor        # board-only

CI builds it for esp32c6, and also builds and runs it on the ESP-IDF linux target. See also: a shared seam needs a thread-safe backend.