A production ESP32 node — the hardened profile¶
A detailed, opinionated recipe for running libtracer as the main communication stack of a real ESP32-class product — not a demo. Every rule on this page was learned the hard way on a shipped ESP32-C6 deployment (single-core RISC-V, 4 MB flash, Wi-Fi + CAN, a web UI, OTA, and libtracer as the graph plane) and then distilled here with the proprietary parts removed. Where the custom-device guide says what to expose, this page says how to run it within an MCU’s RAM, flash, and task budget.
The compile-tested starting point is the bundled
full_node example
(integrations/esp-idf/examples/full_node) — this page is the production hardening
layered on top of it.
1. The node profile: a bounded reactor¶
The shape that survives production is a bounded reactor: every resource the graph plane touches is an injected, fixed-capacity pool declared at init, and every overload surfaces as backpressure, never OOM. On a single-core MCU there is no second core to absorb a leak — the heap watermark only ever ratchets down.
Concretely (the full_node one-slab recipe, ADR-0039/0042):
/** @brief One static slab feeds BOTH memory seams — nothing grows at runtime. */
static std::byte g_slab[24 * 1024];
// Front region: pool_t — RX segments for owning transport delivery.
// Every inbound datagram lands in a pool slot; exhaustion = backpressure.
tr::mem::pool_t rx_pool{front_region(g_slab), /*slot=*/1536};
// Back region: monotonic + synchronized arena — router terminus & label tables.
std::pmr::monotonic_buffer_resource arena{back_region(g_slab).data(),
back_region(g_slab).size()};
std::pmr::synchronized_pool_resource shared{&arena};
Rules that follow from it:
Steady state allocates from the slab, not the global heap. After init, the ESP-IDF heap trace should show libtracer flat.
Never let allocation failure abort. ESP-IDF’s default C++
newthrows → on-fno-exceptionsthat is an instantabort(). A production node under memory pressure once crash-looped exactly this way until every allocation on the delivery path was converted to alloc-or-backpressure (drop the sample, count it, publish the counter — see §6). Audit any code path that calls throwingnew.Size the pool from the transport, not from hope.
udp_transport_tsizes RX segments tomin(64 KiB, backend->max_segment_size())— give the pool MTU-sized slots and datagrams arrive without a 64 KiB scratch buffer on a small thread stack.
2. Compose the node for its role — transports are the RAM lever¶
The single biggest idle-RAM finding from production: the core is lean; the threads are not free. Each socket/CAN listener a node loads costs a dedicated FreeRTOS task — stack + TCB ≈ 12 KB apiece — plus its protocol buffers. A node that enables TCP-listen “just in case” and CAN “because the silicon has it” carries ~24 KB of idle RAM for capabilities nobody wired.
So compose per deployment role, and load nothing else:
Role |
Load |
Don’t load |
|---|---|---|
Wi-Fi leaf publishing sensors |
1× WS or UDP listener |
TCP-listen, CAN |
CAN sensor pod |
|
all socket transports |
CAN↔IP gateway (forwarder) |
CAN + one socket transport |
the third transport |
Bench/debug image |
whatever you’re testing |
ship image ≠ debug image |
Listeners are config-created in-band (write /net:children[] SPEC{listener, kind=udp|tcp|ws, port}), so role composition is deployment
configuration, not a firmware fork — but the type set you compile in is the
flash/RAM commitment, so trim LIBTRACER_SRCS to the kinds the product ships.
Budget honestly: a full graph plane (codec, graph, router, one socket transport) adds tens of KB of idle heap over a bare-metal firmware. That is the legitimate cost of a real comms stack, not a leak — reclaim RAM by shedding transports and retiring the legacy stacks libtracer replaces, not by shaving the core.
3. Single-core tuning¶
CONFIG_LIBTRACER_VERTEX_LOCK_STRIPES=4(menuconfig → libtracer). The stripe table is the only global mutable buffer libtracer links; 16 stripes suit a multi-core host, while on a single-core chip 4–8 reclaims RAM at negligible contention cost (the lock-free LKV read/write hot path never touches stripes).Pin task priorities deliberately: transport RX threads just below the application’s control loop; the publish cadence belongs to the owner (the producer owns cadence — no libtracer throttling exists to save you).
The TWAI RX callback runs in ISR context and only enqueues; dispatch happens in a task. Keep your own handlers on the same discipline — an apply seam that does real work must defer, not run in the delivery path of a transport thread.
4. Task-stack discipline (the crash you will otherwise ship)¶
Two production incidents, one rule each:
Size stacks from stressed high-water marks, never idle ones. A stack that looks 40 % free at idle can overflow on the first deep path — the production crash was an HTTP-server task at ESP-IDF’s default 4096 B overflowing under a deep WS send path, weeks after it “worked.” Right-size = run the device at its boundary (max peers, churn of subscribe/unsubscribe, biggest frames, OTA in flight), then read
uxTaskGetStackHighWaterMarkper task and add margin. Publish the census as vertices (§6) so every future soak test re-checks it.Keep every stack override in versioned
sdkconfig.defaults. That 4096 regression happened because a working 8192 override lived only in a localsdkconfigand silently reverted on a clean checkout. If a stack size matters, it is configuration, and configuration lives in the repo:
# sdkconfig.defaults — stack sizes are product decisions, not local state
CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192
CONFIG_HTTPD_TASK_STACK_SIZE=8192 # if the node serves HTTP/WS
5. Network behavior under pressure¶
An oversized or unsendable WS frame is that frame’s problem, not the session’s. Drop the one delivery and count it; never tear down the peer’s session because one fan-out payload didn’t fit. (A session-drop here turns one slow subscriber into a reconnect storm.)
Egress is gather, not copy: the rope-to-wire path lowers to
sendmsg/iovec on lwIP unmodified. Don’t “help” by flattening payloads before send — the only legitimate flatten is a substrate boundary the DMA cannot span.Backpressure beats buffering. A slow subscriber gets stale-drop (bounded ring, newest wins) — an unbounded egress queue on a 512 KB-RAM chip is just a crash with extra steps.
6. Observability is vertices (self-describing, per the interop model)¶
Everything you need during a soak test should be readable — and subscribable —
through the node itself, described via :schema like any other data
(interoperability):
/system/
├── heap/free u32 bytes — current free heap
├── heap/min_free u32 bytes — lifetime low-watermark (the number that matters)
├── tasks/<name>/hwm u32 bytes — per-task stack high-water mark
└── drops/<counter> u32 — backpressure counters (WS drops, pool exhaustion, …)
The min_free trend under stress is the single most predictive health signal a
fleet dashboard can watch; per-task HWM vertices make §4’s re-check a read, not
a JTAG session.
7. Build-system gotchas (ESP-IDF specifics that cost days)¶
A
CONFIG_*-gatedPRIV_REQUIRESnever propagates — component requirements resolve before Kconfig runs. Gate SRCS onCONFIG_*, keep REQUIRES unconditional, and make sure some CI job builds each Kconfig-gated TU.Every new core source must also be appended to the component’s
LIBTRACER_SRCS(integrations/esp-idf/libtracer/CMakeLists.txt) or the chip build fails to link while host builds stay green.Platform TU selection is a build-system concern, not
#ifdefs: chip targets compiletwai_link.cpp+ a SocketCAN stub; thelinuxtarget compiles real SocketCAN and no TWAI. Extend the pattern, don’t macro around it.Build with
-fno-exceptions -fno-rttiand treat any throwing construct on the delivery path as a bug (§1).
8. Flash layout¶
Two OTA app slots + rollback (
CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y): a new image must confirm itself healthy or the bootloader reverts.Ship web assets in a dedicated data partition, not inside the app image — a UI tweak then doesn’t burn an app slot, and the app image stays under the slot ceiling. Beware the staleness footgun: a partition flashed once and forgotten will happily serve last month’s UI against this month’s firmware; make the asset write part of the same release step as the app OTA.
Expect the graph plane to be roughly flash-neutral-to-negative vs the ad-hoc stacks it replaces (protocol handlers, bespoke framing, glue): the production cutover measured the firmware smaller after migration, with the codec/graph cost more than offset by deleted legacy.
9. Validate like production, not like a demo¶
The measurement discipline that made the numbers above trustworthy:
Pin the config before comparing. Idle-heap deltas between two images are meaningless unless both
sdkconfigs are diffed — most “regressions” were config drift, not code.Rebuild your own baselines. Never trust a previously-flashed board’s label; flash both images yourself in the same session.
Churn is the real test: hundreds of rounds of subscribe/unsubscribe, create/delete, connect/disconnect while publishing at rate. Crashes hide in the churn path, not the steady state.
Bank numbers only from the boundary: min-free heap and per-task HWM read after the stress run (§6), on the shipping image, with the shipping transport set (§2).
Putting it together¶
boot ─► NVS/config ─► one-slab init (§1) ─► graph_t + vertices + :schema tables
─► fwd_router + transport_vertex (catalog: only the kinds this role ships, §2)
─► /net:children[] SPEC writes create the role's listeners
─► observability vertices registered (§6)
─► owner loop: sample hardware ─► write vertices (fan-out) ─► feed watchdog
A node built to this profile runs libtracer as its primary stack in a few tens of
KB of RAM, degrades under overload by dropping data instead of sessions or
uptime, and exposes everything an integrator — or a fleet dashboard, or a
stranger’s coding agent — needs through the same three verbs and :schema as
every other libtracer device.
For wiring these ideas into an existing ESP-IDF firmware with its own legacy stack, see the integration walkthrough and the custom-device guide.