# Changelog — libtracer ESP-IDF component
Notable behavior changes of the `integrations/esp-idf/libtracer` component (the
ESP-IDF packaging of the `core/` reference implementation plus the chip-native
platform links: `httpd_ws_link_t`, `twai_link_t`), per
[CLAUDE.md](../../../CLAUDE.md). The core C++ API itself is tracked in
[core/CHANGELOG.md](../../../core/CHANGELOG.md).
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
## [0.9.1] — 2026-08-10
### Changed
- **Picks up the core's `grammar::total_size_fits` split (#1177).** This component compiles the
core C++ sources, so the change reaches it — and this is the target it was careful about.
On a 32-bit target (`std::size_t` is 4 bytes on every ESP32 variant this component builds
for) the compile-time dispatch selects `total_size_fits_narrow`, which is the #921 wrap-free
subtractive chain **verbatim**. The rv32 overflow check against a hostile
`length = 0xFFFFFFFF` is byte-for-byte what it was, and the "+1 instruction on a
trailer-less header" cost #921 recorded still describes what this component compiles. The
speedup #1177 measured is a 64-bit-host effect and does not apply here; nothing regresses.
- **Picks up the core's WebTransport handshake OOM scoping (#1108).** Only relevant to a build
that opts into QUIC/WebTransport, which this component does not build by default.
## [0.9.0] — 2026-08-10
### Added
- **`httpd_ws_link_t::stats()` — the link-level failure tally (#953).** A new
`stats_t` snapshot covering the drop classes that had **no session to charge** and were
therefore invisible from every published surface: `peers_refused` (the admission
predicate and the `max_peers` ceiling, both of which turn a peer away before it owns a
slot), `sessions_condemned` (the only record of the link *killing* a session as opposed
to a peer leaving), `rx_dropped_oversize` (the abuse cap, applied before the slot lookup
— it had neither a log nor a counter), `rx_dropped_alloc`, `tx_to_dead_peer` (a frame
aimed at a session that had already gone), and `tx_pool_misses`.
`tx_pool_misses` is a **labelled subset of `enqueue_drops`, not a second tally** — the
total is unchanged. It exists because the three causes folded into `enqueue_drops` mean
different things: a pool miss is this link's own outstanding-send depth (read it against
`tx_slot_capacity()`), while a refused enqueue names the shared control queue and an OOM
names the heap. Their one log line said "queue refused / pool exhausted / OOM" and left
the reader to guess.
`enqueue_drops()` is unchanged and keeps working; `stats().enqueue_drops` returns the
same number, and a test pins the two spellings together so they cannot drift.
Two drops that were **entirely silent** — no log, no counter — now say so: the inbound
abuse cap and the reassembly cap.
**Scope note.** Most of what #953 reported as missing had already shipped: per-peer
`rx_frames`/`rx_bytes`/`tx_frames`/`tx_bytes`/`rx_drops`/`tx_drops` are carried by
`link_counters_t` through `enumerate_peer_stats`, `esp_ws_client_link_t` already has its
own `stats()`, and #949 removed the unbounded heap fallback the issue's part B was
written against. This change is the genuine residual.
**Cost, measured** (`-Os`, the level the component ships at): `httpd_ws_link.cpp.o`
`.text` **13079 → 13631 B (+552, +4.2%)**. Nothing was added to a per-frame path — every
counter sits on a refusal, a teardown or a drop. `tx_work` and `queue_send` are byte-for-byte
unchanged; `on_data_frame` moves **+23 B**, and that number is the point: it was **+350 B**
until the two new log call sites were moved out of line behind `[[gnu::noinline]]`
helpers, the same inlining trap #994 paid for. Compile is deterministic (two builds of
the same source are byte-identical), so the A/A null here is exactly 0.
- **`httpd_ws_link_t::peer_named()`** — the `bus_link_t` mode-authority override (#889).
The same constructed flag `bus()` keys off, published so the base can REFUSE its
peer-named wiring calls (`set_peer_receiver`, `set_peer_rope_receiver`,
`set_peer_down_notifier`) on a FLAT link: `bus_link_t` is a public base, so those setters
are reachable by an upcast past the null `bus()`. This link's delivery and departure
paths already read the flag directly, so nothing else about it moves.
- **`httpd_ws_link_t::kRequiredHttpdStack`** — the httpd task stack this link's in-call
servicing needs (12288 bytes), promoted from a constant in the `.cpp`'s anonymous
namespace to a public `static constexpr` on the class (#955). The port-binding
constructor applies it itself; the ADOPTING constructor cannot — it takes an
already-started server, so there is no `httpd_config_t` left to write and
`esp_http_server` exposes no reader for a running server's config either. The figure was
therefore unreadable by the one party who can act on it, which is how an integration came
to start its shared `:80` server with 8192. Write
`config.stack_size = tr::net::httpd_ws_link_t::kRequiredHttpdStack;` before `httpd_start`.
The adopting constructor's documentation now also states the other two server-level
settings the port-binding one establishes and it cannot — `lru_purge_enable = false` and
a socket budget consistent with `max_peers` — and what each one costs when it is wrong.
Neither can be verified from inside the link; they are stated where the person who can
apply them reads.
- **`tr::net::link_counters_t`** (`libtracer_esp/link_stats.hpp`) plus
**`esp_ws_client_link_t::stats()`** and **`httpd_ws_link_t::enumerate_peer_stats()`** —
per-link passive traffic counters (#942): rx/tx messages and payload bytes, rx/tx drops,
and `esp_timer` stamps for the last delivered message and for when the connection came
up. Counts MESSAGES, not WebSocket fragments, so a client's `tx_frames` is directly
comparable with the server's `rx_frames` across a link. The client's snapshot also
carries `reconnects` (completed handshakes — the only externally-observable signal that
a transient drop happened, since `drop()` deliberately suppresses `notify_down`) and
`connect_ms` (the last handshake's duration; an upper bound on ~2 round-trips sampled
only at re-dial, never an RTT). Server-side counters are per SESSION and are handed out
with the slot's claim generation, so a consumer differencing successive snapshots can
tell "same connection, N more frames" from "a different peer landed on that slot".
`stats().c.rx_drops` is not a second tally: it is read from the existing
`dropped_rx()` below, so the client link keeps exactly one inbound-drop truth.
Scope is deliberately narrow — `transport_t` grows no `counters()` virtual and
`fwd_router_t` no per-child accounting, because the only consumers are hosts that
enumerate the two concrete link types they constructed themselves. A polymorphic hook
would put a counter bump on core's hot `deliver_remote` path and buy nothing.
- **`httpd_ws_link_t::tx_slots_busy()` / `tx_slot_capacity()`** — TX work slot
observability (#944): the pool's live occupancy and its size, so a caller can see the
pool being starved rather than infer it from allocation pressure. (This entry originally
also introduced `tx_strands()`; that accessor was removed again before release — see the
BREAKING section above — and neither it nor the sweep it reported on ever shipped in a
tagged component.)
- **`esp_ws_client_link_t::dropped_rx()`** — inbound messages the receive path refused,
spelled the way core's transports already spell it (`transport_can::dropped_rx()`).
It counts the two refusals that were previously logs-only (#953): a message that does
not fit `rx_bytes`, and a stray WebSocket CONTINUATION arriving with no message open.
A frame lost to a connection drop is not counted — that is the link going down, not
the receive path refusing a message.
### Changed
- **BREAKING: `esp_ws_client_link_t`'s handshake headers moved into the constructor and
`set_handshake_headers()` is gone** (#959). The setter could not work: the constructor
spawns the recv thread, the recv thread dials at once, and the setter necessarily ran
after both. So the *only* ordering the API permitted was the racy one — a `std::string`
assigned by the wiring task while `connect_once()` read it with `.empty()`/`.c_str()`,
where a reallocating assignment can hand `esp_transport_ws_set_config` a `cfg.headers`
that is already freed. Nothing in the API decided which side of that race won, so
whether the FIRST dial carried the token was **undefined** — and a dial without one is
refused by a peer whose graph WS gates admission, costing a 1.5 s reconnect backoff
before the next dial. That is what made first-dial liveness nondeterministic by
construction. Which side wins in practice is a scheduler property, not an API one: it
was never measured on chip, and on the host fake a rebuilt pre-fix ordering had the write
win **20 of 20** measured runs. That reconstruction assigns inside the constructor
immediately after the thread spawn — a *narrower* write-to-read window than the setter it
stands in for — so the rate is the arrangement most favourable to the write winning, on
one host and one scheduler. It says the write won the runs that were made; it does not say
the opposite interleaving cannot occur. A tokenless first dial is therefore what the old
surface *permitted*; it is not a behaviour recorded here as observed.
The headers are now the **fourth** constructor parameter (after `ws_path`, completing
the "what the handshake requests" group) and the member is `const`: written before the
thread that reads it exists, re-read on every re-dial, no lock and no snapshot. Empty
still leaves `cfg.headers` NULL, so a dial without a token is byte-for-byte the
historical request. Placing it fourth rather than last is deliberate — appending it
would have forced any caller wanting a token to restate `rx_bytes`/`tx_bytes`/
`recv_stack` positionally, pinning today's defaults into call sites that never meant to
choose them.
**What breaks:** `link.set_handshake_headers(tok)` after construction no longer compiles;
move `tok` into the construction site. A call that passed buffer sizes positionally
(`{host, port, "/ws", 4096, 4096, 12288}`) also no longer compiles — `std::string` is not
constructible from `std::size_t`, so a size in the fourth slot is a hard error.
**One exception, and it is silent:** a literal `0` in that slot is a null-pointer
constant, binds to `std::string(const char*)` and yields `std::string(nullptr)`, which is
UB at run time — `L(host, 8080, "/ws", 0, 0, 0)` compiles clean under
`g++ -std=c++20 -Wall -Wextra`. Grep call sites for a literal `0` in the old `rx_bytes`
position before trusting the compiler here.
`libtracer_esp/esp_ws_client_link.hpp` is the only file involved.
This is deliberately NOT a `start()` split. Deferring the recv thread would make the
whole "set X before the first dial" family expressible, but its other motivation (#900's
discarded `recv_stack`) was already answered by honouring the knob at spawn time, and
the shape of a deferral on this link — which dials, re-dials and delivers on one thread —
is the open contract question in #1102, not something to settle as a side effect here.
- **`esp_ws_client_link_t` now LOGS an outbound frame that exceeds `tx_bytes`**, with both
sizes, alongside the `tx_drops` bump that landed with #942 (#959). `transport_t::send`
returns void, so the router cannot be told the frame died; `tx_drops` says one did, but
`send()`'s peer-down and short-write arms bump that same counter, so a bump does not even
say which drop happened, let alone which knob was too small. A per-frame ceiling nobody
can see is how a blob-carrying value or a composed reply vanishes with clean logs on both
ends. The log is the new channel and the only one that names a size; the counter is
unchanged. The 2048-byte defaults are unchanged — they are documented as modest on
purpose and raising them costs RAM on every dialed peer; what was wrong was the silence
around exceeding them, not the number.
- **BREAKING (chip targets): the portable `ws` transport is no longer built, and no `ws`
entry is registered in the built-in transport catalog.** ESP-IDF WebSocket must never
use POSIX sockets (#947 ruling). Core's `transport_ws_server` / `transport_ws_client`
are the HOST implementation; on lwIP they are not merely unreachable but *unusable* —
their scatter-gather egress asks `sendmsg` for `MSG_NOSIGNAL`, `lwip_sendmsg` rejects
any flag outside `MSG_DONTWAIT|MSG_MORE` with `EOPNOTSUPP`, and `write_all_iov` reads
that as peer-gone, so every data frame is silently discarded while the opening
handshake and PING/PONG still answer (#948). They are therefore **absent**, not fixed.
The sanctioned plane is the IDF-native links this component already ships:
`httpd_ws_link_t` (on `esp_http_server`, needs `CONFIG_HTTPD_WS_SUPPORT=y`) and
`esp_ws_client_link_t` (on `esp_transport_ws`), both bound by the application through
`transport_vertex_t::provide_link`.
Selection is by **which TU compiles**, not a feature macro — the rule
`socketcan_link.cpp` vs. `socketcan_link_stub.cpp` already follows. The `linux` (POSIX
host) target is unchanged and keeps the portable pair; it has glibc's `sendmsg` and no
`esp_http_server`.
**What breaks:** a `:children[]` SPEC carrying `kind=ws` with no staged link now
answers `SCHEMA_NOT_FOUND` on a chip target instead of constructing a portable
socket server that could not deliver anyway. Nodes already staging an IDF-native link
with `provide_link` are unaffected. `CONFIG_LIBTRACER_TRANSPORT_WS` keeps its meaning
("build the WebSocket plane") — only *which* plane it builds is now target-decided.
Measured on `examples/full_node`, esp32c6, `-Os`, ESP-IDF v6.0-dev: `nm` on the linked
ELF went from **46** `transport_ws_server`/`transport_ws_client` symbols to **0**, and
flash fell 394,146 → 381,098 B (**−13,048 B**; `libtracer.a` −12,769 B, image `.bin`
−13,040 B). Flash here is tracked, never gated. `tools/check_esp_ws_plane.py` is the
standing gate.
- **`examples/full_node` now sets `CONFIG_HTTPD_WS_SUPPORT=y`,** so `httpd_ws_link.cpp`
— the sanctioned chip WebSocket *server* — is compiled by CI. It previously had no
compile coverage anywhere: the config it is gated on defaults to `n`, and the portable
server it replaces was silently filling in.
### Fixed
- **A chip build can no longer instantiate the spinlock pool (#1158, #963.3).** The component
compiles `core/src/mem_pool.cpp` into every image, which exports `tr::mem::sync_pool_t` —
the shorter, more discoverable name, documented by core as *"the multi-core-host spelling"*
— while the correct type for a chip lives in a separate header whose own doc concedes it is
opt-in. Nothing failed the build, so reaching for the wrong one compiled cleanly, passed a
smoke test, and surfaced only as a `task_wdt` wedge under load with a particular priority
ordering.
The component now derives core's new `LIBTRACER_SPIN_WAIT_SAFE` from `IDF_TARGET` into the
generated `config.hpp` — **`false` on every chip target**, `true` on `linux` — and core's
guard turns the mistake into a compile error naming `tr::esp::critical_pool_t`. Read from
`IDF_TARGET` rather than a `CONFIG_*` symbol deliberately: `CONFIG_*` is undefined in IDF's
early requirement-expansion pass, so a Kconfig-derived value would render one way there and
another in the build pass.
`false` on **every** chip, not only the single-core ones: the hazard is priority-preemptive
scheduling, not core count, so an SMP chip whose spinner and holder happen to share a core
wedges exactly as a C6 does — which is also why `portmux_sync_t` takes the portMUX spinlock
in addition to disabling interrupts.
No footprint change: `libtracer.a` is byte-identical, and `full_node` + `inprocess_mirror`
build unchanged for esp32c6 with the guard armed.
- **Public headers now propagate their ESP-IDF dependencies (#963.4).** `esp_http_server`,
`tcp_transport` and `esp_driver_twai` moved from `PRIV_REQUIRES` to **`REQUIRES`**. Those
three are named by headers under `include/libtracer_esp/` (`httpd_ws_link.hpp:119`,
`esp_ws_client_link.hpp:157`, `twai_link.hpp:36-37`), and `PRIV_REQUIRES` does not
propagate include dirs — so a dependent that included one of ours without independently
requiring the base component died with `esp_http_server.h: No such file or directory` and
no hint that libtracer was the cause. `lwip` and `esp_driver_gpio` stay private: they are
named only from `.cpp`. The CMakeLists' prose claim that libtracer's public headers expose
no IDF types was true of `core/` and false of `libtracer_esp/`; it is now scoped.
- **`CONFIG_LIBTRACER_TRANSPORT_WS` now delivers the WS server link it advertises (#963.5).**
The option's help promised `httpd_ws_link_t`, but the TU was gated on
`CONFIG_HTTPD_WS_SUPPORT`, which defaults **n** in ESP-IDF and which the Kconfig neither
selected nor mentioned. A stock project taking the default got the WS *client* link, no
server link, and a bare `undefined reference to httpd_ws_link_t::httpd_ws_link_t(...)` at
link time naming no cause — the public header stays includable either way, so nothing
failed earlier. Kconfig now `select`s `HTTPD_WS_SUPPORT`, and the CMake gate emits a
`message(WARNING)` naming the symbol when an existing sdkconfig pins it off.
- **`handle_` and `gate_` are atomic — the TX path no longer races the destructor
(#963.2).** `claim_tx_slot`'s teardown gate documents the TX path as safe to run past the
destructor, but `queue_send` read two **plain** members the destructor concurrently
writes: a data race and UB by the memory model on every build. Both are now
`std::atomic`, loaded **once** each into a local at the top of `queue_send` (they were
read up to three times per send, so one send could act on two different values of the
same member). Every other cross-thread member in the class was already atomic; these two
had simply not been made to match.
**This closes the UB, not the lifetime window.** A producer can still load a handle the
destructor is about to `httpd_stop`, or a gate it is about to `delete`. What bounds that
is the gate's existing `depth`/`cv` barrier, not the atomicity — and making these atomic
is the precondition for reasoning about that barrier at all.
- **A condemned session leaves the bus facet immediately (#963.1).** `enumerate_peers` and
`peer_link` filtered on `open` alone and ignored `dead`, while *every* sending path
already refused a dead slot. Through the condemn→reap gap the link therefore answered
"this peer exists" and "this peer is unreachable" at the same instant, and `peer_link`
handed out an endpoint that was a guaranteed no-op. Both readers now also require
`!dead`: the facet reports **reachability**, not table occupancy.
**Behaviour change:** `peer_link` returns `nullptr` for a condemned-but-unreaped peer
where it previously returned a live pointer that silently discarded every frame.
**Cost, measured** (`-Os`): `httpd_ws_link.cpp.o` `.text` **13631 → 13833 B (+202,
+1.5%)**, A/A null exactly 0 (byte-identical rebuilds). The per-frame paths are
**unchanged** — `queue_send` and `tx_work` are byte-for-byte identical, because a relaxed
atomic load of a pointer is a plain load. `peer_link` +9 B, `on_data_frame` +8 B; the
destructor takes +417 B, which is teardown-only.
- **The derived WS send bound now accounts for IDF's two writes per frame, and can no
longer derive to "block forever" (#956).** Two further corrections to
`derive_send_timeout_ms`, on top of the strike cap (#840):
- `SO_SNDTIMEO` is a **per-`send` property**, and `httpd_ws_send_frame_async` writes a
frame as a header call plus a payload call (`esp_http_server/src/httpd_ws.c:447`,
`:455`), so one frame to one stalled peer parked the httpd task for **twice** what the
derivation claimed. `kIdfWsWriteLegs` is now the third divisor.
- The division had **no floor**: a large-but-legal `max_peers` truncated it to `0`, and
`SO_SNDTIMEO = 0` means *block forever* — exactly the failure the derivation exists to
remove. Clamped so no configuration can reach "unbounded" by arithmetic.
**Behaviour change:** the default derived bound is now **208 ms** at the default cap of 4
(was 416 ms after #840, 1250 ms before it). A peer that cannot absorb a frame inside the
tighter bound accrues a strike sooner; a healthy peer is unaffected, since any completing
send resets the streak. An embedder that wants the old latitude should pass an explicit
`send_timeout_ms`.
Also **logged, not fixed**: on an *adopted* server `max_peers = 0` means "unbounded" to
admission but "assume 4" to this derivation, and `esp_http_server` exposes no reader for
the real `max_open_sockets` — so the mismatch cannot be detected, only declared. The
constructor now warns once. Whether `0` should remain legal in adopted mode is an API
question this does not decide.
- **A post-101 refusal now spends the reconnect backoff instead of spinning (#1128).**
`esp_ws_client_link_t`'s backoff was gated on `connect_once()` returning false, but an
admission refusal can only be expressed *after* `101 Switching Protocols` is on the wire —
so `esp_transport_connect` succeeds and the refusal looked like a connection that later
dropped, not a failed dial. The loop re-dialed with **no delay at all**, re-allocating a
transport pair every cycle; on a single-core target that starved the idle task, tripped the
task watchdog, and the board panic-rebooted — resuming the dial on the next boot, so one
misconfiguration could sustain a reboot loop. A connection that goes down having never had
an inbound message delivered is now treated as a failed attempt and pays the same
`kReconnectBackoffMs` a failed dial does. **Inbound is the test** — a send into a socket the
peer has already decided to close still succeeds locally, so only a message *arriving*
proves the peer admitted the session. A link that has exchanged traffic still retries a
genuine drop immediately: the backoff is conditional, not blanket.
- **The derived WS send bound now divides by the strike cap as well as the peer cap
(#840).** `derive_send_timeout_ms` computed `CONFIG_ESP_TASK_WDT_TIMEOUT_S / peers`, which
makes ONE fan-out round of fully stalled peers fill the entire watchdog window. But a peer
is not condemned on its first failed send — the brokenness detector wants
`kMaxConsecutiveTxDrops` consecutive failures and each one costs a full bound — so the
worst-case stall before the link is rid of a stalled peer set was `strikes` whole windows,
not one. Downstream HIL measured 4959 ms of a 5000 ms budget consumed (99%), i.e. whether
the board panics decided by noise. The bound is now
`watchdog / (peers * kMaxConsecutiveTxDrops)` — 416 ms at the default cap of 4, was
1250 ms. (#956 above then added IDF's two write legs as a third divisor, taking the
shipped value to 208 ms; 416 ms was never released on its own.) **Behaviour change:** a
peer that cannot absorb a frame within the tighter bound
accrues a strike sooner, so a genuinely stalled session is torn down faster; a healthy one
is unaffected, since any completing send resets the streak.
- **`httpd_ws_link_t::set_admission_cb`'s predicate now runs where the opening GET actually
arrives — the server's WebSocket PRE-handshake callback — and its refusal stops the
upgrade (#958).** `esp_http_server` answers the WebSocket handshake internally and returns
from `httpd_uri()` *before* calling `uri->handler`, so a predicate consulted from the URI
handler was never handed the opening GET at all: the handler is entered only for data
frames, on an already-upgraded socket, and the peer is claimed there. The link now
registers a `ws_pre_handshake_cb` thunk at **both** construction sites (own-server and
adopted-server) which is called with the parsed opening GET — method, URI and every header
readable through the ordinary `httpd_req_get_*` accessors — before the 101 is written and
before the session latches the WS route. Returning `false` refuses the peer: no 101, no
session, no slot, no per-socket policy, no entry in `enumerate_peers`. Unset (still the
default) admits every peer, so an unconfigured link is unchanged. In adopted mode the
predicate is scoped to this link's WS URI — registration is per-URI, so the rest of the
caller's HTTP surface is untouched.
**Consumer-visible consequences.** (1) The component's `Kconfig` now
`select`s `HTTPD_WS_PRE_HANDSHAKE_CB_SUPPORT` when `CONFIG_HTTPD_WS_SUPPORT` is on — that
is where the `httpd_uri_t` member lives, and it is present at this component's ESP-IDF
floor (`>=5.5.5`), so there is no fallback tier and no second code path. (2) The
predicate's `httpd_req_t*` is now a *handshake* request rather than a data-frame one,
which is what makes header inspection meaningful; a hook that read nothing off it is
unaffected. (3) A predicate is consulted once per CONNECTION, at the handshake, instead of
once per opening-GET dispatch that never happened.
- **Documentation erratum, shipped in the same change (#958).** The header's threading
section claimed "the WS URI handler is invoked once at the opening handshake (HTTP GET)
and again for each subsequent data frame", and `set_admission_cb` / the `max_peers`
parameter documented a refusal "at the top of every opening handshake". Neither described
the code: the handler is never invoked for the opening GET, and both the admission
predicate and the `max_peers` cap were reached only on a peer's FIRST DATA FRAME. The
admission half is now true because the code moved; the `max_peers` text is corrected to
say where that check really happens (first frame — this change does not move it). The
0.7.1 `set_admission_cb` entry below is left as the historical record and is superseded
by this one.
- **The dead opening-GET handler path is deleted (#958).** `on_handshake` — a second peer
claim site, a second `max_peers` check and a second `bound_socket` call — was unreachable
for a real WebSocket peer at the component's ESP-IDF floor, and reachable only by a plain
non-upgrade GET on the WS URI, which it answered by claiming a peer slot for a socket that
was not a WebSocket. Such a request now falls through to the frame path, where
`httpd_ws_recv_frame` answers `ESP_ERR_INVALID_STATE` on a socket with no handshake done
and the server closes it. `on_data_frame` is now the ONE claim site.
- **A FLAT `httpd_ws_link_t` no longer reports the WHOLE LINK down when one of its several
sessions closes (#889).** `notify_departed` forked on the constructed mode correctly —
peer-named evicts just the departed peer — but the FLAT arm fired
`transport_t::notify_down` on **every** session close, and that hook is
`fwd_router_t::link_down`: it drops every subscriber edge and label binding registered
under the link's one NAME. Both defaults that make this reachable are the defaults
(`peer_named = false`, `max_peers = 0` unbounded), so a two-tab deployment that never
asked for the bus facet had one tab's hangup evict the other tab's subscriptions. The
flat arm now waits for the LAST open session to depart (`any_open_session()`); a mid-life
close notifies nothing and the survivors keep routing. This is the same rule
`slot_server_t::teardown_slot` gained in core, in the one link that cannot inherit it —
the issue named only the core servers, so this instance would otherwise have shipped
unfixed.
- **`httpd_ws_link_t` closes a session whose frame was announced on the wire and then cut
off, instead of dropping it as a recoverable frame** (#951). `esp_http_server` writes one
WebSocket frame as TWO calls to the session's send function — the header, then the
payload — and reports either failure as the same `ESP_FAIL`, so the link could not tell a
frame that never started from one truncated after its header had gone out. It treated
both as the #481 whole-frame loss: drop the frame, keep the socket. For the second that
is unsound. The peer holds a header promising bytes that never arrive and consumes every
later frame as this one's missing payload, so delivery stops on a socket both ends still
consider open, and the very next small frame that succeeds resets the failure streak, so
no teardown ever follows. The send override now brackets each frame and judges the FRAME:
a failed write with bytes of that frame already on the wire is a stream desynchronisation
and closes the session at once (the short-write response), while a failure with nothing
written stays the droppable case it was. Behaviour change for consumers: such a peer now
gets a close — and therefore an `onclose` and a reconnect — where it previously went
silent. The desync log line names its cause (`short write` / `frame truncated`) and the
bytes on each side, and no longer shares wording with the benign drop.
- **A receive-only graph peer is no longer the preferential LRU purge victim on an adopted
server** (#955). Apart from the explicit `httpd_sess_update_lru_counter` API, ESP-IDF
advances a session's LRU counter from inbound request processing only
(`httpd_sess_process`); a server-initiated push does not touch it. So on a host that
runs with `lru_purge_enable = true`, a peer that subscribes and thereafter only RECEIVES
ages toward the lowest counter no matter how much this link is pushing to it — and
`httpd_accept_conn`'s victim search (which skips only `for_async_req` sessions, never a
WebSocket one) picks exactly that peer once the socket table fills. The eviction is
well-formed, which is why it was invisible: it reached this link as an ordinary peer
departure, indistinguishable from a hang-up. In adopted mode the link now calls
`httpd_sess_update_lru_counter` after each successful send.
Not immunity, and must not be read as any: at the host's socket ceiling some session is
still closed. What changes is that the peer this link is actively serving stops being the
one chosen first. Owning mode does not make the call — it sets `lru_purge_enable = false`
on the config it starts the server with, so there is no purge to defend against and no
session-table scan to pay for.
- **A too-small httpd task stack is now NAMED once instead of arriving as an unexplained
reboot** (#955). At each session claim (once per connection, beside the socket options
admission already applies) the link samples `uxTaskGetStackHighWaterMark` and logs one
`ESP_LOGE` if the free headroom has fallen below the margin `kRequiredHttpdStack` buys
over the 8 KB the same measurement found overflowing. It cannot prevent the overflow —
the mark it reads is already a historical minimum — and it does not verify the
precondition above; it converts a stack-protection panic on the batch-apply path from an
unrelated-looking flake into a stated cause. The link stops sampling once it has
reported.
- **Both WS links now detect a peer that vanishes without a FIN, and the client link
reports its departure** (#957). Two halves of one gap.
(1) **Detection, both links.** Nothing in either link noticed a peer that stopped
existing — a Wi-Fi drop, a power cut, a killed browser tab, a NAT rebind. On the server
the ways a session can end are peer CLOSE/FIN, a handler failure, the TX failure streak,
the short-write condemn and `httpd_stop`, with no timer among them, and
`lru_purge_enable = false` (the admission contract that forbids evicting a live peer)
removes the one reclaim `esp_http_server` has for an idle socket — so such a peer held
its slot and one unit of `max_peers` for the life of the process. On the client, a poll
turn that times out is normal and there was no idle deadline, so `ok()` answered `true`
for a dead peer indefinitely on an idle link. Both links now apply `SO_KEEPALIVE` plus
the idle/interval/count tunables to the socket — `httpd_ws_link_t::bound_socket` at
admission, `esp_ws_client_link_t` on every dial — using the values ESP-IDF documents as
the defaults for `esp_http_server`'s own keepalive, so both ends of a board-to-board
connection declare a peer dead at the same age. The server link applies them itself
rather than relying on an adopted server's `keep_alive_enable`, for the same reason it
applies its own send bound. Best-effort, and only behind the enable: a stack that
refuses `SO_KEEPALIVE` keeps the previous behaviour for that one peer rather than half a
policy. **Behaviour change:** a vanished peer's session is now failed by the stack and
reclaimed (server) or dropped and re-dialed (client), on the order of tens of seconds
instead of never.
(2) **Reporting, client link.** `esp_ws_client_link_t` called `notify_down()` from
nowhere: peer CLOSE, a read error, a poll error and a failed or short write were all
silent, so the one departure seam a point-to-point link has
(`transport_t::set_down_notifier`, which `fwd_router_t::add_child` wires to the eviction
of that child's subscriber edges and label bindings) never fired, and the header's claim
that this type substitutes for core's `transport_ws_client` "with no other change" was
false on exactly that seam. The recv loop's not-connected arm — which is downstream of
every one of those paths — now fires it once per connection that was up, with no
transport lock held, before it re-dials; a LOCAL teardown still reports nothing, on
core's `stop_` rule. **Behaviour change:** a reconnect is now a NEW session to the
routing plane. The old premise (keep the edges so deliveries resume transparently) holds
only for a blip the far side also survives, and the link cannot tell one from a peer
that rebooted and forgot every subscription and label it ever issued — which left this
node producing into a session that no longer existed and resolving compact labels
against a stranger's label space. Re-establishing after a flap costs edge churn; that is
the accepted trade.
- **`esp_ws_client_link_t` got a bounded-blocking discipline: a derived write bound, an
interruptible backoff, and a teardown barrier** (#952). Three defects on one seam —
`write_m_` and `stop_` were treated as if the operations under them were prompt.
(1) `send()` held `write_m_` across a 4000 ms write timeout that IDF's `_ws_write`
spends up to three times over (poll, header, payload), so a peer with a closed TCP
window parked the *calling* task for up to 12 s against a typical 5 s task watchdog —
a panic, not a dropped frame — and blocked the recv thread, which serializes its reads
on the same mutex, from even seeing the CLOSE. Both blocking bounds are now derived
from `CONFIG_ESP_TASK_WDT_TIMEOUT_S` the way the server sibling's send bound already
is (#835): a dial gets half a watchdog window and one whole `send()` a quarter of one,
and `SO_SNDTIMEO` is set on the socket next to the existing `TCP_NODELAY` so the
blocking write leg is bounded too. **Behaviour change:** a peer that cannot accept a
small frame within that budget now has its connection torn down and re-dialed instead
of parking the sender; a dial that does not complete within half a watchdog window is
retried rather than waited out. (2) The reconnect backoff was a plain `sleep_for`, so
the destructor's join waited out the full 1.5 s of exactly the unreachable peer a
re-dial exists for; it is now a condition-variable wait the destructor signals.
(3) The destructor destroyed the transport handles with `write_m_` held nowhere, on
the premise that the joined recv thread was the only handle user — which this type's
own contract contradicts (`send()` may be called from any task), so a sender queued
behind a stalled write woke up owning a destroyed handle. Teardown now disarms the
link, destroys the handles under `write_m_`, and waits out every sender that had
already *announced itself on the in-flight tally* — raised at the top of `send()`,
before it queues on that mutex. That tally is the covered boundary, and it is the most
a barrier inside the object can cover: a caller that has not reached it when the
destructor reads it for the last time is not waited out, whether it is a `send()` that
*starts* after the destructor returns or one that entered `send()` and is still short
of the raise. Both stay the embedder's lifetime problem. A sender also reads the
transport handle only behind the `connected_` acquire gate that pairs with the recv
thread's release store: the re-dial rebuilds the handles holding no lock, so `write_m_`
alone does not order that rewrite against a sender's read. No API change; a teardown
that lands mid-dial still costs one dial bound, since `esp_transport_connect` takes no
cancellation.
- **`httpd_ws_link_t::send` (the broadcast) no longer allocates, so a fan-out during a
heap trough drops a frame instead of aborting the node** (#961). The fan-out opened by
building a `std::vector` of destinations under `peers_m_` — the one container shape the
rest of that translation unit is written to avoid, because under `-fno-exceptions` its
throwing allocator turns a failed growth into `abort()` inside libstdc++'s `bad_alloc`
stub. It sat AHEAD of every `new (std::nothrow)` fallback the TX path has, so the
drop-on-OOM backpressure the header advertises was void on the one path every
subscription push takes, and the failure was silent: no counter moves, no OOM log, just
an unexplained reboot. The destinations are now snapshotted into a fixed on-stack chunk
(`kFanoutChunk`, sized at the link's own `kDefaultPeerCap` socket budget) with the scan
resuming where it stopped, so a broadcast to any number of peers takes no heap arm at
all — there is nothing left to fail, hence no new drop counter and no sizing policy for
the unbounded-`max_peers` case. A broadcast at or under the chunk still takes exactly
one `peers_m_` hold; past it, one more uncontended acquisition per chunk. No API change;
the header's steady-state allocation contract now covers fan-out explicitly.
- **`httpd_ws_link_t` fires the peer-departure eviction notifier with the handler gate
RELEASED, not held across it** (#960). `reclaim_slot` scoped `peers_m_` to the field
clears and fired the routing plane's eviction hook outside it — but its only caller held
the handler gate's mutex for the whole call, so `fwd_router_t::link_down` (a walk of
every subscribed vertex under the graph's own locks, bounded by nothing this link owns)
ran under the one mutex every dispatch into the link takes and a destructor blocks on.
`bus_link_t::notify_peer_down` documents the opposite precondition outright ("with none
of its internal locks held"), and both core reference servers
(`transport_ws_server::teardown_slot`, `transport_tcp_server::teardown_slot`) honour it;
this link was the exception, and it established an undocumented `gate → router → graph`
ordering edge that nothing recorded. `reclaim_slot` now returns the departed peer's name
and `on_session_closed` fires the notification after the lock scope ends; the order it
no longer has is written down on `gate_t`. What does **not** change is the lifetime
guarantee holding the mutex supplied: the notification registers on the gate's existing
`depth`/`cv` barrier — the same one a URI-handler frame uses — so `close_gate` still
cannot return while one is in flight, and the notifier still cannot outlive the link.
Scope, stated because both are easy to over-read into this: (1) the mutex-order edge is
gone, so a thread holding a graph lock can always take the gate — but destroying a link
while holding a lock its in-flight work needs still deadlocks on the barrier, exactly as
it already did through the URI-handler join and as it does in `transport_ws_server`,
whose destructor joins its poll thread for the same reason; (2) the eviction still runs
synchronously on the httpd task from inside `free_ctx`, so a departing peer still costs
that task the walk — moving it off is a separate design question (#1071). No API change.
- **`twai_link_t`'s TX backpressure window is spent PER FRAME again, and teardown no
longer queues behind it** (#962). `write_raw` took `write_m_` and only then parked on
the free-slot semaphore that *is* the FULL policy's backpressure point, so the window
was per queue rather than per frame: on a controller whose tx-done never fires (a
bus-off or stalled node), K concurrent writers spent `K * tx_timeout_ms` in series
rather than one window each, and the destructor — which takes the same lock — inherited
the whole series, so a link removal during a bus fault blocked the destroying task for
the same multiple. `tx_dropped()` kept moving throughout, so the symptom read as "we're
dropping, as designed" right up to the watchdog reboot. The semaphore is now taken
OUTSIDE the lock, which leaves the lock covering only the submission (the node handle
and the pool's serialized acquire); a writer parked across teardown re-checks the node
under the lock and hands its token back rather than submitting to a deleted controller,
and the destructor releases parked writers and waits for them to leave before deleting
the semaphore they are waiting on. No API change.
- **`twai_link_config_t::tx_timeout_ms` is clamped to the task-watchdog period**
(`CONFIG_ESP_TASK_WDT_TIMEOUT_S`, #962). It was taken verbatim, so a config could park
a writing task longer than a task may go unfed and reboot the board instead of dropping
the frame — the same class of footgun `derive_send_timeout_ms` exists for on the WS
server link (#835). The bound is one FRAME's wait; a caller writing a burst on one task
still sums its own frames' waits. Configs at or under the watchdog period — including
the 20 ms default — are unaffected.
- **`httpd_ws_link_t`'s TX slot pool no longer dies four dropped datagrams into a boot**
(#944). `httpd_queue_work` on the default non-blocking path is a bare `sendto` to a
loopback UDP control socket, so an enqueue past the receiver's mbox is discarded inside
lwIP while `sendto` — and therefore `httpd_queue_work` — still returns `ESP_OK`. The
close path was routed around that fact with `shutdown`; the TX path still trusted the
return value. A binned work item never ran, and the work item was the *only* thing that
released the pool slot it had claimed: four of them and the pool was gone for the rest
of the boot, after which every outbound frame took two global-heap allocations on a hot
publish path. There was no counter for it — `enqueue_drops()` covers the *refused*
enqueue, which already recycled correctly.
A TX slot now carries a four-state lifetime (free / claimed / armed / running) instead
of a single `busy` flag, and an exhausted claim sweeps the pool before giving up:
a slot armed for longer than `kTxPoolSlots * send_timeout_ms()` — every other slot ahead
of it, each stalled to this link's full per-socket send bound — is presumed lost and
recycled. **Safety does not rest on that window.** A pooled work item lives inside its
slot and so cannot carry an identity a re-claim would not overwrite; it is therefore a
bare token meaning "send whatever slot *i* has armed", and a token that arrives after a
reclaim either finds nothing armed or sends the later frame that is — correctly, since a
payload and the destination it was gathered for are armed together. A window chosen too
short costs a dropped frame (counted, and droppable by contract), never a torn one.
Reclaiming happens only on the exhausted-claim path: no timer, no task, and nothing at
all in the steady state. The **heap-fallback** work item (taken when the pool is
exhausted) is *not* reclaimable by this or any timeout — the token holds a raw pointer
to the shell, so freeing it on a guess is a use-after-free rather than a leak; what the
fix removes is the driver that made those leaks unbounded, since a pool that cannot die
is no longer permanently bypassed.
- **`httpd_ws_link_t`'s queued TX no longer delivers one peer's frames to another after a
descriptor is reused** (#954, partial — the directed-resolve residue is #1013). The
whole TX path identified its destination by bare fd: `send()`
snapshotted fds under the peer lock and released it before enqueueing, the queued work
item stored only `int fd`, and the drain asked `httpd_ws_get_fd_info(handle, fd)` —
which reports "some websocket lives at this number", never "the session this frame was
gathered for", because IDF's session lookup is purely fd-keyed with no generation. The
residency window is wide: `httpd_server` handles ONE control message per `select()`
pass while close, accept and handshake proceed in that same pass, so a peer can hang up
and an unrelated client be accepted onto the recycled descriptor while the first peer's
frames still sit in the queue. Those frames were then written to the new peer — a
**cross-session data leak** on a peer-named server, where a directed FWD reply or a
subscription push produced for one authenticated session was delivered to a different
one — and their failures were charged to the newcomer's strike counter, closing a
session that had failed nothing. The TX path now carries a `session_ref_t` (the peer
slot plus a generation stamped at every claim) and re-validates it at each site through
`live_fd`; a stale reference FAILS and the frame is dropped rather than misdelivered.
**Scope: the enqueue → drain gap, not every gap.** The generation protects a reference
from the moment it is MINTED. It does not protect the window BEFORE that: on the directed
path a caller resolves a peer's endpoint via `peer_link` and sends later in the same
forward hop, and `peer_endpoint_t::send` mints the reference from the slot's CURRENT
generation — so whichever session occupies the slot at send time satisfies the downstream
check. Under preemption that window is unbounded, and the misdelivery is still
constructible. It is narrower than the window this closes and it predates this change,
but it is the same consequence, so it is tracked as **#1013** rather than described as
fixed. Closing it needs a per-resolution identity, which is an API-shape decision the
shared per-slot endpoint object cannot absorb silently.
Both halves of the shipped reference are needed: slots are recycled IN PLACE, so the departed peer's slot — and
therefore the server's session ctx POINTER — is exactly what the next peer is handed,
which is why the pointer comparison the teardown detach path uses does not close this
hole on the live path. No public API change.
- **`esp_ws_client_link_t` now HONORS its `recv_stack` argument** (#900). The constructor
accepted the knob and discarded it, spawning a plain `std::thread` — which on ESP-IDF
takes the global `CONFIG_PTHREAD_TASK_STACK_SIZE_DEFAULT`, since a pthread's stack can
only be set by arming `esp_pthread_set_cfg` for the next `pthread_create`. That thread
runs in-call graph delivery through the `on_write` seam (the deep path the sibling
*server* link sizes at 12288 bytes), so a caller who sized the stack for its delivery
path silently got the default and a stack-overflow reboot. The save/set/spawn/restore
recipe `twai_link_t` already carried is now one shared helper (`tr::esp::spawn_thread`,
component-private) used by both links; the restore is what keeps the size from leaking
onto later threads spawned by the same caller. `recv_stack = 0` still means "platform
default" and arms nothing, so callers passing nothing are unchanged.
- **An over-sized inbound message is now dropped WHOLE** (#901). The receive loop reset
its accumulator on overflow but kept reading the same message, so the remainder
re-accumulated from offset zero and was handed to the router as a bogus standalone
frame — the peer's stream desynchronised and nothing reported it. This never required
fragmentation: `esp_transport_read` caps each read at the space offered and IDF
re-reports the frame's `fin` on every one of them, so one over-sized *unfragmented*
frame split across reads and its tail was delivered. The loop now tracks the current
frame's unread bytes (from `esp_transport_ws_get_read_payload_len`), so "the message
ended" is distinguishable from "the buffer ran out", and consumes the remainder of a
dropped message without delivering it. Two adjacent gaps close with it: a stray
CONTINUATION arriving with no message open is dropped (the rule `httpd_ws_link_t`
already applies), and an **exact-fit** message — `off == rx_bytes` with `fin` — is
delivered instead of being swept into the overflow branch.
### Changed — BREAKING
- **`httpd_ws_link_t` names accepted sessions `p<slot>`, not `<ip>:<port>`** (#994,
ADR-0073 §2, #426). The old name could be ENUMERATED and never ADDRESSED:
`graph::valid_segment` rejects both `.` and `:`, so a peer listed by the link's
synthesized `:children[]` could not be spelled back in a `dst` path, and every FWD
descent into one answered `INVALID_PATH (0x0021)`. That broke the enumerable⇒addressable
invariant RFC-0020 §6 names as the precondition for its NAME-hop rejection — on every
ESP-IDF node, while core's own bus servers had shipped `p<slot>` since #426.
**Anything holding an `<ip>:<port>` peer name must re-resolve it**: `peer_link()`,
`enumerate_peers()`, the departure seam and `peer_stats_t::name` all carry the slot name
now. The physical address is not lost — it moved to the new
`peer_stats_t::endpoint_str`, and the strike log prints both.
- **The component now requires ESP-IDF `>=5.5.5`** (`idf_component.yml`, was `>=5.3`) — a
TX-path correctness floor, not a packaging preference (#949). Below it `httpd_queue_work`
is a bare non-blocking `sendto` on `esp_http_server`'s loopback control socket, so an
enqueue past `CONFIG_LWIP_UDP_RECVMBOX_SIZE` is discarded inside lwIP while the call
still returns `ESP_OK`: the frame is lost with nothing observable anywhere, and the work
item that would have released its TX pool slot never runs. From 5.5.5 the mbox slot is
reserved through a counting semaphore before the `sendto` (`httpd_main.c`), so a full
control queue is an `ESP_FAIL` the caller sees. **Consumers on 5.3.x, 5.4.x and
5.5.0–5.5.4 are dropped**; a project on those versions must either upgrade or pin the
previous component release.
- **`httpd_ws_link_t::tx_strands()` is REMOVED**, together with the machinery it reported
on: the four-state TX slot lifetime, its age stamp, and the exhausted-claim sweep (#949).
Above the new floor a slot cannot be pinned by an enqueue that silently never runs, so
there is nothing to reclaim and nothing to count. `tx_slots_busy()` and
`tx_slot_capacity()` stay. A consumer polling `tx_strands()` should drop the call;
`enqueue_drops()` is now the whole TX-loss surface.
- **An exhausted TX slot pool drops the frame and counts it, instead of falling back to a
heap work item** (#949, and #953's part B). The pool is the link's outstanding-send bound
— `tx_slot_capacity()` sends in flight at once — and exceeding it increments
`enqueue_drops()` and logs, on the same path a refused enqueue takes. The old arm posted
an unbounded number of heap-allocated work items into a control mbox drained one message
per server pass, which is a bounded and observable condition traded for an unbounded and
invisible one; the exhaustion policy the record already rules is drop-and-count
(ADR-0039 §4, ADR-0042 §2). **Consumer-visible consequence**: a broadcast to more peers
than `tx_slot_capacity()` now delivers a pool's worth per pass and counts the rest as
drops, where it previously heap-queued them. On a device the httpd task drains
concurrently, so how much of a wide fan-out lands is a scheduling question — but it is no
longer bounded by the heap, and every loss is on a counter. A host that fans out wider
than the pool should read `enqueue_drops()`.
## [0.8.0] — 2026-08-06
### Changed
- No integration-layer changes — the component ships `core` 0.8.0, whose new
`for_each_vertex` and subscription-observer APIs are thereby available to
component consumers.
## [0.7.1] — 2026-08-04
### Added
- **`esp_ws_client_link_t::set_handshake_headers(std::string)`: append extra HTTP header
lines to the opening-handshake request** (each `Name: value\r\n`-terminated,
`esp_transport_ws` emits them verbatim; empty leaves the handshake byte-for-byte the
historical one). The client counterpart to `httpd_ws_link_t::set_admission_cb`: a
board-to-board dial carries no browser session cookie, so a token header here is how a
dialing node authenticates itself to a peer whose graph WS gates admission. Applied on
the next dial — set it once at wiring time before the link first connects.
- **`httpd_ws_link_t::set_admission_cb(admission_fn_t, void*)`: an optional predicate
consulted at the top of every opening handshake, before the peer-cap check and any slot
allocation.** Returning `false` refuses the peer cleanly (httpd closes the socket — the
same path as the `max_peers` cap); a null hook (the default) admits every peer, so the
historical open-graph behavior is unchanged. The seam a host uses to authenticate the
graph WS the way it gates the rest of its HTTP surface — inspect the handshake request's
headers (a session cookie, a shared token) and refuse an unauthenticated peer before it
can read or write a single vertex. Read on the httpd task with no lock; set it once at
wiring time before the link serves.
### Fixed
- **`httpd_ws_link_t` and `esp_ws_client_link_t`: `TCP_NODELAY` is now set on every
WebSocket socket, removing the Nagle + delayed-ACK latency floor.** A libtracer WS frame
is a small, self-contained TLV whose reply the peer is already awaiting — the request-reply
shape Nagle stalls, adding tens of ms per round-trip with nothing to coalesce that the WS
framing does not already batch. The server sets it per accepted socket at admission
(`bound_socket`, alongside the #835 `SO_SNDTIMEO`), so REST sockets — which this link never
upgrades — are untouched; the client sets it on the freshly connected `esp_transport` fd in
`connect_once`. Both are best-effort: a peer whose socket refuses the option keeps the
pre-patch latency for that one link rather than failing. Measured on an ESP32-C6 bench: WS
request-reply RTT floor drops from ~52 ms toward the airtime bound (HIL-verified per the
merge gate).
- **`httpd_ws_link_t`: WebSocket sends are now bounded by a derived per-socket send timeout,
and the brokenness detector now aims at the peer that is actually broken (#835).** Two
defects on one seam. (1) `esp_http_server` sets `SO_SNDTIMEO` on every accepted socket from
`config.send_wait_timeout` (5 s by default) and every WS send runs on the single httpd
task — the task that owns accept/recv for every socket the server has — so one peer with a
full TCP window blocked that task for up to the whole timeout per frame, serialized under
delivery fan-out, until the task watchdog fired and the HTTP plane stopped answering
(observed on an ESP32-C6 under a delivery burst with a throttled browser tab subscribed).
Every UPGRADED socket now gets a short `SO_SNDTIMEO` of its own at admission, DERIVED as
the task-watchdog period divided by the peer cap — so one full fan-out round with every
peer stalled still fits inside one watchdog window. The bound is a DERIVATION, not a
measurement: the host tests prove the timeout is installed, the short-write handling and
the strike attribution, while the starvation repro itself is silicon-only — that the task
watchdog no longer trips is HIL-verified per the #835 merge gate. REST sockets are
untouched: the server's `send_wait_timeout` still governs HTTP responses, so there is no
config ripple.
(2) The three-strikes teardown counted ENQUEUE drops and charged each to the fd of the
frame that failed to enqueue. The control queue is shared, so when the stalled peer's slow
sends jammed it the drops — and the session closes — landed on whichever HEALTHY peers
sent next, while the stalled peer never accrued a strike. Failed SENDS now feed the
per-session streak (they name their destination by construction) and refused enqueues feed
a link-level counter and strike nobody. #481's protected behaviour is unchanged: one failed
send still only drops that frame, and any successful send resets the streak, so the shipped
"large reply times out between small frames" shape still never closes the session.
(3) A close the link ASKS FOR is not a close that happens. `httpd_sess_trigger_close` is
`httpd_queue_work(httpd_sess_close, …)` — the same loopback control socket, drained by the
same single httpd task that is already serialized behind the stalled fd's queued sends — so
it is strictly FIFO behind the very backlog it exists to clear, at a full send bound per
entry; and on the default non-blocking path `httpd_queue_work` is a bare `sendto`, so an
enqueue past that socket's small UDP mbox is dropped inside lwIP while still returning
success. An `ESP_OK` from it is therefore no evidence a close was queued at all. The first
on-silicon run of this fix measured the consequence exactly: the desync was detected and
logged, and then the same fd kept failing every 2 s for a further two minutes with the peer
never dropped — the starvation's period had shrunk, not the starvation. So the decision and
the close are now separated. At the strike cap or on one short write the session is marked
DEAD in the link's own state, under the existing peer-mutex discipline, at the instant of
the verdict: `queue_send` then refuses new frames to that fd, `tx_work` skips frames already
queued to it (the backlog drains at queue speed instead of a send bound apiece, which is
what frees the httpd task), and the socket is `shutdown`, which needs nothing from the
control queue — it takes effect on the calling line, makes every later write fail at once,
and raises the readable-at-EOF event that gets the session reaped through httpd's own select
arm. It never frees the descriptor, so `esp_http_server` keeps sole ownership of the fd's
lifetime; the dead mark is cleared where the slot is recycled, on the httpd task, so it
cannot outlive its session onto a reused descriptor number. `trigger_close` is still called
as a best-effort second path, but nothing depends on its result. HIL-pending: that the
stalled peer is now actually dropped and new WS connections stop timing out is a property
of a real lwIP socket under a real fan-out and is re-verified on the bench, not claimed here.
- **`httpd_ws_link_t`: peers on IPv6 sockets were all named `0.0.0.0` (#835).** With
`CONFIG_LWIP_IPV6` on — the default on this target — `esp_http_server` binds its listener
`PF_INET6`, so every accepted WebSocket socket is `AF_INET6` and `getpeername` fills a
`sockaddr_in6`. The peer namer decoded it as a `sockaddr_in`: the port read correctly (same
offset) but the address read `sin6_flowinfo`, which is always zero. Every peer name on the
bus tag, the census and — the case that mattered — the strike log was therefore
`0.0.0.0:<port>`, so the one line that had to identify a stalled peer identified nothing.
The family is now read from what `getpeername` actually returned, with v4-mapped addresses
unwrapped so a dual-stack node keeps naming its IPv4 peers exactly as the census always has.
### Added
- **`httpd_ws_link_t` short-write guard.** Both constructors gained a trailing
`send_timeout_ms` parameter (default `0` = derive, clamped to the server's
`send_wait_timeout`) for hosts whose watchdog regime differs from the derivation's inputs,
plus the accessors `send_timeout_ms()` and `enqueue_drops()`. Every admitted session also
installs a send override (`httpd_sess_set_send_override`): lwIP returns the PARTIAL count
when a bounded write expires mid-buffer, and `esp_http_server` treats any non-negative
return as a delivered frame — which would report a half-written WebSocket frame as success
and desynchronise the peer's framing for the life of the socket. A short write is now an
error AND condemns that session immediately, bypassing the streak (a different fault class:
the stream is broken, not a frame missing) — see (3) above for what "immediately" had to be
made to mean, since the queued close it originally used could be delayed or silently lost.
Existing call sites are source-compatible.
- **`httpd_ws_link_t` (adopted mode): the destructor now retires every session's close
callback before the link dies (#816).** Each admitted peer is registered with
`httpd_sess_set_ctx(handle, fd, slot, on_session_closed)`, so a link that adopted an
external server used to leave that `free_ctx` pointing into freed memory: the next
peer disconnect — or the adopting server's own `httpd_stop`, arbitrarily later — called
it. The destructor now queues a self-contained detach onto the server's control queue
(`httpd_queue_work`), which is the only context allowed to touch the session table, and
clears each session's ctx/free_ctx there (`httpd_sess_set_ctx(.., nullptr, nullptr)` —
which runs the outgoing callback inline, while the link is still alive) before closing
the sessions; it returns only once that has happened. A destructor running ON the
adopting server's own task detaches inline instead, because queued work there could
only run after the destructor returns (the same self-deadlock #815 fixed for the TX
pool). If the detach cannot run at all — the queue refuses it, or the server task is
wedged past the teardown drain bound — the still-armed session slots are neutralised
and LEAKED with a warning rather than freed under a live callback, so the late
callback lands on valid, inert memory. No public API change; owning mode is unaffected
(`httpd_stop` already closes every session synchronously). Teardown-only: on-device
nodes leak the link by design, so the exposure was host/testbench teardown and any
future dynamic-reconfiguration path.
- **`httpd_ws_link_t` (adopted mode): frames can no longer be dispatched into a
destroyed link, and the destructor joins the handler before freeing anything (#816).**
Unregistering the WS URI does not stop inbound frames: `esp_http_server` copies the
route (`handler` + `user_ctx`) into each session as it answers the handshake
(`httpd_uri.c`) and dispatches from there (`httpd_parse.c`), clearing it only when that
session is deleted — which a link cannot force and, for a peer that upgraded without
ever sending a frame, cannot even observe. The registered `user_ctx` is therefore now a
small handler GATE rather than the link: after teardown the gate holds no link, so a
late frame is refused (httpd closes that socket) and a late session callback is inert,
and while a handler frame IS inside the link the destructor blocks until it leaves.
The gate is deliberately leaked in adopted mode, since the set of sessions still
routed at it is unknowable. Also fixed on the same seam: the session being serviced by
an in-flight handler is now neutralised rather than detached (`httpd_sess_set_ctx`
edits the request, not the socket table, for that one fd, and `httpd_req_cleanup` runs
its callback after the destructor has returned); a completed reassembly is moved out of
its slot before delivery, so an in-call teardown cannot be followed by a write into the
freed slot; and the queued detach now identifies its sessions by stored context, not by
socket descriptor alone, so an item draining late cannot run a co-tenant's `free_ctx`
or force-close its session after the descriptor was recycled on the shared server.
## [0.7.0] — 2026-08-02
### Added
- **`tr::esp::critical_pool_t` — a thread-safe, slab-bounded `mem_backend_t` for a
single-core ESP32 (#770).** `include/libtracer_esp/critical_pool.hpp`. `portmux_sync_t`
implements core's `tr::mem::pool_sync_policy` with `portENTER_CRITICAL_SAFE` /
`portEXIT_CRITICAL_SAFE`, and `critical_pool_t` is `tr::mem::synchronized_pool_t` specialised
on it — the interrupt-disable variant
[ADR-0060](../../../docs/adr/0060-lkv-copy-store-injected-value-backend.md) §2 names for a
priority-preemptive target, where core's spinlock `sync_pool_t` would let a high-priority task
spin on a lock its lower-priority holder cannot release. `is_isr_safe` is `true` (the section
disables interrupts, and the `_SAFE` macros pick the ISR-context primitive). Inject it wherever
a shared byte seam must live in the application's own slab — `transport_vertex_t`'s
`rx_backend`, `graph_t`'s `value_backend`, `fwd_router_t`'s `flat`; a bare `tr::mem::pool_t` is
unsynchronised and wrong at all three. **Opt-in construction — nothing defaults to it**, and an
app that never names it links nothing (the compile-gate TU `critical_pool.cpp` instantiates the
specialisation on chip targets so the adapter is type-checked by every build; `--gc-sections`
drops it). The bundled `full_node` example wires it behind its per-target platform seam.
- **`esp_ws_client_link_t` — an ESP-IDF `esp_transport_ws`-backed WebSocket *client*
`transport_t` (dial-out).** The embedded-native counterpart to core's portable
`transport_ws_client` and the dial mirror of `httpd_ws_link_t`: the RFC 6455 opening
handshake, masking/framing, and PING/PONG/CLOSE control frames run inside ESP-IDF's
tested `esp_transport_ws` (over core `tcp_transport`), instead of the raw-socket client's
hand-rolled handshake that does not reliably complete on lwIP silicon. One inbound BINARY
message is one libtracer TLV, read directly into a reusable buffer (server→client frames
are unmasked, so RX is a zero-copy fill) and delivered borrowed in-call to the router;
`send()` emits one masked BINARY frame, copied once into a reusable scratch (client
masking is in-place, so a shared frame must not be mutated). Point-to-point: `bus()` is
nullptr, `delivers_ropes()` is false. A single recv thread owns the read side and re-dials
with backoff (a transient drop keeps subscriber edges, resuming on reconnect); all handle
access is serialized by one syscall-brief mutex. Chip-only, gated on
`CONFIG_LIBTRACER_TRANSPORT_WS` (default `y`); adds `tcp_transport` (core IDF) to the
unconditional `PRIV_REQUIRES`. The portable `transport_ws_client` still compiles; a node
that binds this link leaves it unreferenced for `--gc-sections` to drop, the same
coexistence as `httpd_ws_link_t` vs the raw server.
- **`CONFIG_LIBTRACER_TRANSPORT_{UDP,TCP,WS,CAN}` — per-transport Kconfig knobs (#393).**
The component now gates each built-in transport's translation units on its own
menuconfig bool, mirroring the core CMake options `LIBTRACER_TRANSPORT_{UDP,TCP,WS,CAN}`.
All four **default `y`**, so a stock node compiles the full transport set and
`libtracer.a` is byte-identical to before. Turning one **off** sheds that transport's
factory glue + socket TU(s) from the archive — e.g. UDP drops `builtin_transport_udp` +
`transport_udp`; WS additionally drops the chip-native `httpd_ws_link_t`; CAN drops
`transport_can` + the TWAI `twai_link_t` — and a `SPEC` of that trimmed `kind` then
resolves to `SCHEMA_NOT_FOUND` (the factory is simply absent). This is the RAM-audit
"compose the C6 for its role" lever (shed unused transports), with **no in-source
feature macros**: selection is which TUs the build compiles, and the
`register_builtin_transports` dispatcher is CMake-generated to call only the enabled
factories (the hand-written full-node form is used when udp+tcp+ws are all on, so a
stock build is unchanged). The transport `PRIV_REQUIRES` (`esp_driver_twai`,
`esp_driver_gpio`, `esp_http_server`, `lwip`) stay **unconditional** — a Kconfig-gated
REQUIRES never propagates in IDF's early requirement-expansion pass — and are free when
their TU is absent (`--gc-sections` drops the link dep).
### Changed
- **`CONFIG_LIBTRACER_VERTEX_LOCK_STRIPES` now rides the generated `libtracer/config.hpp`**
([ADR-0068](../../../docs/adr/0068-build-configuration-is-plain-cpp-config-header.md))
instead of a PUBLIC compile definition: the component `configure_file`s the header from the
Kconfig value and lists it before `core/include`, so every dependent TU reads the same
constexpr `tr::graph::kVertexLockStripes` — the ODR hazard the PUBLIC `-D` existed to manage
is gone by construction. menuconfig behavior is unchanged.
- **`httpd_ws_link_t` steady-state RX/TX no longer allocates per frame (#814).** Two
once-per-link buffers replace the per-frame heap on the hot paths, bringing the server
link toward `esp_ws_client_link_t`'s allocation discipline. **RX:** a frame that fits the
2 KB reusable scratch (all graph control TLVs) is read into it and delivered borrowed —
the exact-size `new (std::nothrow)` remains only as the fallback for larger frames (up to
the 32 KB abuse cap), trading one allocation for not pinning 32 KB permanently. **TX:** a
send claims one of 4 pre-allocated work slots **lock-free** (a CAS scan; senders on any
task, released by the httpd task as the send drains) and gathers the frame straight into
the slot's ~1.5 KB inline payload — no allocation. A frame past the inline capacity keeps
the pooled shell and takes a nothrow heap payload; a momentarily exhausted pool falls back
to the previous fully-heap work item. Every fallback stays nothrow with the same
drop-on-OOM backpressure (`note_tx_result` streak accounting unchanged) — never an abort.
No API change; ~8.5 KB of heap moves from per-frame churn to one construction-time
allocation per link.
## [0.6.0] — 2026-07-23
## [0.5.0] — 2026-07-21
### Added
- **`CONFIG_LIBTRACER_VERTEX_LOCK_STRIPES` — Kconfig knob for the vertex
lock-stripe count.** libtracer's one process-global mutable buffer is the
vertex lock-stripe table (`core/include/libtracer/vertex.hpp`, #361 §2): it
costs N lazily-allocated FreeRTOS mutexes (~90 B of heap each) plus N
condition variables once a `graph_t` exists, independent of how many vertices
are registered. The core header already honored a
`-DLIBTRACER_VERTEX_LOCK_STRIPES` override; this exposes it through
menuconfig (default **16**, unchanged) and propagates it as a **PUBLIC**
compile definition so every translation unit that includes
`<libtracer/vertex.hpp>` agrees on the `inline constinit` table size (a
mismatch would be an ODR violation). A single-core chip node can drop it
(e.g. 4–8) to reclaim RAM; the lock-free LKV read/write hot path is
unaffected — only control-plane lock contention (ring trim, edge/ACL
mutation, `await` wake) rises. Doctrine-pure per RFC-0006 (bounds are
per-target config, never a synthetic magic number).
### Changed
- **`httpd_ws_link_t` heap exhaustion no longer aborts the node (OOM soft-fail).**
With `-fno-exceptions`, the throwing allocator turned heap exhaustion on the
in-call WS service path into `abort()` (decoded on-device: 3/3 browser-session
crashes on the httpd task while serving a ~12.7 KB composed-read reply). All
buffers on that path are now nothrow with graceful degradation: (1) TX — the
reply is gathered **once** from the caller's iovec into the queued work item
via `new (std::nothrow)` (the link and its `peer_endpoint_t` now override
`transport_t`'s iovec `send()`, eliminating the base default's throwing gather
temporary and the frame's double-buffering); a gather-OOM lands in the existing
`note_tx_result` drop/close ladder as a counted drop. (2) RX — the pass-2
payload buffer is nothrow (OOM closes that session), and fragment reassembly
moved to a nothrow exact-size regrow buffer (OOM drops the in-flight message,
keeps the peer). No wire-visible change: peers observe the pre-existing
drop/close backpressure behavior instead of a device reboot.
- **`httpd_ws_link_t` no longer goes silently deaf on a failed WebSocket send.**
Previously the TX work callback ignored the `httpd_ws_send_frame_async` result,
so a send failure (e.g. a large fragmented frame timing out the socket's 5 s
`SO_SNDTIMEO`) left the session open while the peer silently missed frames.
Now a failed send logs a warning and **triggers the session's close**
(`httpd_sess_trigger_close`, flowing through the normal `free_ctx` teardown),
so the client's `onclose` fires and it can reconnect into clean state.
Likewise, TX enqueue drops (`httpd_queue_work` refused / work-item OOM) — which
were silently discarded — are now logged and counted per session; a session
accumulating `kMaxConsecutiveTxDrops` (3) consecutive drops with no successful
enqueue in between is treated as broken and closed the same way. Clients that
held a connection during TX failure will now observe a disconnect instead of a
half-dead session.