# 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.13.0] — 2026-08-16
### Changed
- **BREAKING — `twai_link_t` follows the `can_link_t` seam's new two-phase lifecycle: the
dispatch thread is spawned by `start()`, not by the constructor**
([#1186](https://github.com/avatarsd-llc/libtracer/issues/1186)). Construction still brings
the on-chip controller up exactly as before (pins, bitrate, queues, callbacks,
`twai_node_enable`), so `ok()` means what it always meant and TX is live on return — but
nothing pops `rx_queue_`, and hence nothing invokes the registered `rx_fn_t`, until
`start()`. Nothing is lost by the split: the rx-done ISR keeps filling that queue meanwhile,
the ESP-side counterpart of the kernel socket buffer `socketcan_link_t` relies on. `start()`
is idempotent and a no-op on a controller that never came up. **Migration**: a caller that
owns the link directly must call `link->start()` after `link->on_receive(...)`; a link handed
to `transport_can` needs no change — the transport drives both phases. The
`twai_link_config_t::stack_size` knob is unchanged; it is now spent at `start()` rather than
at construction. See [core/CHANGELOG.md](../../../core/CHANGELOG.md) for the seam itself.
### Fixed
- **`httpd_ws_link_t` no longer closes a handshake-authenticated peer that has said nothing —
such a session now claims its slot AT THE 101**
([#1334](https://github.com/avatarsd-llc/libtracer/issues/1334)). A session the three-valued
admission predicate answered `ADMIT_AUTHENTICATED` for used to be recorded as a row in the
pending-handshake ledger #1247 introduced, and that ledger's only two retirement paths are
the lazy first-frame claim and the deadline sweep. A peer entitled to be silent — a link
established ahead of demand, a subscriber that only ever receives, an idle control channel —
takes neither, so the sweep closed it with `kCloseAuthTimeout` at `auth_deadline_ms`; it
reconnected, took a fresh row with a fresh deadline, and the loop ran forever. A native
dialer cannot escape it by speaking: presenting a credential IN-BAND is exactly the thing it
has no way to do, which is why #1245 gave it a handshake verdict in the first place.
**What changed.** The claim body is factored into one `claim_session` reached from two edges.
An `ADMIT_AUTHENTICATED` verdict claims its slot in `ws_pre_handshake`, before the 101, and
never enters the ledger at all: `auth_pending` is false and **no deadline is armed** — not
suppressed, unarmed — so the sweep has nothing to find. Every other session is claimed
lazily on its first frame, ledger-bound and swept, exactly as #1247 left it. The
slot-reclamation `free_ctx`, the per-socket policy, the stack sample and the #1223 arrival
seam all move with the claim, so a session claimed at the 101 is indistinguishable from one
claimed at a frame from there on; the two edges are mutually exclusive per connection.
**The alternative was ruled out**, not overlooked: exempting pre-authenticated rows from the
sweep leaves rows nothing ever retires (a socket closing does not retire one), so 24 idle
pre-authenticated links would fill the fixed ledger and every subsequent upgrade — including
plain `ADMIT` ones — would be refused. That is the same deployment, with a link-wide
admission stall instead of a per-socket reconnect loop.
**Observable surface.** (1) A session now EXISTS before its first frame for this verdict:
it is enumerable, resolvable and countable from the 101, and its `connected_at_us` is stamped
there — more accurate, not less. (2) `max_peers` is charged at the 101 for this verdict, so a
link at its cap refuses the upgrade outright (`peers_refused` +1, counted exactly once)
rather than admitting the peer and killing it `auth_deadline_ms` later; the claim reaps
expired sessions first, so an already-expired squatter cannot spend the slot. (3) The ledger
now holds only sessions whose entitlement is *not yet* known, which is what
`kMaxPendingHandshakes` always described. `pending_handshake_t::preauthenticated` is gone
along with the `bool` parameter of the private `note_pending_handshake`; the public
`kMaxPreauthenticated` alias is unchanged.
- **`httpd_ws_link_t`'s authentication deadline now bounds a socket that upgrades and then
says NOTHING** ([#1247](https://github.com/avatarsd-llc/libtracer/issues/1247)).
`docs/reference/16-websocket-session-auth.md` names "open sockets, say nothing" as the exact
attack the deadline exists to stop, and that was the one case it did not cover: measured at
8 s still open, no close code, against a documented 2000 ms deadline. The cause is a domain
mismatch, not an off-by-one — `esp_http_server` answers the handshake internally and this
link claims a `session_t` LAZILY on the peer's first data frame, so a peer that completes the
101 and sends nothing has no session, never sets `auth_pending`, and the sweep of `slots_` has
literally nothing to walk. The sweep was auditing the resource allocated lazily instead of the
one that is actually scarce: on an embedded node a server sized for ten sockets is spent by
ten silent upgrades, none of which ever costs the attacker a credential or a frame.
**What changed.** The pre-handshake now opens a row in a fixed-size per-socket ledger
carrying that socket's deadline (and, as before, the `ADMIT_AUTHENTICATED` verdict of #1245);
the deadline sweep walks it alongside `slots_` and closes an un-spoken socket on the raw
descriptor with the same `kCloseAuthTimeout`, counted in `stats_t::auth_expired`. The row is
consumed the moment the socket speaks, so exactly one of the two mechanisms bounds a
connection at any instant. The close is guarded by the server's own `httpd_ws_get_fd_info`,
the same second opinion `tx_work` takes, so a row left by a socket that already hung up
cannot shut down whoever was accepted onto the recycled descriptor. **A link with no auth
hook records nothing and is unaffected**: a silent upgraded socket remains the HTTP server's
business, exactly as before.
**A full ledger REFUSES the handshake** — `ws_pre_handshake` answers `ESP_FAIL`, no 101 is
written, and `stats_t::peers_refused` counts it. This is a behaviour change from #1246, where
a full record set evicted a row and the affected session merely degraded to `ADMIT`: the row
now carries that socket's deadline, so a missing row is a socket nothing can ever close, and
eviction would fail OPEN on the scarcer resource. The alternative considered and rejected was
sizing the ledger so overflow is "unreachable by construction" and asserting the invariant —
an invariant checked by reasoning rather than by the build, and on an MCU an `assert` is an
`abort()` in the field, on a node whose entire premise is that exhaustion is answered **by
value** (ADR-0065's nothrow `try_alloc`; the egress store that drops and counts a frame rather
than truncating it or dying). Refusal is also the only arm a test can exercise AND ablate; the
host suite drives the ledger to its bound, asserts the refusal, and reddens when the refusal
is removed.
**Public surface.** `kMaxPreauthenticated` is renamed `kMaxPendingHandshakes` — the ledger
now holds a row per pending handshake rather than only per pre-authenticated one — with the
old name kept as an alias so an embedder that read the 0.12.0 constant still compiles. The
bound and its consequence are documented on the type. Nothing else moves: no new constructor
argument, no new Kconfig knob (the deadline is already one), and the three rows of #1247's
measured table that were already correct are unchanged.
- **`httpd_ws_link_t::peer_link()` now returns a PER-RESOLUTION handle, closing the
`[resolve -> mint]` misdelivery window**
([#1013](https://github.com/avatarsd-llc/libtracer/issues/1013)). The half of #954 the
0.10.0 entry below explicitly left open. Until now `peer_link` handed back a facade owned
by the peer SLOT — one object for the link's life — so it had no identity of its own and
the directed `send()` re-read the slot's generation when it finally ran. A caller
resolves a peer at one end of a forward hop and sends at the other, with label and gather
work in between; on a single-core MCU the httpd task can be scheduled in that gap, close
the session and accept another onto the same descriptor AND the same recycled slot — a
browser reload — and a generation minted after that swap describes the STRANGER. Every
downstream check then passed by construction, and one authenticated session's directed
FWD reply was written into another's socket. Machine-verified, and now pinned by the
host suite (`httpd_ws_session_identity_test`, cases 6-9).
The generation is **captured at the resolve**, under `peers_m_`, and carried by the
returned handle; a send only ever COMPARES it and never re-reads the slot's. A session
that departed in between fails that comparison, so the frame is dropped and counted
(`stats_t::tx_to_dead_peer`) exactly as a send to an already-departed peer is. Stamping
the shared per-slot object at resolve time was rejected rather than shipped: two callers
resolving one slot at different generations overwrite each other's stamp, which narrows
the window while reading like a closure.
**What callers see.** The return type is unchanged (`transport_t*`), and the
`bus_link_t::peer_link` contract is unchanged — resolve per use, do not cache the pointer
across a possible departure. Two resolutions of the same LIVE session still answer with
the same object (they saw the same generation, so sharing is safe); resolutions either
side of a departure no longer do. `peer_link` can now also answer `nullptr` for a peer
that IS live, in the one case where the handle pool cannot be grown — an honest "no such
peer" the caller drops and counts, never a handle that might name the wrong session.
**Cost.** The handles come from a per-link pool grown on demand to the peer population
and recycled through a quarantined free list, so a forward hop's resolve allocates
nothing in steady state — the same shape the TX slot pool and the peer slots already use,
and no per-hop global-heap traffic. One handle is 20 B on a 32-bit chip (~32 B with the
pool's owning pointer and the allocator's block overhead), the pool holds at most
`open sessions + 4`, and each session slot gives back the 12 B its embedded endpoint used
to occupy: about +20 B per concurrent peer plus ~128 B fixed for the quarantine.
## [0.12.0] — 2026-08-14
### Added
- **`httpd_ws_link_t::close_peer()` — a listener can now drop ONE inbound session by name**
([#1146](https://github.com/avatarsd-llc/libtracer/issues/1146)). The "revoke this
controller" action, matching what core's `slot_server_t` has always offered; previously
the link inherited `bus_link_t`'s `false`, so the capability was absent. Callable from any
task, and it keeps the base contract HONESTLY: `true` means the teardown really was
initiated. It cannot close in-call — `esp_http_server` owns the descriptor's lifetime on
its own task, and an off-task `shutdown` of a stale fd lands on whoever inherited the
number (#954) — so the close is marshalled onto the httpd task like every send, and the
return value is `httpd_queue_work`'s verdict. That verdict is only trustworthy above the
component's ESP-IDF floor (`>=5.5.5`), where the control-mbox slot is reserved through a
counting semaphore before the `sendto`: below it a full queue was binned inside lwIP while
still reporting success, which would have made `true` a lie told precisely when the queue
is fullest, i.e. when a stalling peer most deserves revoking (#949). A refused enqueue is
a plain `false` with the session untouched, so a caller may retry. The queued item carries
the session's `(slot, generation)` identity minted at the call — never a name or a
descriptor, because `p<slot>` is a pure function of the slot index and a reclaimed slot
re-earns it — so a peer that departs before the item drains cannot have its successor
closed in its place. The peer is told why: a CLOSE frame carrying the new
**`httpd_ws_link_t::kCloseRevoked`** (4403, mnemonic for HTTP 403), distinct from
`kCloseAuthFailed`/`kCloseAuthTimeout` because a revoked client should stop reconnecting
rather than re-prompt or retry. The departure reaches the routing plane through the
ordinary `free_ctx` seam, so subscriber-edge eviction is unchanged. A FLAT link still
answers `false` — it has one routing identity for every tab it carries.
- **`httpd_ws_link_t::tx_reply_reserve()`**
([#1218](https://github.com/avatarsd-llc/libtracer/issues/1218)) — TX work slots held
back for sends issued ON the httpd task, **additional to `tx_slot_capacity()`**. The
in-call sender is the one claimer that cannot wait for a slot (the task that frees them
is the task asking), so the guarantee that a request's reply always finds one is now a
slot no other sender can take, rather than a shortened pool for everybody else. Read
`tx_slots_busy()` against `tx_slot_capacity() + tx_reply_reserve()`: that sum is the
link's total slot count, and the depth a send issued on the httpd task can reach.
- **A handshake can now authenticate its own session:
`httpd_ws_link_t::set_admission_verdict_cb`**
([#1245](https://github.com/avatarsd-llc/libtracer/issues/1245)). The three-valued form of
the admission predicate — `REFUSE` / `ADMIT` / `ADMIT_AUTHENTICATED` — where the third
verdict says the opening GET already carried a credential this node accepts, so no
authentication frame is asked for and no deadline is armed for that session. Without it
#1184's two seams do not compose in the one deployment that motivated the frame: a node
serving BOTH browsers (which cannot present a header, hence the frame) and native dialers
(`esp_ws_client_link_t`, which presents a header and has no way to send a frame). Installing
an auth hook put EVERY session into the unauthenticated state, so every dialer of such a
node was closed with `kCloseAuthTimeout` however good its header credential was — and the
hook could not work around it, `auth_fn_t` being handed a payload and no session identity to
attach a handshake observation to. **Additive**: `set_admission_cb` is untouched, a link
with no auth hook is unaffected by any verdict, and the new predicate is a SEPARATE method
rather than an overload precisely so `set_admission_cb(nullptr, nullptr)` does not become
ambiguous. The verdict is carried from the pre-handshake to the lazy first-frame claim (the
slot does not exist at the first, and only this link spans the two) through a fixed-size
per-socket record set, `kMaxPreauthenticated`; an overflow, or a handshake whose peer never
sends a frame, degrades to `ADMIT` — the session is asked for a credential, which is the
fail-CLOSED direction.
- **`httpd_ws_link_t` gains a post-handshake authentication frame**
([#1184](https://github.com/avatarsd-llc/libtracer/issues/1184)), so a BROWSER can
authenticate a graph session. The `WebSocket` API cannot set request headers, so the
existing `set_admission_cb` predicate — which reads the opening GET — is unreachable from
a browser, leaving embedders a cookie-minting HTTP endpoint or the credential in the URL
query string (a leak into logs, history and referrers). The new `set_auth_cb` installs an
in-band check that runs on the session's first data frame(s) instead. Header-based
admission is unchanged and both may be installed at once.
- The payload is **opaque** to the link and handed to the hook verbatim: the frame is a
permanent CARRIER, not a token format. The hook answers `ACCEPT` / `CONTINUE` / `REJECT`
(`auth_verdict_t`) with an optional reply payload, so a multi-round-trip handshake — the
ed25519/Noise direction — rides the same frame later with no format change.
- Until it is accepted a session is admitted at the transport level and **served nothing**:
its frames go to the hook and never the graph, and it is absent from `enumerate_peers`,
`enumerate_peer_stats` and `peer_link`, and skipped by every broadcast.
- `auth_result_t::subject` binds a session identity, published through the new
`peer_stats_t::subject`. What it means to a handler stays
[#375](https://github.com/avatarsd-llc/libtracer/issues/375)'s question.
- Both constructors take a new trailing `auth_deadline_ms` (0 = `kDefaultAuthDeadlineMs`).
A session that has not authenticated within it is closed — an unauthenticated peer now
reaches a slot, which it never could before, so the window it may hold one is bounded.
Enforced by a periodic `esp_timer` sweep AND synchronously at the claim edge, so an
expired session can never be the reason a live peer is refused.
- Two application-range close codes, `kCloseAuthFailed` (4401) and `kCloseAuthTimeout`
(4408), written to the peer BEFORE the socket is shut down. New `stats_t::auth_rejected`
and `stats_t::auth_expired` count the two causes separately.
- Described in [docs/reference/16-websocket-session-auth.md](../../../docs/reference/16-websocket-session-auth.md).
- **The two chip-native WebSocket links are configurable, and their effective values are
readable** ([#1160](https://github.com/avatarsd-llc/libtracer/issues/1160)). Every number
governing them used to be a `constexpr` in an anonymous namespace with no knob, no
argument and no accessor — including the three that ARE the per-link static RAM
(~8.4 KiB/link), on a target where the same CMakeLists derives 896 B of stripe padding
rather than asking. Split knob by knob:
- **RAM-bearing → constructor arguments** (per-LINK, because a Kconfig value is
per-image and two links in one image serve different peers — the shape
`esp_ws_client_link_t` has always had for `rx_bytes`/`tx_bytes`). Both
`httpd_ws_link_t` constructors take three new trailing arguments,
`rx_scratch_bytes` / `tx_pool_slots` / `tx_inline_bytes`, each `0` = "take the
default" (the same idiom `send_timeout_ms` and `auth_deadline_ms` already use). The
defaults are published as **`kDefaultRxScratchBytes`** (2048),
**`kDefaultTxPoolSlots`** (4) and **`kDefaultTxInlineBytes`** (1600), so nothing
about a stock link changes.
- **Readable**: new **`httpd_ws_link_t::rx_scratch_bytes()`**,
**`tx_inline_bytes()`** and **`buffer_bytes()`** (the whole per-link buffer cost, what
a RAM audit asks for), plus **`esp_ws_client_link_t::rx_bytes()`** / **`tx_bytes()`**
and **`esp_ws_client_link_t::timing()`** — the five blocking bounds the client rides.
A node reporting an inbound-drop streak can now name the ceiling that produced it.
- **Client timing → Kconfig** (per-image: recv-loop cadence and re-dial policy, no
per-link RAM behind them): `LIBTRACER_WS_CLIENT_READ_TIMEOUT_MS` (100),
`LIBTRACER_WS_CLIENT_POLL_MS` (200), `LIBTRACER_WS_CLIENT_RECONNECT_BACKOFF_MS`
(1500). Defaults are the historical literals.
- **Deliberately still fixed**, each now saying why next to itself: the consecutive-TX-drop
strike cap (a brokenness DETECTOR, and a divisor of the derived send bound — a knob over
it would let an embedder disable the protection that keeps one stalled peer from parking
the httpd task), the inbound frame ceiling (bounds an abuse case, costs no RAM), the
socket slack and the assumed peer cap (already reachable through `max_peers`), the
teardown drain bounds, and the client's dial/write timeouts (DERIVED from
`CONFIG_ESP_TASK_WDT_TIMEOUT_S`, which is itself a Kconfig one level up — a second knob
could configure a node into a watchdog panic).
**BREAKING (source):** `httpd_ws_link_t::tx_slot_capacity()` is no longer `static` — it
reports the per-link depth. Call it on the link, or name `kDefaultTxPoolSlots` where no
link is in hand. `tx_reply_reserve()` stays `static`.
### Changed
- **The component writes a `libtracer/config_override.hpp` FRAGMENT instead of rendering
`config.hpp.in`** ([#1244](https://github.com/avatarsd-llc/libtracer/issues/1244),
ADR-0068 §Erratum 1). It was the last consumer of core's template, which core itself
stopped rendering in #1142; the template is now deleted. The generated fragment inherits
`tr::graph::default_config_t` and states only the four knobs an ESP target changes —
`kVertexLockStripes` (menuconfig `CONFIG_LIBTRACER_VERTEX_LOCK_STRIPES`), `kCacheLineBytes`
(derived from `CONFIG_FREERTOS_UNICORE`), `kEdgePinSlots` (8) and `kSpinWaitSafe` (derived
from `IDF_TARGET`: `false` on every chip, `true` on `linux`). The ACL profile, the LKV slot
and the hazard reader slots are no longer restated here at all, because core's defaults are
already what this component wants — so a knob added to `config.hpp` now reaches an ESP build
at its new default, where before it had to be mirrored into the template by hand.
**No configured value changed and no Kconfig option was renamed**; the generated directory
still precedes `core/include` in `INCLUDE_DIRS`, which is what lets `config.hpp` find the
fragment with `__has_include`.
- **A CONDEMNED session no longer counts toward `max_peers`.** The admission cap counted
slot-table occupancy while every other question this link answers — `enumerate_peers`,
`peer_link`, the fan-out — answers from reachability (#963), so a session the link had
already refused to carry another frame for could turn a live peer away until httpd got
round to reaping it (a select round when the server is healthy, unbounded when it is not,
which is exactly when `condemn` is used). The cap now skips condemned slots.
### Fixed
- **`httpd_ws_link.cpp` now compiles with `CONFIG_LWIP_IPV6` off.** The #994 peer-endpoint
rework sized its buffers from `INET6_ADDRSTRLEN` and decoded an `AF_INET6` `getpeername`
unconditionally, but lwIP defines neither `sockaddr_in6` nor the v6 constant when IPv6 is
disabled — an embedder with `CONFIG_LWIP_IPV6=n` (v4-only nodes are common on constrained
builds) could not compile the component at all. The v6 arm is now compiled out under that
config: `esp_http_server` binds `PF_INET` there, so `AF_INET` is the only family a peer
can present and the v4 buffer bound is exact, not truncated. No behavior change with
IPv6 on.
- **The in-call reserve no longer narrows the fan-out width**
([#1218](https://github.com/avatarsd-llc/libtracer/issues/1218)). 0.11.0 introduced the
reserve by shortening the scan an off-httpd-task claimer was allowed, which took a slot
from **every** producer, permanently, whether or not a reply was pending — so the
outstanding-send bound a fan-out actually got was `tx_slot_capacity() - 1`. A publish
sweep of exactly `tx_slot_capacity()` destinations, which had always fit and delivered at
the offered rate, therefore had to wait for the drain on **every pass**; on a unicore
target that drain cannot happen while the producer is running, so the wait expired, the
last destination of the sweep was dropped, and the futility latch then suppressed further
waits for a full send occupancy — a permanently starved tail plus a producer stalled a
send bound per latch cycle. The reserve is now `tx_reply_reserve()` slots **past** the
pool, and an in-call send takes it FIRST, so a reply in flight leaves the pool's whole
depth claimable. What #1187 bought is unchanged: a sweep wider than the pool still costs
latency rather than its tail, and a delivery burst still cannot starve a request's reply —
it now survives a burst holding the pool's **full** depth. The cost is one more TX slot
of RAM per link (a work-item shell plus its inline payload capacity); a guarantee for a
claimer that cannot wait has to be a slot nobody else can take.
## [0.11.0] — 2026-08-13
### Added
- **`esp_ws_client_link_t` gains a `defer_recv` constructor flag and a real
`start_receiving()`** ([#1102](https://github.com/avatarsd-llc/libtracer/issues/1102),
ADR-0081's defer-the-dial arm). Constructed with `defer_recv`, the recv thread is
spawned but parks BEFORE its first dial until `start_receiving()` releases it — so a
peer's push-on-connect can no longer be decoded into the not-yet-installed receiver
slot and dropped silently. The latch is one-shot (reconnects are not re-gated: every
re-dial happens on a link that was already armed) and `start_receiving()` is idempotent
and safe on a link that never connected, matching `transport_vertex_t::make_connection`
arming every link unconditionally. Default `false` keeps the historical dial-at-once
ctor contract; the documented `provide_link` recipe gains the opt-in step, because
there is no `ws` factory on a chip target to pass the flag for you — and an embedder
that sets it and never arms the link (never issues the creating write) has a link that
never dials, `ok()` false, all reconnect/keepalive machinery off.
- **`httpd_ws_link_t::stats_t` gains `tx_pool_waits`**
([#1187](https://github.com/avatarsd-llc/libtracer/issues/1187)) — sends that found the TX
pool full and waited for the httpd task to free a slot, whether or not the wait then
succeeded. Read it against `tx_pool_misses`: waits without misses is the pool doing its
job as an in-flight bound (a wide fan-out paced by the drain, costing latency), while
misses mean the httpd task did not free a slot within one send occupancy. The rest of
`stats_t` is unchanged, and `drop_stats()` is unaffected — a wait is not a loss.
### Changed
- **A fan-out wider than the TX pool now costs latency, not its tail**
([#1187](https://github.com/avatarsd-llc/libtracer/issues/1187)). `httpd_ws_link_t`'s TX
slot pool is an OUTSTANDING-SEND bound, but a producer task that walked its peer set
without ever leaving the CPU could only fill it once: on a unicore target (ESP32-C6) the
whole publish sweep was posted before the httpd task ran at all, so exactly the first
`tx_slot_capacity()` destinations landed **every pass, in publish order**, and the rest
of the peer set sat at a flat zero — 12 subscriber edges, 5 alive, reproduced
bit-identically on two boards. A send issued from any task OTHER than the httpd task now
sleeps in short turns until a slot frees, bounded by one slot occupancy (the per-socket
send bound spent once per write leg — derived, not configured), and a whole sweep by one
such bound; a send that is still unserved when the bound expires is dropped and counted
exactly as before. Nothing is buffered to achieve this: the frame waits in the caller's
own memory, in the caller's own call (ADR-0081 §1). A send issued ON the httpd task —
a reply serviced in-call, or a push provoked by an inbound frame — cannot wait for a
drain it is itself supposed to perform, so it still meets the pool's depth as a hard
same-pass limit; one pool slot is now **reserved** for those in-call sends so a delivery
burst can no longer starve a request's reply (the reported secondary effect, where a
subscribe ack was lost and the requester timed out against a live edge). `kTxPoolSlots`
is unchanged at 4 and remains a compile-time constant; making the RAM-bearing link
constants settable is [#1160](https://github.com/avatarsd-llc/libtracer/issues/1160)'s
ruled scope (ctor parameters), and this fix is deliberately independent of the depth
chosen there.
- **BREAKING (semantic): `esp_ws_client_link_t::ok()` is now the CAME-UP predicate, and
liveness moved to the new `link_up()` override**
([#1203](https://github.com/avatarsd-llc/libtracer/issues/1203), completing
[#1059](https://github.com/avatarsd-llc/libtracer/issues/1059) on the chip-native links).
Same declaration, different answer, so nothing breaks at COMPILE time — **read this
before upgrading if you poll a chip WS client link.**
- `ok()` used to return `connected_`, i.e. live state that reverts on every disconnect.
Under the tree-wide ruling that is `link_up()`'s job, so this link had two liveness
answers (its own and the base default `true`) and no came-up answer — the inverse of
the contract every core transport already follows.
- `ok()` now latches: false until the first dial + RFC 6455 handshake lands (forever, on
a link constructed with `defer_recv` and never armed), true from then on for the rest
of the object's life. `link_up()` answers liveness — true while a handshaken connection
is standing, cleared by the recv loop's drop path, by `send()`'s failed/short-write arm
and by the destructor, and true again after a re-dial.
- **Migration:** an embedder polling `link->ok()` to decide whether the peer is reachable
(to re-dial, to gate a publish, to drive a status LED) must move to `link->link_up()`.
One gating on "did this link ever come up" is already correct and needs no change.
`stats().up` is unchanged in meaning — it always reported liveness, and now says so.
- Deliberately shipped in ONE release with the accessor's arrival rather than latched
first and implemented later: a window in which both spellings answer liveness is a
window in which a downstream site silently keeps working and then breaks.
- **`httpd_ws_link_t` overrides `link_up()` (#1203).** Its `ok()` was already the came-up
predicate (the `httpd_handle_t` is published by the constructor and cleared only by the
destructor), so nothing about it changes; what changes is that a link whose server never
started no longer inherits the base default `true`. A multi-peer server still outlives
any one peer — a session's departure does not move this, it is reported through the
per-session eviction seam.
- **`twai_link_t::ok()` — documentation only (#1203).** It was already a came-up predicate,
and there is no `link_up()` to add: the type is a `can_link_t` driver seam rather than a
`transport_t`, so it is not on the `#1059` contract, and a bus has no closure concept
anyway. The owning `transport_can` is the `transport_t` in that stack.
- **`tr::esp::portmux_sync_t` declares the new `is_nonblocking` policy trait (#928).** Core
split the overloaded `is_isr_safe` into `is_isr_safe` (safe concurrent with an ISR) and
`is_nonblocking` (no heap, no syscall, no OS wait), and made both `pool_sync_policy`
requirements. The interrupt-disable section is `true` for both, so `tr::esp::critical_pool_t`
keeps publishing `is_isr_safe = true` — it stays the ISR-safe pool the bare `mem::pool_t`
never was.
## [0.10.0] — 2026-08-12
This component compiles the core C++ sources, so — unlike 0.9.1 — essentially all of
core 0.10.0 reaches it.
### Added
- **Picks up `transport_t::link_up()` (#1059).** The liveness question now lives on the
transport base this component's links inherit: `tcp_transport_t`'s DIAL role answers it
from the live fd, and the chip-native links (`httpd_ws_link_t`, `esp_ws_client_link_t`,
`twai_link_t`) answer the base default until they carry their own liveness. Deliberately a
relaxed atomic with no is-always-lock-free assertion — a 32-bit embedded core is exactly
the target that ruling protected.
- **Picks up the wire-trailer timestamp writer (#1109).** `wire::stamp_ts` + the FWD plane's
stamp preservation and terminus echo compile in, so an ESP node in a FWD path now
preserves an outer TF=0 stamp verbatim and echoes a request stamp on every reply — RTT to
and through a chip node becomes measurable with no clock sync. The forward hop's iovec
budget grows one slot (`kFwdMaxIov` 9 → 10); no library-internal buffering is added.
### Changed
- **Picks up the core's wire-visible tightenings.** A kind-less connection `SPEC` matching
no staged link now answers `TYPE_MISMATCH`, not `NOT_FOUND` (#1062); `graph::valid_segment`
rejects the full reserved set `/ : . [ ] * ?` — brackets included — so a bracketed NAME now
answers `INVALID_PATH` (#996); and read/write disclosure parity on nonexistent fields
(#435) governs this node's `:settings.app` doors the same as every other terminus.
- **Picks up the tighten-only `max_frame` clamp (#1035).** The component's framed transport
(`tcp_transport_t`, both roles) now resolves `:settings max_frame` through
`length_prefix_framer::configured_cap`, so a config write can only narrow what this node
buffers off the wire — on a heap-constrained chip target the 16 MiB protocol default being
a ceiling rather than a suggestion is precisely the wanted direction.
- **Not this component: the PlatformIO WS exclusion (#984).** The `library.json` srcFilter
change ships the *PlatformIO* espressif32 package with no WebSocket transport; this
ESP-IDF component is the other packaging channel and is unchanged — it already builds the
IDF-native WS links and never core's POSIX-socket pair (#947/#978). Noted here because a
consumer choosing between the two channels should know WS-on-ESP32 now lives only here.
## [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:152`,
`esp_ws_client_link.hpp:176`, `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.