DeterministicESPAsyncWebServer v6.27.1
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
Bug log

A running record of every bug found in this library: what broke, the root cause, the fix, and status. Newest first. A bug is logged here the moment it is found (even before it is fixed) so nothing slips.

Status key: OPEN (found, not fixed) - FIXED (fixed, validated) - SHIPPED (released).


Edge-cache Range on a MISS lost the client's Range header (stale http_pool[slot])

  • Status: FIXED (2026-07-17). Found by the HW MISS+Range test of the new Range/206-from-cache feature on an ESP32-S3 - the exact integration path host tests cannot reach.
  • Symptom: a Range: bytes=15-35 request against a cold cache returned the full 200 body (83 bytes, HTTP/1.0, X-Cache: MISS) instead of a 206 window. A fresh-**HIT** Range request worked (byte-exact 206), so range parsing/serving was correct - only the miss path lost the range.
  • Root cause: serve_hit read the Range header from http_pool[slot]. On a fresh hit that runs synchronously in dispatch while http_pool[slot] is still the client request. But a miss/stale entry is served from the poll loop after the async origin fetch, by which point http_pool[slot] has been reset/reused (the tell: the miss response was HTTP/1.0 - send_chunked's version fallback fires because http_pool[slot].version is no longer HTTP_11), so Range (and the version) were gone.
  • Fix: capture the client Range header at middleware time (when http_pool[slot] is valid) into a per-slot owned buffer EdgeCacheProxyCtx::range_hdr[MAX_CONNS][48], and resolve the window against that in serve_hit. Re-flashed: a cold-MISS Range: bytes=15-35 now returns 206 + Content-Range: bytes 15-35/83 + the byte-exact 21-byte window, and HIT/416/HEAD/Accept-Ranges all stay byte-exact.
  • Related (pre-existing, not fixed here): the same http_pool[slot]-goes-stale-after-the-async-fetch root cause means a miss response is emitted as HTTP/1.0 (Connection: close instead of keep-alive), and store_response's Vary-value capture reads the stale request - so a Vary response cached on a miss stores an empty/garbage secondary key (it degrades to always-miss-and-refetch for that variant; never serves wrong content). Both are latent efficiency issues present since the RAM tier and want a holistic fix (preserve or snapshot the client request across the suspend); tracked in TODO.md.

79.EdgeCache example opened no HTTP listener - server.begin() with no port

  • Status: FIXED (2026-07-17). Found by the first HW bring-up of the edge cache on an ESP32-S3: the board joined WiFi and its TCP stack was alive (it RST'd port 80), but nothing served - :80 was refused.
  • Symptom: GET /cdn/<path> got a connection refusal; the board pinged fine and actively refused :80. A serial DIAG confirmed the edge cache itself was configured (map=1, begin=1 listeners) but no HTTP port was open.
  • Root cause: the example called server.begin() with no port and no prior server.listen(). begin() with no argument requires a registered listener (else it returns DETWS_ERR_NO_LISTENERS and starts nothing); the single-HTTP-port form is server.begin(80). A library-contract slip in the example, not a library defect.
  • Fix: server.begin(80) in examples/L7-Application/79.EdgeCache. Re-flashed; the full MISS -> HIT -> REVALIDATED(304) -> purge path then passed byte-exact against a real origin. Exactly the class of whole-firmware integration bug that host mock-seam tests miss and a real HW run catches.

native_codeql (all-flags) test_dispatch 405/Allow tests fail - CSRF gate, hidden by CI

  • Status: FIXED (2026-07-17). Found 2026-07-16 by the WSL native env actually running the native_codeql suite. CI never caught it because the CodeQL workflow builds native_codeql with pio test --without-testing (compile-only, to trace the build) - its assertions have never executed in CI.
  • Symptom: under the all-feature-flags native_codeql config, test_get_route_advertises_head_in_allow (POST to a GET-only route → expect 405 + GET/HEAD in Allow) and test_405_includes_allow_header (DELETE to a POST-only route → expect Allow: POST) both failed (Expected Non-NULL), while the same tests driven with GET (test_method_mismatch_returns_405, test_405_allow_lists_all_methods_for_path) and HEAD passed.
  • Root cause: a feature-flag interaction, not a dispatch bug - the all-flags build enables DETWS_ENABLE_CSRF, and csrf_gate (dwserver.cpp) runs before the route loop and rejects any un-tokened state-changing method (POST/PUT/PATCH/DELETE) with 403, so those requests never reach the §6.5.5 405/Allow dispatch. GET/HEAD/OPTIONS are CSRF-exempt, so they still 405. This is the intended, fail-closed security behavior (don't leak a path's allowed methods to an un-tokened request); the test simply didn't account for CSRF being on.
  • Fix: the two unsafe-method tests now attach a valid X-CSRF-Token (via feed_unsafe + a suite-level csrf_set_secret in setUp, both #if DETWS_ENABLE_CSRF), so a legitimate token-bearing request reaches the 405/Allow dispatch; with CSRF off the request line is plain. Verified: native_codeql:test_dispatch 11/11 (was 2 failed) and native_app:test_dispatch (CSRF off) still green.

DTLS 1.3 HelloRetryRequest carried the TLS version codepoints (0x0303 / 0x0304) instead of DTLS (0xFEFD / 0xFEFC)

  • Status: FIXED (2026-07-15). Found by real-peer interop the moment the HelloRetryRequest path was first driven end-to-end (wolfSSL DTLS 1.3 client leading with a non-X25519 group; test/servers/dtls_wolfssl). The HRR builder shipped in v6.17.0 but was never wired into the state machine until the HRR group renegotiation landed, so no released version ever sent an HRR - the bug was latent in unexercised code.
  • Symptom: with the client offering only a non-X25519 key_share, the server correctly answered with a HelloRetryRequest, but wolfSSL rejected it with a protocol_version alert (-326, record layer version error). The direct one-round-trip path (client offers X25519 up front) worked, because its first server message is a ServerHello - which did use the DTLS codepoints - while the HRR did not.
  • Root cause: tls13_build_server_hello already took a dtls flag to emit legacy_version 0xFEFD and supported_versions 0xFEFC (RFC 9147 §5.3), but tls13_build_hello_retry_request - a separate builder for the same ServerHello structure - hard-coded 0x0303 / 0x0304. The byte-exact HRR KAT (native_dtls_tls13) had pinned those TLS codepoints, so it stayed green: another self-referential KAT that shared the mistake.
  • Fix: tls13_build_hello_retry_request gained the same dtls flag (0xFEFD / 0xFEFC when set); the DTLS state machine passes dtls=true, and the HRR KAT was recomputed with the DTLS codepoints. Verified end-to-end: wolfSSL now completes the full handshake through a HelloRetryRequest and an application-data round trip (HANDSHAKE OK (via HelloRetryRequest) ... INTEROP OK).
  • Lesson: exactly the same lesson as the entry below - a self-KAT proves self-consistency, not conformance. The moment a new code path (the HRR) was exercised against a real peer, it surfaced a deviation the green KAT had frozen in. Wire up real-peer interop for every path, not just the happy one.

DTLS 1.3 used the TLS 1.3 "tls13 " HKDF label prefix instead of "dtls13" (plus two DTLS ClientHello/version bugs)

  • Status: FIXED (2026-07-15). Three DTLS-vs-TLS conformance bugs found by the first real-peer interop (wolfSSL DTLS 1.3 client vs. dtls_conn; test/servers/dtls_wolfssl). Shipped in v6.15.0 (record layer) and v6.18.0 (handshake).
  • Symptom: the hand-rolled DTLS 1.3 server completed a self-consistent handshake against its own test client but could not interoperate with wolfSSL at all - the ClientHello failed to parse, then (after that was fixed) the handshake was rejected on version, then (after that) the AEAD open of the peer's first encrypted record failed because the derived keys diverged.
  • Root causes (all DTLS-specific deviations from TLS 1.3 that the self-referential host KATs could not catch, since the KATs shared the implementation's assumptions):
    1. **legacy_cookie** (RFC 9147 §5.3): the DTLS ClientHello carries an extra legacy_cookie field between legacy_session_id and cipher_suites; the shared tls13_parse_client_hello (written for QUIC) skipped it, so it read cipher_suites at the wrong offset and the parse failed.
    2. Version codepoints (RFC 9147 §5.3): DTLS 1.3 advertises 0xFEFC in supported_versions (not the TLS 0x0304) and puts legacy_version 0xFEFD in the ServerHello. The server checked for 0x0304 and sent a TLS ServerHello, so wolfSSL rejected it with a protocol_version alert.
    3. HKDF-Expand-Label prefix (RFC 9147 §5.9): DTLS 1.3 replaces the "tls13 " label prefix with "dtls13" in every Expand-Label - both the key schedule (traffic secrets, Finished) and the record key/iv/sn. The DTLS code reused quic_hkdf_expand_label, which hard-coded "tls13 ", so every DTLS secret was wrong. Diagnosed by dumping our handshake-traffic secret and comparing to wolfSSL's SSLKEYLOGFILE, then bisecting transcript vs. key-schedule in an independent Python reconstruction.
  • Fix: (1) tls13_parse_client_hello takes a dtls flag that skips legacy_cookie; (2) tls13_build_server_hello + supported_versions parse use the DTLS codepoints under the same flag; (3) the label prefix became a first-class KDF variant - Tls13Kdf (TLS13_KDF / DTLS13_KDF), bound once into the Tls13KeySchedule and passed to the record-key derivation, replacing the hard-coded prefix (no bool dtls threaded through the schedule). The record KAT was recomputed with "dtls13". Verified end-to-end: wolfSSL now completes the handshake and an application-data round trip (INTEROP OK).
  • Lesson: a byte-exact KAT pinned to your own independent reimplementation proves self-consistency, not conformance - it shares your misreadings of the spec. A real reference peer (or vectors from one) is the only thing that catches a wrong-but-consistent assumption. Every new wire protocol needs a real-peer interop check, not just a self-KAT.

SSH server KEXINIT (I_S) overflowed its 512-byte store once ecdh-sha2-nistp256 was advertised

  • Status: FIXED (2026-07-15). Found while adding the ecdh-sha2-nistp256 KEX (v6.14.0).
  • Symptom: advertising one more KEX algorithm made ssh_kexinit_build() return -1 in some builds; two native tests (test_begin_rekey_preserves_session_and_auth, test_ssh_transport_more_guards) failed with a stack-smash abort (SIGQUIT) once the payload crossed the buffer edge.
  • Root cause: SSH_KEXINIT_S_MAX was 512, exactly enough for the previous advertised suite. Adding ecdh-sha2-nistp256, (19 bytes) to kex_algorithms pushed the worst-case server KEXINIT (PQC hybrid + zlib s2c + all three host-key types + the full cipher/MAC lists ~= 580 bytes) past 512, tripping the w.len > SSH_KEXINIT_S_MAX guard (which had been marked "never exceeds"). The production packet buffer (SSH_PKT_BUF_SIZE = 2048) was fine; the fixed i_s[] store and one test's local 512-byte buffer were not.
  • Fix: raised SSH_KEXINIT_S_MAX to 704 (headroom over the ~580 worst case) and grew the test's local kbuf to 1024. Also corrected test_kexinit_parse_rejects_missing_kex, which had used ecdh-sha2-nistp256 as its example of an unsupported KEX - now ecdh-sha2-nistp521.
  • Lesson: a buffer sized to exactly fit today's advertised algorithm lists breaks the next time a list grows. Size protocol-list buffers to the theoretical worst case with headroom, and never mark a length guard "unreachable" - it is one algorithm away from firing.

75.InterfaceBridge: ESP32 Build linked without its feature flag - det_bridge_publish undefined

  • Status: FIXED (2026-07-15, commit 90e5a972).
  • Symptom: the ESP32 Build CI job for 75.InterfaceBridge failed at link with an undefined reference to det_bridge_publish() - chronically red since the example shipped (v6.8.0).
  • Root cause: the ESP32 Build discovers each example's build_flags by scraping the first documented pio ci command from its README.md (tools/ci/example_footprints.py). 75.InterfaceBridge's README had no such command, so CI built it with empty flags: the library's iface_bridge_hw.cpp guards its body under #if DETWS_ENABLE_IFACE_BRIDGE, so with the flag absent det_bridge_publish() compiled to nothing while the sketch (which sets the flag only in its own translation unit) still referenced it. An in-sketch #define never reaches the separately compiled library.
  • Fix: added the standard ## Build section with -DDETWS_ENABLE_IFACE_BRIDGE=1 to the README, matching every other feature-gated example. Verified pio ci links on a real ESP32 (esp32dev, 59.5% flash) and the ESP32 Build CI turned green.
  • Lesson: when the build system derives config from docs, a missing doc line is a silent build break. Every feature-gated example needs its pio ci build-flag command in the README, not just an in-sketch #define.

Protocol dispatch: PROTO_BRIDGE (and any ConnProto id >= 8) silently never registered

  • Status: FIXED (2026-07-14). Found while adding PROTO_NTRIP_CASTER = 9.
  • Symptom: the interface-bridge listener (PROTO_BRIDGE = 8, shipped v6.8.0) would accept connections but its handler was never invoked - the dispatch table returned no handler for the slot - so a bridged port did nothing. Latent because the v6.8.0 verification was a host codec test + a compile, not a live PROTO_BRIDGE connection.
  • Root cause: DETWS_PROTO_MAX (the dispatch-table size) was 8, and both proto_register() and proto_get() bound-check with (unsigned)proto < DETWS_PROTO_MAX. PROTO_BRIDGE = 8 fails 8 < 8, so the handler was neither stored nor fetched. Adding a ConnProto id at/above the table size silently disabled it.
  • Fix: raised DETWS_PROTO_MAX to 10 and added a static_assert((unsigned)ConnProto::PROTO_NTRIP_CASTER < DETWS_PROTO_MAX, ...) next to the enum's config so any future proto that outgrows the table is a compile error, not a silent no-op.
  • Lesson: a fixed-size table indexed by an enum needs a static_assert pinning the size to the enum's max; an off-by-the-newest-value ceiling is invisible without one, and "compiles + host test passes" does not exercise runtime registration. Verify a new listener with a live connection, not only a compile.

W5500: large transfers crash / truncate - marginal SPI signal integrity, NOT a library defect

  • Status: NOT A LIBRARY BUG (hardware; 2026-07-13). Root-caused by JTAG + an SPI-clock sweep, resolved by clean wiring + a conservative clock. Logged so it is not re-chased as a software bug.
  • Symptom: on an ESP32-S3 + W5500 (breadboard jumpers), a large streamed download (send_chunked) crashed the board mid-transfer; curl saw a connection reset (rc=56). Small requests succeeded.
  • Investigation: a JTAG break-in under load caught a TLSF heap-corruption panic (assert block_is_free ... "block must be free") inside emac_w5500_task -> esp_pbuf_allocate -> mem_malloc
    • the W5500 driver's RX path, entirely upstream, with zero library frames. The corrupt tlsf was in PSRAM. Initial hypotheses (our send path flooding lwIP; pbufs spilling to a PSRAM heap) were disproved by instrumenting the live heap: across clean 50 MB and 200 MB transfers the internal heap never drained, PSRAM was never allocated from (psram_free flat), and heap_caps_check_integrity_all stayed OK. Our chunk_send_pump already honors tcp_sndbuf backpressure and is zero-heap.
  • Root cause: marginal SPI signal integrity. After the W5500 was rewired in isolation (off shared breadboard rails / USB3 noise), the same firmware streamed 200 MB byte-exact with a flat heap. An SPI-clock sweep then reproduced the failure on demand: clean to ~24 MHz sustained, truncation at 40-60 MHz, and at 80 MHz the W5500 chip-ID read back 0x82 instead of 0x04 - a corrupted SPI read. A corrupted frame length from a bad SPI read makes the driver over-copy into a pbuf, which is what corrupts the heap. So the "crash" was a downstream symptom of bad wiring, not a code defect.
  • Fix / mitigation: added DETWS_ETH_W5500_SPI_MHZ (default 20, the safe/upstream value proven at 200 MB) so the clock can be tuned to the wiring; documented the SPI-bound throughput curve and the signal-integrity ceiling in FEATURE_PERFORMANCE.md / HARDWARE_HOOKUP.md. No library code was at fault.
  • Lesson: an upstream-only backtrace plus a flat heap under load points at hardware, not the library; the cheapest confirmation was an SPI-clock sweep that turned an intermittent crash into a monotonic, explainable signal-integrity curve. Overclocking a bus to force the failure beats guessing at a software cause.

Transport: a large streamed response (chunked / file) truncates mid-transfer - the idle sweep reaps an actively-sending connection

  • Status: FIXED (library, 2026-07-13; found by a 1 GB download benchmark against ESP32Async/ESPAsyncWebServer on an ESP32-S3, then reproduced deterministically).
  • Symptom: a response whose body is much larger than one TCP window (send_chunked or serve_file) drops mid-stream at a non-deterministic point - observed once at 233 MB with a connection reset (curl rc=56) and once at 86 MB with a short clean close - while small requests to the same server keep succeeding. The same 1 GB download served by ESPAsyncWebServer completes fine.
  • Root cause: the CONN_TIMEOUT_MS (5 s) idle sweep in check_timeouts() reaps any CONN_ACTIVE slot whose last_activity_ms is older than 5 s. That timestamp is refreshed on RX (recv callback) and on TX ACK (sent callback), so a healthy stream stays fresh - until a transient send stall (a Wi-Fi hiccup, or a brief full window with no ACKs) exceeds 5 s, at which point the sweep reaps a connection that is actively mid-transfer, truncating the body. (Same 5 s mechanism as the SSH "drops every framed packet after the banner" bug below, a different trigger.)
  • Fix: a slot still paging out a body is active, not idle. The file/chunk send pumps now call det_conn_touch_active(slot) each poll they run (they run every handle() loop through the on_poll seam), refreshing the idle timer so the sweep cannot reap an in-flight transfer. Dead-peer teardown for such a slot is delegated to lwIP's own retransmission timers, which abort a black-holed pcb through the err callback. The timestamp read that a size-based check would need lives on tcpip_thread, so refreshing from the worker-side pump (writing our own last_activity_ms) is the layer-clean signal.
  • Validation: native native (334, incl. test_transport) + native_keepalive (11) + native_range (20) all pass. HW (ESP32-S3): a deterministic reproduction - pause the client 9 s (> 5 s) mid-stream with SIGSTOP so ACKs starve - truncates on the pre-fix build and survives on the fixed build (the transfer resumes on SIGCONT and keeps streaming). Regression test test_active_send_not_reaped.
  • Lesson: an idle-timeout sweep must exclude a connection with an in-flight response - "no activity for N seconds" only means "idle" when nothing is being sent. A large-transfer stress test (not a happy-path smoke) is what exposes it; a small-response test never streams long enough to hit a stall.

SSH: SERVICE_REQUEST accepted before key exchange completes - pre-encryption userauth bypass

  • Status: FIXED (library, 2026-07-11; found by the pentest tool's ssh_msgtype_abuse against the S3 rig).
  • Symptom: a client that sends its KEXINIT and then - instead of completing the key exchange - sends SSH_MSG_SERVICE_REQUEST("ssh-userauth") gets SSH_MSG_SERVICE_ACCEPT back and the server advances to the userauth phase, all in cleartext (no NEWKEYS, no session keys derived, no host-key verification).
  • Root cause: the SSH_MSG_SERVICE_REQUEST case in ssh_server_dispatch() had no phase guard (unlike KEXDH_INIT and USERAUTH_REQUEST, which check their phase). It processed a service request in any phase, so a client could jump SSH_PHASE_DH_INIT -> SSH_PHASE_AUTH, skipping the entire key exchange. RFC 4253 §10 requires the service request only after the key exchange (ssh_newkeys_complete() advances a fresh connection to SSH_PHASE_SERVICE and turns on encryption).
  • Fix: guard the case with if (s->phase != SSH_PHASE_SERVICE) return -1;, so a premature service request is rejected and the connection closed. Regression test test_service_request_before_newkeys_rejected. HW-verified: the pentest ssh_msgtype_abuse attack goes from 1 finding to 0, and a real OpenSSH login (which sends SERVICE_REQUEST in the correct phase, after NEWKEYS) still completes 6/6.
  • Lesson: every state-machine transition that grants a capability needs an explicit phase guard; the raw-socket adversarial client that ignores the normal message order is the thing that finds the gap - a well-behaved client (or a host test that drives the happy-path sequence) never would.

SSH: server drops every framed packet after the banner - the dispatcher's emit callback is never wired

  • Status: FIXED (library, 2026-07-11; root-caused + fixed with a real OpenSSH 10.0 client, tcpdump, and on-device counters over JTAG on an ESP32-S3).
  • Symptom: a real SSH client connects, both sides exchange identification banners, the client sends its KEXINIT - then the connection is reset by peer ~5 s later and the server's KEXINIT never arrives (ssh -v). tcpdump: the device ACKs the 672-byte client KEXINIT (lwIP received it), then sends nothing for exactly 5 s (CONN_TIMEOUT_MS) before the RST. No panic, no reboot - the device is healthy, it just never replies.
  • Root cause: ssh_conn_setup() - which installs the SSH dispatcher's binary-packet emit callback via ssh_server_set_emit_cb(ssh_emit) - had no production caller. It was declared, defined, and documented ("call from begin()"), but nothing ever called it, so s_srv.emit_cb stayed null. The server-identification banner is written directly by ssh_conn_accept() (not through the callback), so it still went out; but every framed SSH packet - KEXINIT, KEXDH_REPLY, NEWKEYS, channel data - is emitted through the null callback and silently dropped, so the handshake stalls forever and the client is reset on the idle timeout. On-device counters confirmed the receive path was perfect (rx_enter=2 bytes=713 disp=1 msg=20, KEXINIT parsed + reply built n=422) while SSHEMIT enter=0: ssh_emit was never reached because the callback was null.
  • Why host tests missed it: every SSH test wires the emit callback itself (ssh_server_set_emit_cb(rec_emit) in test_ssh_server; ssh_conn_setup() in test_ssh_conn's setUp) and then drives ssh_server_dispatch / ssh_conn_rx directly. The mechanism was covered; the production wiring was not - a mock-seam blind spot.
  • Fix: ssh_proto_handler() (the one accessor every consumer goes through to install SSH) now calls ssh_conn_setup() before returning, so registering the handler always wires the emit callback - it can never be forgotten again. Regression guard test_proto_handler_wires_emit clears the callback, calls ssh_proto_handler(), drives a banner+KEXINIT, and asserts the server's reply reaches the socket (fails without the fix; verified). HW-verified end to end on an ESP32-S3 vs OpenSSH 10.0: curve25519-sha256 KEX, ssh-ed25519 host key, NEWKEYS, Authenticated ... using "password", and a byte-exact channel echo over chacha20-poly1305.
  • Lesson: a unit test that installs the production callback itself proves the mechanism but not that the mechanism is wired in production. Wire such one-time hookups at the single install seam (the handler accessor), not as a separate step a caller must remember.

Native build: transport unit envs fail to compile - freertos/task.h has no host mock

  • Status: FIXED (test infra, 2026-07-11).
  • Symptom: pio test -e native_ssh_conn (and every native env that compiles tcp.cpp) fails at the build stage: src/network_drivers/transport/tcp.cpp:31:10: fatal error: freertos/task.h: No such file or directory.
  • Root cause: the TLS tcpip-thread self-detection fix (babf01f4) added #include "freertos/task.h" (for xTaskGetCurrentTaskHandle()) to tcp.cpp unconditionally, but the host build resolves freertos/* through test/mocks/freertos/ and only FreeRTOS.h + queue.h were mocked - task.h was missing. Its symbols are used only inside #if defined(ARDUINO) code, so on the host the include just needs to resolve.
  • Fix: added test/mocks/freertos/task.h (typedef TaskHandle_t + an xTaskGetCurrentTaskHandle() stub), matching the existing host-mock pattern. All native transport/session/SSH envs build again.

HTTP/3: QUIC frame parser rejects standard post-handshake frames - real clients get FRAME_ENCODING_ERROR

  • Status: FIXED (library, 2026-07-11; found + fixed same day with an aioquic client on the PSRAM board).
  • Symptom: QUIC handshake: CONNECTED, then the device sends CONNECTION_CLOSE error_code=0x07 (FRAME_ENCODING_ERROR) and the h3 GET / times out. aioquic's event log: ConnectionTerminated(error_code=7, frame_type=0).
  • Root cause: quic_frame_parse() (quic_frame.cpp) only decodes PADDING, PING, HANDSHAKE_DONE, ACK, ACK_ECN, CRYPTO, STREAM (0x08-0x0f), MAX_DATA, and CONNECTION_CLOSE; for every other frame type it returns 0 (line ~122, "a frame type this minimal server does not handle"), and process_frames() turns a 0 into FRAME_ENCODING_ERROR + closes the connection (quic_conn.cpp:193). But a real QUIC client sends connection-management + flow-control frames right after the handshake - MAX_STREAMS (0x12/0x13), MAX_STREAM_DATA (0x11), NEW_CONNECTION_ID (0x18), NEW_TOKEN (0x07), DATA_BLOCKED (0x14), STREAM_DATA_BLOCKED (0x15), STREAMS_BLOCKED (0x16/0x17), RESET_STREAM (0x04), STOP_SENDING (0x05), RETIRE_CONNECTION_ID (0x19), PATH_CHALLENGE/PATH_RESPONSE (0x1a/0x1b) - so the very first 1-RTT packet carrying the h3 request also carries one of these and the whole packet is rejected. RFC 9000 requires an endpoint to be able to parse every defined frame type (it may ignore the ones it does not act on); returning FRAME_ENCODING_ERROR for a well-formed known frame is both a spec violation and an interop break with any real client.
  • Fix: quic_frame_parse() (+ named constants in quic_frame.h) now consumes (skips) all the standard frame types above with their correct varint/byte layout, so they parse successfully and the dispatcher ignores the ones with no server-side action (like it already does for MAX_DATA/PING). Grouped by wire shape (1/2/3 varints; length-prefixed NEW_TOKEN/NEW_CONNECTION_ID; fixed-width PATH_CHALLENGE/RESPONSE). HW-verified with aioquic: h3 GET / -> :status=200 (HeadersReceived + DataReceived, body served over QUIC). HTTP/3 device-as-server now works end to end.
  • Lesson: "a minimal server only parses what it acts on" is wrong for QUIC - the transport must parse the whole frame grammar even to ignore it, or the first real-client packet closes the connection.

HTTP/3: QUIC handshake crashes the device - worker-task stack too small for Ed25519 signing

  • Status: FIXED (library, found on hardware 2026-07-11 bringing up the HTTP/3/QUIC server on the PSRAM board; the first QUIC handshake panicked the board).
  • Symptom: h3_cert() + begin() come up (h3_cert=1, BEGIN=1) and the QUIC listener binds UDP/443, but the first client handshake reboots the board: Guru Meditation ... Core 1 panic'ed (Unhandled debug exception) - a task stack canary trip, no clean assert message.
  • Root cause (JTAG addr2line of the backtrace): the crash is deep in Ed25519 signing during the QUIC TLS-1.3 CertificateVerify: quic_server_poll -> quic_conn_recv -> quic_tls_recv_crypto -> process_client_hello -> tls13_build_cert_verify -> ssh_ed25519_sign -> ed_scalarbase/scalarmult/add -> ssh_gf_mul. The QUIC TLS-1.3 handshake reuses the SSH ed25519 signer, whose software field arithmetic peaks at ~10.5 KB of stack. quic_server_poll runs on the worker task, whose default stack is only 8 KB unless SSH is enabled - and there was a compile guard forcing >= 12 KB (DETWS_WORKER_STACK_CURVE_MIN) for DETWS_ENABLE_SSH but not for DETWS_ENABLE_HTTP3, even though HTTP/3 exercises the same signer. So an HTTP/3-without-SSH build got the 8 KB default and overflowed.
  • Fix: ServerConfig.h - DETWS_ENABLE_HTTP3 now bumps the default DETWS_WORKER_TASK_STACK to 12 KB (same as SSH) and is included in the DETWS_WORKER_STACK_CURVE_MIN build guard. After the fix the QUIC handshake completes (QUIC handshake: CONNECTED from an aioquic client) with no crash.
  • Lesson: a shared crypto primitive imposes its stack floor on every feature that reaches it - when a new caller (HTTP/3) reuses SSH's ed25519, it must inherit SSH's stack guard, not just the code. [[hw-testing-finds-integration-bugs]]

TLS: handshake crashes on a core-locking lwIP core (arduino 3.x / PSRAM) - <tt>tcp_write</tt> called without the core lock

  • Status: FIXED (library, found on hardware 2026-07-11 running HTTP/2-over-TLS on the rebuilt PSRAM core, IDF 5.5; the TLS handshake rebooted the board on every connection).
  • Symptom: the TLS server starts, but the first record flush during the handshake panics/reboots: assert failed: tcp_write ... "Required to lock TCPIP core functionality!". On the stock PlatformIO arduino-2.x core the same firmware handshakes fine.
  • Root cause: lwIP has two threading models and the framework picks one. Mailbox (arduino 2.x / IDF 4.x, CONFIG_LWIP_TCPIP_CORE_LOCKING off): tcpip_api_call marshals the op to one dedicated tcpip thread. Core-locking (arduino 3.x / IDF 5.x, the PSRAM core, flag on): tcpip_api_call instead takes the core lock and runs the op inline on the calling task. det_tcp_marshal's "am I already in a safe context, so run inline instead of marshaling" test was on_tcpip_thread() = a task-handle compare (captured on the first det_tcp_do). That is correct for the mailbox model, but under core-locking the captured "tcpip task" is just whichever task ran the first op, so the test false-positives for a normal caller (the handshake pump) and runs det_tcp_do -> tcp_write without holding the core lock -> the assert. (This path never worked on core-locking cores; it is why CI only compiles arduino 3.x. It surfaced now because the PSRAM core is the first core-locking build actually HW-run with TLS.)
  • Fix: tcp.cpp on_tcpip_thread() now branches on LWIP_TCPIP_CORE_LOCKING. Core-locking: use lwIP's own holder query sys_thread_tcpip(LWIP_CORE_LOCK_QUERY_HOLDER) (the exact predicate LWIP_ASSERT_CORE_LOCKED uses) - a direct lwIP call is safe iff we hold the lock. Mailbox: the original task-handle compare, byte-identical, so the shipped 2.x path cannot regress.
  • Verified: on the PSRAM/IDF-5.5 core the TLS handshake now completes and HTTP/2 (ALPN h2) is served: curl -k -> HTTP 200 ver=2, openssl -alpn h2 -> ALPN protocol: h2 (TLS 1.2), curl --http2 -> 200. Both static pools live in PSRAM (s_h2@0x3c0e0000, s_pool@0x3c0fcf30), internal DRAM 18%. This also fixes TLS on the stock arduino-esp32 3.x core (same core-locking model).
  • Lesson: "am I in a context where a direct lwIP call is safe" is a per-threading-model question - detect the model, don't assume a dedicated tcpip thread exists. [[no-spaghetti-piping]] [[hw-testing-finds-integration-bugs]]

TLS: device HARD-HANGS on a TLS 1.3-leading ClientHello (tcpip_thread self-deadlock in the close path)

  • Status: FIXED (library, found on hardware 2026-07-11 bringing up the TLS device-as-server interop peer against an ESP32-S3; a real 1.3-leading client from curl/OpenSSL/Python wedged the whole board).
  • Symptom: a forced-TLS-1.2 client handshakes and serves 200 OK fine, but a default client (which leads with TLS 1.3) leaves the device completely dead - no ping, all ports closed, no panic and no watchdog reboot (a silent hard hang, not a crash). A single attempt could wedge it.
  • Root cause (caught live over JTAG/GDB): the lwIP tcpip_thread self-deadlocks. A raw lwIP sent callback (lowlevel_sent_cb, which runs in tcpip_thread) finalizes a closing slot -> det_tls_conn_end() -> mbedtls_ssl_close_notify() -> server_bio_send() -> det_conn_raw_send() -> det_tcp_marshal(RAWSEND) -> tcpip_api_call() -> sys_arch_sem_wait() forever: it marshals a raw write onto tcpip_thread and blocks on the mailbox semaphore that only tcpip_thread can post - but the caller is tcpip_thread. The reentrancy guard TransportCtx::in_tcpip_thread was set only inside det_tcp_do(), so raw lwIP callbacks (which never enter through det_tcp_do) read it as false and wrongly re-marshal. (UDP got this right - its flag is set in the recv trampoline; TCP missed the raw callbacks.) Every task that then touches lwIP (the worker's det_conn_detach) also blocks on the dead tcpip_thread, so the whole stack dies.
  • Fix: tcp.cpp - replace the "inside `det_tcp_do`" boolean with the actual tcpip_thread task handle (captured the first time det_tcp_do runs) and compare xTaskGetCurrentTaskHandle() against it in on_tcpip_thread(). det_tcp_marshal() now owns the context decision for every op: run det_tcp_do inline when already in tcpip_thread (any raw callback), else tcpip_api_call. This is correct for raw callbacks that in_tcpip_thread never covered and kills the whole deadlock class, not just close_notify.
  • Lesson: a reentrancy flag that only some entry points set is a latent self-deadlock; detect the thread by identity (task handle), not by "did I come through my own dispatcher." Mock-seam host tests can't see this - it only appears with a real TLS client on hardware. [[no-spaghetti-piping]] [[hw-testing-finds-integration-bugs]]

TLS: a modern (TLS 1.3) ClientHello is refused because it exceeds the 1024 B RX ring

  • Status: FIXED (library, found on hardware 2026-07-11, same bring-up; distinct from the deadlock above).
  • Symptom (after the deadlock fix): the board no longer hangs but still RSTs any 1.3-leading ClientHello (curl -> HTTP 000, OpenSSL default -> handshake fails, read 0 bytes) while a -no_tls1_3 / max-1.2 client succeeds. OpenSSL reported writing a 1533-byte ClientHello.
  • Root cause: a modern TLS 1.3 ClientHello (key shares + cipher/sig-alg lists + RFC 7685 padding) is ~1.5 KB and arrives in one TCP segment larger than the whole RX_BUF_SIZE (1024 B) ring. The recv callback refuses a segment that will not fit the ring (ERR_MEM, lossless backpressure), so a ClientHello bigger than the ring is refused forever and the handshake stalls until the idle-timeout reaper RSTs it. A 1.2-only ClientHello is small enough to fit, which is why it squeaked through. The ring was smaller than a single MSS segment - a latent limit a big handshake exposes (SSH's ~1.5 KB KEXINIT already had the same auto-upsize).
  • Fix: ServerConfig.h - when DETWS_ENABLE_TLS and RX_BUF_SIZE was left at its default, upsize it to 2048 (mirrors the existing SSH KEXINIT upsize). An explicit RX_BUF_SIZE build flag is honored. After both fixes: curl -> 200, interop peer 7/7 (TLS 1.2 ECDHE-ECDSA-AES256-GCM-SHA384), 20/20 default handshakes negotiate 1.2 with the board staying alive, 10/10 rapid curls 200.
  • Lesson: the RX ring must hold at least one full MSS segment; a handshake whose first flight is one big segment (TLS ClientHello, SSH KEXINIT) will otherwise be refused-forever by all-or-nothing segment backpressure. [[stress-test-before-ship]]

Rig route table overflowed MAX_ROUTES (16) - silently dropped /ws, /events, /secure, /syslog/probe

  • Status: FIXED (rig-firmware config, found on hardware 2026-07-11 while adding the syslog /syslog/probe route - it 404'd even though the new firmware was live, since its /bench field was present).
  • Root cause: DetWebServer has a fixed flat route table of MAX_ROUTES (default 16); server.on() / on_ws() / on_sse() / dav() each consume one slot and return false (silently) when the table is full - the app does not check the return. The rig had grown to 20 registrations (dav + 17 handlers + ws + sse), so the four registered after slot 16 (/syslog/probe, /secure, /ws, /events) never took effect. The overflow first occurred at the FTP tick (when the count crossed 16), silently disabling the rig's WebSocket + SSE + auth surface from then on (those dims were covered in earlier ticks, so no false coverage was claimed, but the rig had regressed).
  • Fix: rig platformio.ini -DMAX_ROUTES=28 (test-firmware config; not a library change). Verified on the rig: /syslog/probe now registers (7/7 interop), and /secure -> 401 + /events -> 200 are back.
  • Lesson: this is not a library defect (the fixed table + false-on-full is the documented O(MAX_ROUTES) design), but server.on()'s return value is worth checking in apps that register many routes. Whenever a new rig route 404s while a fresh /bench field proves the new firmware is live, suspect the route table is full before anything else. Watch the count as more device-as-client probes accumulate.
  • Sequel (2026-07-11, NTP tick): the same class bit the UDP side - DETWS_MAX_UDP_LISTENERS defaults to 2 (CoAP 5683 + SNMP 161 filled it), so ntp_server_begin()'s det_udp_listen(123) returned false and the rig serial printed NTP=bind-failed. Fixed with -DDETWS_MAX_UDP_LISTENERS=4. Same rule: fixed pools that fail closed need their cap raised as the rig accretes protocols - check UDP listeners too, not just the HTTP route table.

syslog MSG CR/LF passthrough (log-forging) - AUDITED, INFO-level (caller's responsibility, spec-permitted)

  • Status: NOT A LIBRARY BUG (audited 2026-07-11 via the new syslog_injection attack; recorded INFO).
  • Observed: syslog_format() copies the caller's MSG verbatim into the RFC 5424 line (... - - - s), so a message containing CR/LF/control bytes is emitted as-is. At a collector that splits a stream/file on newlines this enables log forging (CWE-117): the attack sent legit\r\n<34>1 - evil ... FORGED-RECORD and the collector received it verbatim in one datagram.
  • Why it is INFO, not a finding: over UDP (RFC 5426) one datagram = one syslog message, and RFC 5424 §6.4 permits any characters in MSG, so a compliant receiver treats the whole datagram as one MSG and does not split on \n - the passthrough does not break a compliant peer or violate the spec. CWE-117 output neutralization is the responsibility of the code that logs untrusted data (the caller), which is why the attack records it as INFO. The device stayed up and the fixed DETWS_SYSLOG_MSG_MAX (256 B) bound held (a 2 KB message was refused: syslog_format -> 0, no datagram, largest observed datagram 80 B).
  • Optional hardening (deferred, not done): syslog_format could replace control bytes in MSG with a safe char for defense-in-depth (many production syslog clients do). Left as caller responsibility per the library's "the app owns the log content" model; revisit if a user wants built-in sanitization.
  • Sequel (2026-07-11, statsd tick): the same class applies to the StatsD client - statsd_format copies the metric name verbatim, and StatsD packs multiple \n-separated metrics per UDP packet, so a newline in the name forges extra metrics at a compliant collector (and a : / | corrupts the name:value|type split). Recorded INFO by the statsd_injection attack; same verdict - the caller sanitizes the metric name. The fixed DETWS_STATSD_LINE_MAX (256 B) bound holds (a 2 KB name is dropped, largest observed datagram 51 B), so there is no over-read - only the app-level forging concern.

Outbound-client-path first-touch heap drop (FTP + SMTP) - INVESTIGATED, NOT A BUG (one-time warmup)

  • Status: NOT A BUG (investigated on hardware 2026-07-11 while adding the ftp_malicious_server and smtp_malicious_server attacks - both showed the identical signature).
  • Found: the first-ever run of each device-as-client attack against a freshly-booted S3 rig flagged the heap-drift oracle: free heap fell ~1344-2016 B (e.g. FTP 131836 -> 130044; SMTP 131356 -> 130012) and stayed down past the 6 s settle, which the oracle reports as a determinism-promise violation ("no heap allocation after begin()").
  • Why it is not a leak: the drop is a one-time lazy warmup of the outbound-client TCP path (det_client_* -> lwIP), which begin() never exercises - the server listener path is warm at boot, but the first outbound connect+send (and, for SMTP, the first rapid connect+error+close churn) grows lwIP's TX pbuf / working-set backing once. Proof it is bounded, not monotonic: (1) for FTP, 8 more failed-open /ftp/probe
    • 8 more /redis/probe connections dropped nothing further (plateaued at 130044); (2) the second and third identical attack runs were clean - 0 findings, heap steady within a ~4 B jitter (FTP 130044 -> 130044; SMTP 130012 -> 130008 -> 130012). A real per-connection leak would fall by another ~1.5 KB each run. The library's own code allocates nothing after begin(); the one-time growth is inside esp-idf/lwIP pools and is capped.
  • Lesson: the heap-drift oracle's first-touch false-positive fires the first time any never-before-used code path runs after begin() (here the outbound-client path). Distinguish warmup from a leak by re-running: warmup plateaus immediately and the second run is clean; a leak is monotonic. The MQTT/Redis device-as-client probes were judged clean earlier only because their path was already warm by the time the oracle sampled. (SMTP's warmup fired even after the interop happy-path had run, because the attack's error/close churn hits a slightly different lwIP allocation - so re-run per attack, not just per code path.)

SSE teardown slot leak wedges the whole HTTP server (sse_free never called)

  • Status: FIXED (found on hardware 2026-07-11 by the pentest rig's new sse_exhaustion attack).
  • Found: pentesting/detws_pentest.py --only sse_exhaustion against the S3 rig (pentesting/rig_firmware, pinned espressif32@6.13.0, MAX_CONNS=4, MAX_SSE_CONNS=2). A single clean run on a freshly-booted, healthy board (heap 252636) drove the server permanently unresponsive: /health -> 56 then 28 (timeout), no recovery past CONN_TIMEOUT_MS (5 s), and no crash/reboot (serial clean) - a hard wedge.
  • Symptom: one burst of GET /events connections beyond MAX_SSE_CONNS and the device stops answering every endpoint, forever, without rebooting (so no watchdog catches it). A remotely-triggerable permanent DoS.
  • Root cause (JTAG-confirmed): sse_free() had zero callers - dead code. WebSocket teardown is wired (ws_free() in dwserver.cpp handle loop), but SSE had no equivalent, so a closed / idle-reaped / aborted SSE stream never released its sse_pool entry. An SSE upgrade also leaves the slot as ConnProto::PROTO_HTTP (SSE is a long-lived HTTP response, not a protocol switch), so the leaked binding persists on the HTTP slot. The kill step is in http_poll_slot(): if (sse_find(i)) return; skips HTTP dispatch for a slot it believes is a live SSE stream. Once sse_pool is full of leaked entries (slot_id 0,1), any new HTTP connection reusing conn slot 0 or 1 matches the stale sse_find() and is silently never dispatched -> the client hangs. Live JTAG on a wedged board: sse_pool[0]+sse_pool[1] both active (paths /events), conn_pool[1] already CONN_FREE (stale binding to a freed slot), a GET /health curl sat unparsed in a slot's rx ring, and conn_pool[0].last_activity_ms climbed 163048 -> 423695 across two halts (each hung-then-retried connection refreshed it), so the idle sweep never reaped it. loopTask/worker/tcpip_thread all alive.
  • Fix: src/network_drivers/presentation/presentation.cpp - new http_release_upgrade_bindings(slot) calls ws_free(slot) + sse_free(slot) (both no-ops when unbound). Invoked from http_evt_close() (FIN/RST/error on an SSE or WS slot frees its binding) and http_conn_open() (a reused slot must not inherit a stale binding - covers the idle-sweep / abort free paths that never fire a close event). Verified on the rig: the exact sse_exhaustion burst that permanently wedged the board now recovers (/health -> 200 within ~5 s); native test_sse (+2 regression tests) and test_presentation green.
  • Lesson: every presentation-layer binding (WS/SSE) needs a teardown wired to every transport free path, not just the graceful protocol-close path; clean up stale bindings at slot reuse to cover the direct-free paths (idle sweep, abort) that never emit a close event. Also: the liveness oracle must settle-recheck so a permanent wedge (stays down) is distinguished from a connection-holding attack's transient pool saturation (recovers) - the same discriminator that proved the fix.

Basic-auth password compared with strcmp: NUL-truncating + not constant-time

  • Status: FIXED (found on hardware 2026-07-11 by the pentest rig's auth_bypass attack).
  • Found: pentesting/detws_pentest.py --only auth_bypass against the S3 rig. Sending Authorization: Basic base64("admin:admin\x00GARBAGE") returned 200 on the protected route.
  • Symptom: a submitted password of the form correct-password + \0 + junk authenticated, because check_basic_auth compared the password with strcmp(pass, r->auth_pass) and strcmp stops at the first embedded NUL - so "admin\0GARBAGE" compared equal to "admin".
  • Severity: LOW as a direct exploit (the attacker still has to submit the correct password prefix, so it grants no access they could not already get), but a real robustness + timing-side-channel weakness: strcmp/memcmp early-out, leaking via response timing how many leading credential bytes matched, and the NUL truncation means the decoded credential length was not validated.
  • Root cause: src/server/auth.cpp check_basic_auth used memcmp for the username (length-checked, ok) but strcmp for the password, and never bounded the password to its decoded byte length.
  • Fix: compute the real password byte length (plen = n - (pass - decoded)) and compare BOTH fields with a new constant-time, length-bounded ct_equal() - so an embedded NUL cannot truncate the compare and the byte loop always runs to completion (no timing early-out). Verified: native test_auth/test_digest_auth green (261), and on the rig the null-truncation vector now returns 401.
  • Lesson: never compare a secret with strcmp/memcmp - use a length-bounded constant-time equality. A password check must be validated against its actual byte length, not a C-string terminator.

Connection pool wedges (no recovery) under a saturation + RST-race flood - was masked by the crash

  • Status: FIXED (library) - the remediation is implemented at src/network_drivers/transport/tcp.cpp lowlevel_recv_cb: the idle-timer refresh (slot->last_activity_ms = detws_millis()) now sits after the backpressure check, and the refused-segment branch explicitly does not refresh (it returns ERR_MEM without touching the timer), so a no-progress connection idle-times-out and is reaped. HW re-attack with the pentest rig (http_conn_saturation + oversized lines, then confirm the pool recovers past CONN_TIMEOUT_MS) is the remaining validation.
  • Found: re-attacking the S3 rig after the stale-pcb crash fix below. http_conn_saturation + a burst of oversized requests closed with SO_LINGER=0 (RST) leaves the device pingable but not serving HTTP (curl -> 000), and it does not recover on its own past CONN_TIMEOUT_MS (5 s) - only a reset brings the server back. A fresh boot serves normally (/health -> 200), so the fix does not break normal HTTP.
  • Why it surfaced now: the stale-pcb crash used to reboot and self-heal this exact wedge; fixing the crash removed that accidental recovery, exposing that the 4-slot pool's slots do not free under abnormal (RST-race / half-open saturation) teardown. Fixing one bug uncovered the one it was hiding.
  • Root cause (JTAG-confirmed): the idle-timeout timestamp is refreshed on raw recv activity instead of accepted-data progress. lowlevel_recv_cb set slot->last_activity_ms = detws_millis() before the backpressure check. An oversized request line (or any body larger than RX_BUF_SIZE) fills the RX ring; the segment is refused (ERR_MEM, kept as lwIP refused_data) and redelivered every retransmit. Each redelivery refreshed last_activity_ms, so check_timeouts always saw the slot as recently active and never reaped it. Live JTAG breakpoint at the reap check on a wedged slot: now=810066, last_act=806956, diff=3110 < timeout=5000 (and last_act climbing 358768 -> 806956 across samples = being refreshed). Four such slots leak and permanently wedge the 4-slot pool. A Slowloris-class DoS.
  • Fix: move the last_activity_ms refresh in lowlevel_recv_cb to after the backpressure check, so a refused/redelivered segment does not refresh the idle timer - only data actually accepted into the ring (real progress) does. A no-progress connection now idle-times-out and is reaped; a legitimately backpressured connection the worker is draining still refreshes on each accepted segment.
  • Lesson: (1) a crash that reboots can mask a resource-leak DoS - fix crashes, then re-attack to find what the reboot was hiding. (2) an idle/liveness timer must be driven by progress, not raw I/O events: refreshing on a refused/retransmitted segment lets a stalled peer hold a slot forever.

Oversized request line / connection saturation reboots the device (tcp_output on a stale pcb)

  • Status: FIXED (library) - the remediation is implemented in src/network_drivers/transport/tcp.cpp det_tcp_do: pcb_still_bound() (and the O(1) k->pcb == conn_pool[k->slot].pcb check for the slot-carrying SEND/OUTPUT ops) re-validates the captured pcb at execution time on tcpip_thread; a stale pcb skips with ERR_CLSD instead of calling tcp_write/tcp_output on freed memory. HW re-attack with the pentest rig (http_oversized_request_line + http_conn_saturation, confirm no panic + no heap drift) is the remaining validation.
  • Found: pentesting/detws_pentest.py --host <rig> --diag (attacks http_oversized_request_line + http_conn_saturation) against the ESP32-S3 rig firmware (pentesting/rig_firmware, pinned espressif32@6.13.0, MAX_CONNS=4). Reproduced standalone with ~15 oversized-request-line connections.
  • Symptom: the device panics and reboots. Serial: assert failed: tcp_output .../lwip/src/core/tcp_out.c:1249 (tcp_output: invalid pcb) + backtrace + rst:0xc (RTC_SW_CPU_RST). The determinism oracle also saw free heap drift down (251904 -> 245136) before the crash, and legitimate requests hang while the pool is saturated.
  • Root cause: src/network_drivers/transport/tcp.cpp:232, det_tcp_do() (the tcpip_api_call marshalled raw-lwIP op) calls tcp_output(k->pcb) - and tcp_write(k->pcb, ...) for SEND/RAWSEND - without re-validating that k->pcb is still live. The worker captures the pcb, then marshals the op to tcpip_thread; in between, the connection can be torn down (RST / lwIP error callback / close nulls conn_pool[slot].pcb), leaving k->pcb stale. tcp_output on the freed/closed pcb trips lwIP's assertion and panics. Symbolized via addr2line on the -g rig firmware.elf: 0x420074c6 = det_tcp_do (tcp.cpp:232) -> tcp_output (tcp_out.c:1251) -> __assert_func.
  • Fix: (implemented) re-validate the pcb at execution time inside det_tcp_do for the SEND / RAWSEND / OUTPUT ops - skip with ERR_CLSD when the captured pcb is no longer the slot's live pcb (pcb_still_bound() scans the pool for RAWSEND, whose slot is 0; SEND/OUTPUT do the O(1) k->pcb == conn_pool[k->slot].pcb compare). Both reads are on tcpip_thread, where teardown also runs, so the compare is race-free. HW re-attack over JTAG to confirm the crash is gone is pending.
  • Lesson: a marshalled/deferred raw-lwIP op MUST re-validate its pcb at execution time - the pcb it captured on another thread may have been freed by teardown before the op runs. Capturing a raw pointer across the worker -> tcpip_thread hop without a liveness re-check is a use-after-free waiting to happen.

FTP command emitters used additive length checks that could wrap (buffer-overflow risk)

  • Status: FIXED (native_ftp: 16 cases pass, byte-identical output; the change is behavior-preserving under the codec's own invariant and only hardens the helper against a hostile length).
  • Found: 2026-07-10, SonarCloud flagged the additive bound in ftp_emit.
  • Symptom: none in practice - every in-tree caller passes a strnlen(_, cap)-bounded length, so the sum never wrapped. The risk is latent: ftp_emit/ftp_emit_uint/ftp_finish are general helpers that take a raw size_t length, and a future caller passing a near-SIZE_MAX length would wrap the check.
  • Root cause: the room check was written as n + slen > cap. With n and slen both size_t, a huge slen makes n + slen overflow to a small value that passes the check, after which memcpy(buf + n, s, slen) writes past buf.
  • Fix: rewrote all three bounds as overflow-safe subtraction (slen > cap - n, ri > cap - n, n >= cap). The codec keeps the invariant n <= cap on every non-sentinel return, so cap - n can never underflow; the checks are now provably safe for any length.
  • Lesson: never bound a write with offset + len > cap when len is (or could become) untrusted - use the subtraction form len > cap - offset and keep the offset <= cap invariant that makes it safe.

server.listen() returned DETWS_OK instead of the listener id (port-forward never matched)

  • Status: FIXED (HW: the relay/DNAT example now forwards a file byte-exact through the ESP32; native_app + native_relay pass with the corrected return).
  • Found: 2026-07-10, on hardware, testing 70.PortForward against a real HTTP origin.
  • Symptom: a connection to the published front port was accepted then RST with nothing relayed; relay_on_accept's bind lookup found no bind, so it closed the connection. The origin never saw the request. No handler output at all, which made it look like the event was dropped.
  • Root cause: DetWebServer::listen() returned DETWS_OK - which is 1, not 0 - but the relay example (and relay_listener.h's own docs) treat the return as the listener id passed to det_relay_publish(). begin() assigns the actual listener index (0 for the only listener), so the bind was stored under id 1 while the accepted slot carried id 0; bind_by_listener() missed.
  • Fix: listen() now returns the listener id (its index, _listener_count - 1) on success; errors stay negative. Updated the two tests that asserted == DETWS_OK and the header doc.
  • Lesson: an API whose documented use is "pass the return to publish()" must return that id, not a generic success sentinel - and DETWS_OK == 1 turned it into an off-by-one that only bit when the listener was not index 1.

SMB / DNC / relay / SMTP / SSH-forward could never connect (client transport stubbed out)

  • Status: FIXED (HW: an ESP32-S3 SMB client now connects to a real Samba server - det_client_open returns a valid slot instead of -1). Found by hardware testing; the host tests cannot catch it because they drive the engines through a mock send/recv seam, not the real det_client transport.
  • Found: 2026-07-10, first on-hardware run of the 68.SmbFileClient example.
  • Symptom: every outbound connection from an SMB (also DNC, relay/DNAT, SMTP, or SSH port-forward) build failed - det_client_open() returned -1 on the very first call, so the feature silently never talked to its peer on device.
  • Root cause: client.cpp compiles the real transport only under #if defined(ARDUINO) && DETWS_NEED_DET_CLIENT, else it falls through to a host stub whose det_client_open returns -1. DETWS_NEED_DET_CLIENT was derived from only HTTP_CLIENT || MQTT || WS_CLIENT, omitting every newer feature that drives det_client: the direct callers (relay, smtp, ssh port-forward) and the seam-based engines whose shipped example binds the seam to det_client (smb, dnc). An SMB-only firmware got the stub.
  • Fix: added DETWS_ENABLE_RELAY || DETWS_ENABLE_SMTP || DETWS_SSH_PORT_FORWARD || DETWS_ENABLE_SMB || DETWS_ENABLE_DNC to the DETWS_NEED_DET_CLIENT derivation (which also force-enables the shared DNS resolver), so any feature that needs the outbound transport pulls it in.
  • Lesson: a "needs X" derived flag must list EVERY consumer, including features that reach the transport only through an example's seam binding; and a stub that returns an error instead of failing to link hides the omission until a board is on the bench.

SMB client crashed the ESP32 during NTLMv2 auth (smb_open stack overflow)

  • Status: FIXED (HW: the S3 read a file byte-exact - the fnv1a matched the server - after the buffers moved off the stack; native_smb host tests still pass).
  • Found: 2026-07-10, on hardware, right after the det_client fix let smb_open reach the real Samba NTLMv2 exchange.
  • Symptom: "Guru Meditation Error ... Stack canary watchpoint triggered (loopTask)" inside hmac_md5 <- ntlm_ntowfv2 <- smb_open, in a boot loop. Host tests never saw it (the native stack is large and unguarded).
  • Root cause: smb_open declared ~4 KB of working buffers on the stack (tx + rx = 2*DETWS_SMB_BUF, plus nt_resp + ntauth + sp2 + utf16 = 4*(DETWS_SMB_BUF/2)); smb_read/smb_write each add 2*DETWS_SMB_BUF. With the caller's frame this overran the default 8 KB Arduino loopTask stack, tripping the canary during the deep NTLMv2 call chain.
  • Fix: moved the large working buffers into one owned, feature-gated SmbClientCtx static (matching the library's owner-context pattern), leaving only small locals on the stack. The SMB dialogue is sequential (open -> read/write -> close) so a single shared working set is correct; documented as not reentrant across two concurrent SMB connections.
  • Lesson: protocol clients with multi-KB working buffers must own them statically, not stack-allocate them - default embedded task stacks are ~8 KB and host tests will not reveal the overflow.

Multipart parser truncated binary parts (strstr on a length-tracked binary body)

  • Status: FIXED (native_app test_binary_part_not_truncated + all multipart cases pass; native_pentest 38/38 clean under ASan + UBSan with the rewritten scan). Previously documented as a "known limitation"; it is really a data-integrity bug and is now fixed.
  • Found: 2026-07-10, reviewing the KNOWN_LIMITATIONS entry "a binary part containing the boundary bytes is truncated".
  • Symptom: a multipart/form-data file upload whose body contains a NUL byte, or the raw boundary token (e.g. --BND) inside the payload, was truncated - the part's data_len stopped at the first NUL or the first boundary-looking bytes, corrupting binary uploads (images, firmware, ...).
  • Root cause: multipart_parse scanned the body with strstr (strstr(body, delim) / strstr(pos, "\r\n")), which (1) stops at the first NUL even though HttpReq::body is a byte buffer with an explicit body_len, and (2) matched the bare --boundary bytes anywhere, so a payload that merely contained those bytes (without the framing CRLF) was treated as a delimiter.
  • Fix: rewrote the scan to be length-bounded over body_len with a binary-safe mem_find (memcmp, no NUL stop) and to match the full RFC 2046 \r\n--boundary delimiter for the data sections, so only a true CRLF--boundary ends a part. The in-place NUL terminator is kept as a convenience for text parts; binary parts are read via part->data + part->data_len.
  • Lesson: never strstr/strlen-scan a buffer that is length-tracked and may hold binary - use a length-bounded memcmp search; and match the full framed delimiter (CRLF--boundary), not the bare token, so payload bytes can never masquerade as a boundary.

Signed-overflow UB + <tt>10^exponent</tt> DoS in the remaining hand-rolled number parsers

  • Status: FIXED (native_pentest 35/35 clean under ASan + UBSan -fno-sanitize-recover=all, including new det_strtof + GraphQL fuzz targets that feed huge exponents / integer literals; native_jwt 22/22 and native_exc_decoder 7/7 clean under UBSan). Completes the sweep started by the previous entry.
  • Found: 2026-07-10, continuing the signed-v*10 audit. Five more sites; two carried a second, worse bug: an exponent parsed into an int then applied as for (k = 0; k < ex; k++) m *= 10.0 - a huge exponent (e.g. GraphQL 1e999999999) is both signed-overflow UB and a denial-of-service (billions of iterations hang the device).
  • Sites + fix:
    • shared_primitives/numparse.h det_strtof and services/graphql/graphql.cpp (query number literal): the exponent overflowed and its 10^ex loop was unbounded - clamped the exponent (if (ex < 400) - 10^400 saturates the double to inf anyway), fixing UB + DoS. GraphQL's integer literal (long long ipart) also overflowed - now unsigned-accumulate.
    • services/jwt/jwt.cpp jwt_claim_int (untrusted numeric claim) and services/exc_decoder/exc_decoder.cpp (crash-dump core id): the same signed v*10 - fixed by unsigned-accumulate + reinterpret (jwt) / clamp (exc_decoder).
  • Not a bug: network_drivers/network/ip.cpp:54 matched the grep but is bounded - the if (digits >= 3) return false guard caps the octet at 3 digits (<= 999), so val*10 never overflows. Left as-is.
  • Lesson: the same as the previous entry, plus: an exponent applied by a 10^ex loop is a DoS vector independent of the overflow - always clamp a parsed exponent to the type's real range.

Signed-integer-overflow UB in three untrusted-input number parsers

  • Status: FIXED (native_pentest now passes clean under ASan + UBSan with -fno-sanitize-recover=all: 33/33; the directly-affected suites - native_det_primitives, native_redis, native_snmp, native_http_client - all still green).
  • Found: 2026-07-09, by the extended pentest fuzzer. Adding OPC UA Binary fuzz targets and running the built binary directly under -fno-sanitize-recover=all (so UBSan aborts instead of just printing) surfaced three latent undefined-behavior sites that earlier runs had been printing and ignoring. All three are hit by feeding a parser a very long / hostile digit string.
  • Sites + root cause:
    • services/snmp/snmp_ber.cpp ber_read_integer: sign-extended the BER INTEGER by seeding a signed long v = -1 and then v = (v << 8) | byte - left-shifting a negative signed value is UB (C++ < 20).
    • shared_primitives/numparse.h det_strtol: v = v * 10 + digit on a signed long - signed overflow is UB once the digits exceed LONG_MAX (the unsigned det_strtoul was already safe).
    • services/redis_resp.cpp RESP integer + double parsers: the same v = v * 10 + digit on a signed int64_t (the integer) and an unbounded int exp accumulator (the exponent).
  • Fix: accumulate in unsigned and reinterpret with sign (BER integer, det_strtol, RESP integer - neg ? (T)(0 - uv) : (T)uv, which also avoids the negate-MIN UB), and clamp the RESP double exponent (if (exp < 1000000) - a larger exponent saturates the double to inf/0 anyway). All fixes are value-identical for in-range inputs, so no test changed.
  • Lesson: a hand-rolled v = v*10 + digit (or a neg_seed << 8) on a signed accumulator is UB the moment an attacker supplies enough digits; parse untrusted numbers into an unsigned type and reinterpret, or clamp. The fuzzer only caught these once it ran the sanitized binary with -fno-sanitize-recover=all - printing-but-not-failing UBSan output had hidden them.

DNC decoder ate the EIA digit <tt>3</tt> (0x13) as an XOFF flow-control byte

  • Status: FIXED (caught pre-ship by the codec's own encode -> decode round-trip test; never released). native_dnc 13/13; the program "M30" now round-trips through the EIA code intact.
  • Found: 2026-07-09, on the first run of test_roundtrip_program for the new CNC DNC codec (services/dnc): the EIA round-trip of M30 came back as M0 - the 3 vanished.
  • Symptom: decoding an EIA-coded program silently dropped every digit 3; any block containing a 3 was corrupted.
  • Root cause: the block decoder (dnc_decode_feed) filtered XON/XOFF (DC1 0x11 / DC3 0x13) out of the byte stream as flow control - but in the EIA RS-244 tape code the data character 3 is 0x13. Filtering it from the forward program stream deleted the digit. The design conflated two different channels: the forward program data (sender -> controller, what the decoder reassembles) and the reverse flow-control channel (controller -> sender, XON/XOFF). They are opposite directions of a full-duplex link and must not share a filter.
  • Fix: removed the XON/XOFF filtering from dnc_decode_feed entirely; the decoder now decodes the forward stream faithfully (0x13 = the data byte 3). Flow control lives only in dnc_flow_feed, which the caller drives from the reverse channel's bytes. Added test_decode_eia_three_is_not_xoff as a regression guard.
  • Lesson: an in-band control code (ASCII DC3) can collide with a data code in a different character set (EIA 3); never filter control bytes out of a data stream that may legitimately contain that byte value. Keep flow control on its own channel.

Owner-context grouping anchored the server TLS config + worker queue store (Arduino 3.x DRAM)

  • Status: FIXED (25.WebSocketClient builds on the arduino-esp32 3.x core - 37% DRAM, was 408 bytes over; 03.HTTPS/04.mTLS/06.TlsResumption/07.SecureWebSocket + 01.SSH still build; native_ssh / native_crypto_kat 154/154).
  • Found: 2026-07-07, after the scratch-arena fix cleared the ESP32 (PIO 2.x) build and 4 of the 5 Arduino (3.x) failures. The last, 25.WebSocketClient (a TLS WebSocket client), still overflowed dram0_0_seg by 408 bytes - but only on the arduino-esp32 3.x core, whose larger core footprint leaves less headroom than the 2.x core the PlatformIO job uses.
  • Symptom: an outbound-only WebSocket client linked ~900 bytes of server-only state it never uses: the linked-map/cref diff (base vs HEAD) showed tls.cpp +600, worker.cpp +160, listener.cpp +145. Two more instances of the same gc-liveness trap as the scratch arena.
  • Root cause: small always-referenced fields grouped into large owned Ctx symbols, so --gc-sections (per-symbol) could not drop the big cold parts:
    • TlsServerCtx bundled a 1-byte ready flag with the ~600-byte mbedTLS server config/cert/key/ ticket. det_tls_ready() (called on the client path) reads ready, anchoring the whole server config into a client that never runs det_tls_configure().
    • DeferCtx bundled the per-worker FreeRTOS queue handles (hot; detws_defer() pushes to them) with the multi-hundred-byte static queue storage (only detws_workers_start() touches it), so the storage stayed linked in a build that never starts workers.
  • Fix: split each into a hot symbol + a cold symbol - TlsServerReadyCtx s_srv_ready apart from TlsServerCtx s_srv; DeferStorageCtx s_defer_store apart from DeferCtx s_defer. The cold halves are now referenced only by server-setup / worker-start code and garbage-collect out of client-only firmwares. Server examples that do use them are unchanged (03.HTTPS DRAM identical). Both new types end in Ctx, so the owner-context guard still passes. (listener.cpp +145 was left: the two splits already reclaimed far more than the 408-byte deficit.)
  • Lesson (reinforced): verify on the tightest target - the arduino-esp32 3.x core overflows where the 2.x core has room. Keep large conditionally-used buffers in their own owned symbol, separate from small always-referenced fields. See the scratch-arena entry below for the first instance.

Owner-context grouping anchored the 8 KB scratch arena, overflowing TLS-example DRAM

  • Status: FIXED (all four TLS examples - 03.HTTPS, 04.mTLS, 06.TlsResumption, 07.SecureWebSocket - build for esp32dev again; native_ssh 146/146 and the 01.SSH scratch-consumer build still pass).
  • Found: 2026-07-06, RCA'ing why ESP32 Build / Arduino Build were red. Last green at f7767ba6, first red at 23e0797b - both owner-context-refactor commits.
  • Symptom: the four largest TLS examples failed to link: .dram0.bss will not fit in region dram0_0_seg, overflowed by 944-1264 bytes. A per-object .o size diff showed only +62 bytes of DRAM growth across the whole tree, which could not explain a ~1 KB overflow.
  • Root cause: a --gc-sections liveness change, invisible to a compiled-size diff (the object is compiled in both trees; only its linkage differs). The scratch owner-sweep merged three separate symbols - the 8 KB per-worker bump arena, the off[] offsets, and high_water[] - into one struct ScratchCtx s_scratch. session.cpp calls scratch_reset() every dispatch, touching only off[]; but off[] and the arena were now one symbol/section, and --gc-sections is per-section, so that always-live reference anchored the whole 8 KB. Previously the arena was its own symbol, referenced only by scratch_alloc(), which is dead code in a plain TLS/HTTP build (its callers are SSH / WebSocket / OIDC) - so the linker dropped it. The linked-map diff was unambiguous: scratch.cpp contributed 8 bytes of DRAM at f7767ba6 and 8228 bytes at HEAD. That +8 KB tipped the already DRAM-marginal TLS examples (~122 KB dram0_0_seg ceiling) over.
  • Fix: split the arena back into its own owned instance (struct ScratchArenaCtx s_scratch_arena), keeping only the small off[]/high_water[] metadata in ScratchCtx. scratch_alloc() is the sole referrer of the arena, so a firmware that never allocates scratch garbage-collects both again; the always-live scratch_reset() anchors only the tiny metadata. Both are *Ctx types, so the owner-context guard still passes. Semantics are byte-for-byte unchanged.
  • Lesson: when consolidating globals into one owned Ctx, keep a large conditionally-used buffer in a separate owned symbol from small always-referenced fields, or --gc-sections can no longer drop the buffer from builds that never use it. Measure regressions with the linked map, not a compiled .o size sum.

Ed25519 verify accepted non-canonical S (signature malleability)

  • Status: FIXED (native_crypto_kat green: the Wycheproof SignatureMalleability vectors now reject; native_ssh_ed25519 RFC 8032 7.1 regression still passes).
  • Found: 2026-07-06, standing up the data-driven external crypto KAT env (native_crypto_kat) that runs Project Wycheproof vectors through the primitives. Wycheproof ed25519 tcId 63-70 (SignatureMalleability) verified when they must be rejected.
  • Symptom: ssh_ed25519_verify accepted a signature whose scalar S had been replaced by S + L (L = the group order). Because L*B is the identity, S*B - h*A recomputes the same R, so the recompute-and-compare-R verification passed for both S and S + L - i.e. a third party could maul a valid signature into a different byte string that still verifies.
  • Root cause: verification checked the group equation but never range-checked S. RFC 8032 5.1.7 requires the verifier to reject a signature whose S is not in [0, L); that check was missing.
  • Fix: added ed_scalar_canonical() (compares the little-endian S against the group order ED_L from the top byte down) and reject up front in ssh_ed25519_verify when S >= L. Verification is public-data only, so a plain compare is fine. Legitimate signatures always have S < L (signing reduces mod L), so no valid vector or existing test changes.

54 shipped features were missing from the feature grid (FEATURES.md drift)

  • Status: FIXED (gen_feature_tables.py coverage guard green; all DETWS_ENABLE_* flags now documented).
  • Found: 2026-07-06, answering "did all the features make it into docs/FEATURES.md?".
  • Symptom: 54 opt-in features had a DETWS_ENABLE_* flag, a src/services/* implementation, and tests, but no ## heading in docs/FEATURES.md - so they were absent from the README/docs feature tables too (those tables are generated from FEATURES.md). The whole industrial-protocol wave (HART, GOOSE, MMS, PROFINET, PROFIBUS, J2735, NTCIP, OpenADR, ...), HTTP/3, and several infra features (Failsafe, Sleep Scheduler, Wear Leveling, Network Adaptation, PSRAM Pool, Themes, ...) had shipped without ever being listed.
  • Root cause: FEATURES.md is hand-maintained and nothing enforced that every feature flag has an entry. gen_feature_tables.py guarded the README tables against drift from FEATURES.md, but not FEATURES.md against drift from the config header, so shipped features silently never reached the grid (the same failure mode as the "silently lost 28 features" the generator docstring already warned of).
  • Fix: backfilled all 54 entries (descriptions extracted verbatim from each flag's config-header doc comment) into FEATURES.md, mapped them to their OSI layer, regenerated the tables, and added a coverage guard to gen_feature_tables.py: it fails (in the Feature Tables CI job) if any DETWS_ENABLE_* flag lacks a FEATURES.md entry, excluding a small allowlist of internal derived flags (STREAM_BODY, CLIENT_TLS). Docs-only; no library code changed.

HTTP/3 TLS flight append ignored its buffer cap (potential <tt>flight_hs</tt> overflow)

  • Status: FIXED (native_quic_tls / native_tls13_msg / native_h3_e2e green; flagged by SonarCloud cpp:S3519 as two BLOCKER "memory copy overflows the destination buffer" findings).
  • Found: 2026-07-06, reviewing the SonarCloud quality gate on the HTTP/3 handshake code.
  • Symptom: the TLS 1.3 server flight is built message-by-message into a fixed qt->flight_hs buffer, each builder called with sizeof(flight_hs) - flight_hs_len as its capacity. emit() was handed that same capacity but ignored it ((void)cap;) and did *plen += written unconditionally.
  • Root cause: nothing bounded flight_hs_len against sizeof(flight_hs). In the correct flow each builder returns <= cap, so the sum stayed in bounds, but the invariant was never enforced: if a builder ever returned more than the remaining room, a later builder's sizeof(flight_hs) - flight_hs_len would underflow to a huge cap and w_bytes() would memcpy past the fixed array.
  • Fix: emit() now honors the cap contract it was given - it refuses an append that would run past the buffer (written > cap - *plen), maintaining the *plen <= cap invariant so the next builder's capacity subtraction can never underflow. w_bytes() also now checks w->pos > w->cap explicitly before the w->cap - w->pos bound so that subtraction provably cannot underflow even for a malformed Writer. Separately, the HTTP/3 DATA-frame coalescing in dispatch_request() now clamps with a room subtraction (sizeof(body) - body_len) instead of a body_len + take sum that could wrap. All three are the codebase's own overflow-safe idiom.

QUIC anti-amplification checked after building, desyncing the flight under loss

  • Status: FIXED (RPi netem loss interop + native_quic_conn; found while adding PTO loss recovery).
  • Found: 2026-07-06, driving the HTTP/3 interop harness under 10-20% netem packet loss - a timer-polled server got worse with loss, not better, and connections stalled.
  • Symptom: under loss, quic_conn_send() advanced packet-number / CRYPTO-offset / stream-send state for a datagram it then discarded, so the retransmitted flight no longer matched what the peer had (or had not) received. Loss recovery made the stall worse instead of curing it.
  • Root cause: the 3x anti-amplification check (RFC 9000 sec 8.1) ran after build_packet() had already bumped next_pn / crypto_tx_off / tx_sent / last_ae_pn. When the send was then amplification-blocked and dropped, that state stayed advanced - a build-then-discard desync.
  • Fix: move the amplification check to the top of quic_conn_send(), before any packet is built, so a blocked send advances no packet state. Also reset the PTO backoff on acknowledged progress (RFC 9002 sec 6.2) so a recovering connection does not keep doubling its probe interval.

SSH outbound wire buffer under-sized for a near-max payload + long MAC

  • Status: FIXED (native SSH suites green; found while adding s2c compression, which made the overflow reachable in the general case).
  • Found: 2026-07-05, sizing the wire buffer for compression - the existing sizing did not cover the worst case even without compression.
  • Root cause: ssh_conn.cpp sized every outbound wire buffer as SSH_PKT_BUF_SIZE + SSH_HMAC_SHA256_LEN (= 2080). A payload approaching SSH_PKT_BUF_SIZE with the hmac-sha2-512 MAC (64-byte tag) needs 4 + (1 + payload + pad) + 64 ≈ 2128 bytes, so ssh_pkt_send() would hit its wire_len > out_cap guard and return -1, dropping the packet. Latent because real payloads (channel-data chunks bounded by the peer window) never approached the max. With s2c compression the effective payload can also expand slightly (fixed-Huffman on incompressible data), and a dropped packet mid-stream desyncs the stateful cipher / compression stream (session corruption), so the under-size had to be fixed properly rather than relied upon to never trigger.
  • Fix: a single SSH_WIRE_CAP in ssh_packet.h sized for the true worst case - 4 + 1 + SSH_MAX_EFFECTIVE_PAYLOAD + SSH_MAX_PAD(32) + SSH_MAX_MAC(64), where the effective payload grows to ssh_deflate_bound(SSH_PKT_BUF_SIZE) when DETWS_ENABLE_SSH_ZLIB is set. All four wire buffers in ssh_conn.cpp use it. Correct for every cipher/MAC mode, compressed or not.

UDP transport called raw lwIP off tcpip_thread (sibling of the listener bug)

  • Status: FIXED (HW-validated on an ESP32-S3: the SNMP agent binds UDP/161 and runs; before, the same udp_bind from the app task would have asserted like the listener did).
  • Found: 2026-07-04, immediately after the listener fix below - auditing for the same root cause in the UDP path, since UDP services bind at begin() from the app task too.
  • Root cause: udp.cpp (det_udp_*, the single place lwIP UDP is touched) called raw udp_new / udp_bind / udp_recv / udp_remove / udp_sendto directly from the app task, never marshaled. On arduino-esp32 3.x (lwIP core-locking) that trips LWIP_ASSERT_CORE_LOCKED, so every UDP service - SNMP agent (:161), CoAP, the captive-portal DNS responder (:53), syslog, UDP telemetry, flow-export - would crash at begin() / on send. Same latency story as the listener: harmless on the IDF-4.x board all runtime HW used, and CI only compiles the 3.x core. (The TCP transport det_tcp_do and the outbound client cc_do_* were already marshaled; UDP was the remaining raw path.)
  • Fix: marshal the UDP ops onto tcpip_thread via tcpip_api_call() (a udp_do dispatcher for listen / send / send-out), mirroring the TCP transport - including the s_in_tcpip_thread guard so a handler replying from the udp_recv trampoline (already in-thread) sends directly instead of re-marshaling (which would deadlock). All det_udp_* callers are unchanged; the native host stubs are untouched. Native UDP-service suites green (native_coap / native_snmp / native_dns_resolver / native_syslog); HW: SNMP agent binds :161 and the device runs on the modern core.
  • Prevention: every raw lwIP tcp_* / udp_* from outside a lwIP callback now goes through tcpip_api_call(); the transport layer is the one owner of that rule (listener, TCP conn, UDP, client all marshal). See the listener entry below for the runtime-test-the-3.x-core lesson.

Listener bring-up called raw lwIP off tcpip_thread, asserting on arduino-esp32 3.x

  • Status: FIXED (HW-validated on an ESP32-S3: begin_tls() now starts the HTTPS listener and the server runs, where before it crash-looped at boot).
  • Found: 2026-07-04, first on-hardware run of any listener on the arduino-esp32 3.x core (IDF 5.x) - surfaced while HW-proving the PSRAM TLS arena. The board booted, joined WiFi, then panicked in begin_tls(): assert failed: tcp_alloc ...lwip/src/core/tcp.c:1854 (Required to lock TCPIP core functionality!), rebooting in a loop.
  • Root cause: listener_add() / listener_stop() (the path begin() and begin_tls() use) called raw lwIP tcp_new_ip_type / tcp_bind / tcp_listen_with_backlog / tcp_close directly from the app (setup/loop) task, which is not tcpip_thread and does not hold the TCPIP core lock. arduino-esp32 3.x ships CONFIG_LWIP_TCPIP_CORE_LOCKING=1 + CONFIG_LWIP_CHECK_THREAD_SAFETY=1, so LWIP_ASSERT_CORE_LOCKED fires. It was latent because (1) all prior runtime HW testing used the classic ESP32 via PlatformIO (arduino 2.x / IDF 4.x), where the assert is compiled out and the unlocked call merely raced, and (2) the "Arduino Build" CI only compiles the 3.x core, never runs it. The dynamic listener (SSH ssh -R) already marshaled correctly via tcpip_api_call(); the primary listener was the one unmarshaled path, written under a stale "this build has core-locking off" assumption.
  • Fix: listener_add() / listener_stop() now route their create/close through the same listener_lwip_marshal() (tcpip_api_call) the dynamic listener uses, on ARDUINO. The raw path stays only for the native host build (no lwIP thread). This is correct on both cores: with core-locking it satisfies the lock; without it, it still removes the off-thread race. The stale comment was corrected.
  • Prevention: HW test the modern (arduino-esp32 3.x) core at runtime, not only compile it - the core-locking assert only fires when the code actually runs. Any new raw lwIP tcp_* from a non-callback context must go through tcpip_api_call() (transport det_tcp_do and the client cc_do_* were already correct; the listener now matches).

SSH curve25519/ed25519 handshake overflowed the 8 KB worker task stack

  • Status: FIXED (HW-validated on an ESP32-S3; the modern-crypto handshake completes with the raised default and ~1.8 KB of stack margin).
  • Found: 2026-07-03, first on-hardware connect to the new curve25519-sha256 + ssh-ed25519 suite: the client reached expecting SSH2_MSG_KEX_ECDH_REPLY then Connection reset by peer, and the serial log showed Guru Meditation Error: ... Stack canary watchpoint triggered (detws_worker).
  • Root cause: the software field arithmetic for curve25519 (ssh_x25519) and ed25519 (ssh_ed25519_sign), in radix-2^16 with many ssh_gf temporaries per frame, nests deeper than the finite-field DH/RSA path - plus the field inversion calls the mbedTLS bignum modexp at the bottom of that chain. Measured peak worker-task stack use is ~10.5 KB (uxTaskGetStackHighWaterMark: a 16 KB stack left 5928 B free at the deepest point), which overflows the historical 8 KB worker default sized for the ~7 KB RSA path.
  • Fix: DETWS_WORKER_TASK_STACK now defaults to 12288 when DETWS_ENABLE_SSH is set (8192 otherwise), a new DETWS_WORKER_STACK_CURVE_MIN (12288) documents the floor, and the compile-time guard enforces it for SSH builds (the RSA-only floor still applies to OIDC-only builds). Re-flashed at the shipped 12288 default: the curve25519 + ssh-ed25519 + ed25519-client-auth session runs to a data round-trip with 1832 B of stack free at peak, no canary trip; the DH-group14 + rsa-sha2-256 path is unchanged (both echo on hardware).
  • Prevention: the worker stack is now sized from a measured high-water mark, not a guess, and a build-time #error catches any future lowering below the curve floor before it can reach the canary at runtime.

SSH answered SSH_MSG_GLOBAL_REQUEST with UNIMPLEMENTED instead of REQUEST_FAILURE

  • Status: FIXED (native_ssh 127/127; the dispatcher now routes GLOBAL_REQUEST to a dedicated handler).
  • Found: 2026-07-03, implementing ssh -R remote forwarding (which arrives as a tcpip-forward global request).
  • Root cause: the SSH message dispatcher had no case for SSH_MSG_GLOBAL_REQUEST (80), so every connection-wide request fell through to the default arm and got SSH_MSG_UNIMPLEMENTED (RFC 4253 §11.4). That is wrong: GLOBAL_REQUEST is a known message type - only the request name may be unknown - and RFC 4254 §4 says an unrecognized request is answered with SSH_MSG_REQUEST_FAILURE when want_reply is set, or ignored otherwise. In practice this broke OpenSSH client keepalives (keepalive@openssh.com, want_reply=true): the client saw an UNIMPLEMENTED rather than a SUCCESS/FAILURE reply and could treat the keepalive as unanswered, and benign want_reply=false requests (hostkeys-00@openssh.com, no-more-sessions@openssh.com) drew a spurious UNIMPLEMENTED.
  • Fix: ssh_global_request_handle() (ssh_channel) now parses the request name + want_reply, replies REQUEST_FAILURE (or stays silent) per §4, and routes tcpip-forward / cancel-tcpip-forward to an opt-in remote-forward seam (accepted only when an owner is installed; a port-0 bind echoes its allocated port per §7.1). Host-tested (test_ssh_channel: accept / refuse / port-0 echo / no-reply / cancel / unknown-request / malformed).
  • Prevention: the seam is the plug-in point for the ssh -R listener + byte-bridge owner (the next phase); until it is installed the server correctly declines forwards rather than making a promise it cannot keep.

Abuse-prevention state keyed on a 32-bit hash of the IPv6 source address (security)

  • Status: FIXED (transport now carries the full family-tagged address; all IP-keyed features match the whole IPv4/IPv6 address; native_det_ip / native_accept_gate / native_auth_lockout / native green with added v6 coverage).
  • Found: 2026-07-03, reviewing the IPv6 phase-2 work. Shipped in v4.87.0 (auth lockout) and v4.88.0 (per-IP throttle) - both superseded by this fix.
  • Root cause: when IPv6 arrived, the per-IP throttle, the auth-lockout table, and the accept-time source key all reduced the peer address to a 32-bit value: a raw v4 word, and for v6 a 32-bit FNV-1a hash (det_ip_key / det_conn_remote_key / det_lwip_ip_key). In a security-forward library that is a real vulnerability, not a shortcut:
    • Targeted lockout / throttle poisoning. A 32-bit hash is trivially collidable, so an attacker can pick a v6 source that hashes to the same bucket as a victim and drive the victim into lockout or throttle denial (a remote DoS of a specific peer).
    • Per-IP cap evasion. A v6 attacker owns a whole /64 (2^64 addresses); collisions let many real addresses share one bucket (reset/share another's budget) or dodge their own cap.
    • Cross-family confusion. Flattening a v6 peer toward a v4-shaped key risks it colliding with a real v4 host's bucket.
  • Fix: the transport pipe was made protocol-agnostic. DetIp (a family tag + 16 address bytes, the library's sockaddr_storage/lwIP ip_addr_t equivalent) is now carried end to end: det_conn_remote_addr() / det_lwip_to_detip() produce it, and every IP-keyed feature stores and matches the full address - bucket lookups by det_ip_equal (throttle, lockout), allowlist by det_ip_prefix_match (real v4 /0-32 and v6 /0-128 CIDR containment). The hash/word keys (det_ip_key, det_conn_remote_key, det_lwip_ip_key) and the host-order DETWS_IPV4 allowlist macro are deleted; the public allowlist entry point is now CIDR text (listener_ip_allow_add_cidr("2001:db8::/32")). No hashing, no flattening.
  • Prevention: the native suites now assert the security property directly - distinct v6 peers get distinct throttle/lockout buckets, a v6 peer never shares a v4 peer's state, and a v4 allowlist rule never admits a v6 peer (and vice versa). One owner (the transport) resolves the peer address; features consume DetIp and never re-key it.

06.PreemptQueue example used the pre-3.0 Arduino-ESP32 timer API

  • Status: FIXED (compiles on esp32:esp32 3.3.10; still builds on the 2.x core the PlatformIO CI pins).
  • Found: 2026-07-02, the all-example compile sweep against the 3.x core (the second break it caught, after ESP-NOW).
  • Root cause: Arduino-ESP32 3.0 reworked the timer API - timerBegin(freq) is now frequency-based (was timerBegin(num, divider, countUp)), timerAttachInterrupt() dropped its edge argument, and timerAlarm() replaced timerAlarmWrite() + timerAlarmEnable(). The example's ISR setup used the 2.x forms, so it failed to compile under the core an Arduino IDE user installs.
  • Fix: the timer block in 06.PreemptQueue.ino is guarded with #if ESP_ARDUINO_VERSION >= ESP_ARDUINO_VERSION_VAL(3, 0, 0) and uses the 3.x calls on the new core, the 2.x calls otherwise. It is the only example using that API (grep-verified).
  • Prevention: a new Arduino Build CI (.github/workflows/arduino-build.yml) now compiles every example against the Arduino-ESP32 3.x core with arduino-cli - using each example's shipped build_opt.h, exactly as the IDE builds it - so 3.x / IDF-5 API drift is caught on every push, not just in a manual local sweep.

ESP-NOW would not compile on the Arduino-ESP32 3.x core (ESP-IDF 5 recv-callback ABI)

  • Status: FIXED (compiles clean on esp32:esp32 3.3.10 via arduino-cli; native_espnow host suite still 7/7).
  • Found: 2026-07-02, first compile of 53.EspNow in the Arduino IDE toolchain (esp32 core 3.x). The PlatformIO CI pins espressif32 @ ^6.0.0 (Arduino-ESP32 2.x, ESP-IDF 4.4), so it never exercised the 3.x API and reported green - the break only showed up under the core an Arduino IDE user actually installs.
  • Root cause: ESP-IDF 5.0 changed the esp_now_recv_cb_t signature. The receive callback used the 4.x shape (const uint8_t *mac, const uint8_t *data, int len); under IDF 5 the source MAC moved into a struct and the type is now (const esp_now_recv_info_t *info, const uint8_t *data, int len), so registering the old callback was an invalid conversion (-fpermissive error).
  • Fix: services/espnow/espnow.cpp selects the callback signature with an #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(5, 0, 0) guard (mirrors how ssh_rsa handles mbedTLS v2/v3) and reads the MAC from info->src_addr on 5.x, the mac argument on 4.x. Both cores now build. The whole binding is under #ifdef ARDUINO, so host tests are unaffected.
  • Prevention: all examples now build unmodified in the Arduino IDE (each ships a build_opt.h) and were compile-swept against the 3.x core; a broad multi-feature 3.x compile found no other IDF-5 API breaks.

DMA frames corrupted (~8%) when the completion was posted to the work queue as a pointer

  • Status: FIXED (HW-verified on an ESP32 DevKitV1: 2.2M+ frames ingested with zero integrity errors under concurrent HTTP stress, no heap growth).
  • Found: 2026-07-02, first HW stress of the new DMA ingest path (services/dma). A combined webserver + continuous-DMA rig reported dma_errors: 8824 out of ~95946 frames (~8%) via an HTTP counter - while all 11 host tests passed. Textbook "a small happy-path smoke hides the bug; stress on hardware surfaces it."
  • Root cause: the DMA-complete callback posted the det_dma_event whose data is a pointer into the 2-deep ping-pong RX buffer into the 16-deep preempting work queue. detws_pq_post_from_isr() calls portYIELD_FROM_ISR(), but the simulator drives the callback from a task context (not a real ISR), where the yield is not an immediate context switch - so under load loop() ran several more completions before the high-priority task drained the queue, the two ping-pong buffers wrapped, and the older queued pointers read half-overwritten data. A descriptor consumed by another task must own its bytes, not point into a buffer that is reused a transfer or two later.
  • Fix: the callback now copies the frame bytes into the queue item (a self-contained message) instead of posting the pointer. Eliminates the dangling reference entirely (error rate 8% -> 0%). The example 07.DmaIngest uses the same copy pattern, and dma.h now documents that a deferred (queued) consumer must copy the len bytes rather than keep the RX pointer. The pointer API itself is unchanged and correct for an in-callback consumer (the standard DMA-HAL shape).
  • Tests: the host suite (native_dma) covers the ping-pong flip, byte-exact loopback round trip, and fail-closed paths; the deferred-consumer lifetime is a HW-load property, now covered by the on-device stress rig.

SSH server could not interoperate with a stock OpenSSH client

  • Status: FIXED (host-tested + HW-verified on an ESP32 against OpenSSH 9.5: ssh, pubkey auth, and ssh -L port forwarding all work with no client-side algorithm overrides).
  • Found: 2026-06-30, first live ssh against the server once a host key was provisioned. A stock ssh user@board reset during key exchange; even forcing the KEX through, RSA pubkey auth was refused. The earlier "handshake HW-verified" had only ever used a hand-built minimal client (the native test) and a tiny forced-algorithm client.
  • Two independent root causes, both fixed:
    • The RX ring and the KEXINIT store were smaller than a real client's first flight. A modern OpenSSH KEXINIT (post-quantum + curve KEX names, cert host-key algs, EtM MACs, ext-info-c) is ~1.5 KB. SSH_KEXINIT_MAX was 512, so ssh_kexinit_parse() rejected the payload outright (len > SSH_KEXINIT_MAX -> dispatch returns -1 -> RST), and the default 1024-byte RX_BUF_SIZE ring could not hold the banner + KEXINIT, resetting the handshake at key exchange. Fixed by raising SSH_KEXINIT_MAX to 2048 (client I_C; the server I_S stays small at SSH_KEXINIT_S_MAX) and auto-upsizing RX_BUF_SIZE to >= 2048 when SSH is enabled and the ring was left at its default (same idiom as the streaming-body upsize; an explicit RX_BUF_SIZE is honored).
    • No RFC 8308 ext-info / server-sig-algs. Without it a modern OpenSSH client will not sign an RSA key (send_pubkey_test: no mutual signature algorithm) and falls back to password. Fixed by advertising ext-info-s in the server KEXINIT, detecting the client's ext-info-c, and sending SSH_MSG_EXT_INFO with server-sig-algs = rsa-sha2-256 as the first message after NEWKEYS. An inbound EXT_INFO is now accepted (ignored) rather than answered with UNIMPLEMENTED.
  • Tests: test_ssh_server gains test_extinfo_build_advertises_server_sig_algs and test_large_client_kexinit_accepted; the full-handshake test now sends ext-info-c and asserts the server replies with EXT_INFO. (Curve25519 KEX + Ed25519 keys, which would let the client use its preferred algorithms instead of falling back to group14/rsa, are tracked separately on the roadmap.)

SSH channel close packed CHANNEL_EOF + CHANNEL_CLOSE into one binary packet

  • Status: FIXED (host-tested; test_ssh_server).
  • Found: 2026-06-30, while building the SSH port-forwarding (direct-tcpip) close path on top of the channel layer.
  • Root cause: build_close_chan() (ssh_channel.cpp) frames the channel-close sequence as ten bytes - CHANNEL_EOF (type + recipient, bytes 0..4) immediately followed by CHANNEL_CLOSE (type + recipient, bytes 5..9) - in one output buffer, and the only emit site (the SSH_MSG_CHANNEL_CLOSE case in ssh_server.cpp) sent the whole buffer through a single ssh_pkt_send(), i.e. two SSH messages in one binary packet. RFC 4253 6 says a binary packet carries exactly one message; a strict client (openssh runs packet_check_eom() after each message handler) sees five trailing bytes after CHANNEL_EOF and disconnects with a packet-integrity error, so a channel could not be closed cleanly against openssh. It slipped earlier HW checks because those exercised the handshake + channel data, not a strict close.
  • Fix: emit the two halves as two packets - emit(buf, 5) then emit(buf + 5, 5) - so each is framed, encrypted, and sequence-numbered on its own. The builder is unchanged (its 10-byte layout is still the contract); the fix is at the emit boundary, with a comment so it is not re-bundled. Regression test test_inbound_close_emits_eof_then_close_separately asserts the dispatcher emits two packets (EOF then CLOSE).

SonarCloud static-analysis sweep: response-header over-read, crypto zeroization, and friends

  • Status: FIXED (first SonarCloud C/C++ scan; host-tested).
  • Found: 2026-06-30, the initial SonarCloud analysis (8 BLOCKER, 7 bug, 7 vulnerability findings; the ~2169 "code smell" results are mostly modern-C++ style rules that clash with this deliberately terse C++11 / zero-heap embedded style and are not defects).
  • Real issues fixed:
    • **Dynamic-response header over-read on truncation (cpp:S3519, dwserver.cpp).** append_resp_trailer() returned snprintf's would-be length, which can exceed the header buffer when the trailer (Date + CORS + user _extra_hdr cookies/headers + Connection) does not fit. The caller then sent (u16_t)hlen bytes from a fixed header[RESP_HDR_BUF_SIZE] - reading past the stack buffer. Fixed by clamping append_resp_trailer()'s return into [0, cap-1] so no send path (send / send_empty / redirect / send_template / send_chunked) can ever read past the buffer; an over-long header is now sent truncated-but-in-bounds.
    • **Crypto key material not securely zeroed (cpp:S5798 x5, services/snmp/snmp_crypto.cpp).** The memset()s that wipe the localized key, AES round keys, key stream, and CFB feedback at function exit can be elided by the optimizer (dead store on a buffer that is never read again), leaving secrets on the stack. Replaced with snmp_wipe(), a volatile-loop clear the compiler cannot remove (same idiom as ssh_wipe).
    • **Uninitialized byte fed to the parser if the ring drains mid-read (cpp:S836 x2, presentation.cpp, websocket.cpp).** The drain loops ignored det_conn_read_byte()'s return and fed byte to the parser even though a false return means nothing was read. Now they break when the read fails.
    • **conn_pool[slot_id] indexed without a bound (cpp:S3519).** The public response emitters dereferenced conn_pool[slot_id] on a caller-supplied id; added a slot_id >= MAX_CONNS guard at each entry (send / send_empty / redirect / send_template / send_chunked).
    • Misleading constructs: a (cond) ? 4 : 4 ternary (cpp:S3923, nats.cpp) reduced to 4; a while that always broke on the first iteration (cpp:S1751, http_parser.cpp Forwarded proto=) rewritten as the if it actually is; and a redundant if (!app(...)) return len; return len; (cpp:S3923, webdav.cpp) simplified to app(...); return len; (the helper is atomic - it leaves len unchanged on no-room).
  • Assessed and intentionally kept (false positives, marked // NOSONAR with a reason): cpp:S5332 "using HTTP is insecure" on http_client.cpp (a URL parser in an HTTP client must accept http://; TLS is the caller's choice) and opcua.cpp (the OPC UA spec transport-profile URI http://opcfoundation.org/..., an identifier string that is never dereferenced as a URL). The 3 remaining cpp:S134/long-switch style notes are correct protocol-dispatch switches, left as-is.

WebSocket / SSE upgrade-alloc-fail detached the pcb but never closed it (leak)

  • Status: FIXED (during the L7 teardown-ownership refactor; host-tested + HW-soaked).
  • Found: 2026-06-30, auditing the hand-rolled connection teardown sites while routing them through one transport-owned API.
  • When a WebSocket or SSE upgrade had already been promoted but the per-protocol slot pool was full (ws_alloc / sse_alloc returned null), the cleanup path called det_conn_detach(pcb) and reset the slot but never closed or aborted the pcb.** The struct tcp_pcb was left open with no lwIP callbacks attached: a leaked pcb that lingered until lwIP's own timeout, with the application slot already freed. Under upgrade churn against an exhausted pool this slowly bled pcbs.
  • **Fix: the teardown is now owned by the transport. det_conn_abort_slot(slot) frees the TLS context (abrupt), detaches the pcb, resets the slot, and sends a RST; every drop path (WS/SSE close + upgrade-fail, session.cpp tls_abort, SSH/telnet/modbus/opcua) routes through it (or det_conn_close(slot) for graceful), so a pcb can no longer be detached without also being closed. Verified on hardware (COM3): a WS-pool-exhaustion churn drove 12 det_conn_abort_slot RSTs with every slot reclaimed and no pcb/slot leak (the device kept accepting throughout). Tracked-debt item closed in TODO.md.

Test-report generator spewed "tail: write error: Broken pipe" into the CI log

  • Status: FIXED (host-verified against all 1906 test functions; behavior identical).
  • Found: 2026-06-30, reading the CI Test Report run log: the report was written fine but the job output was flooded with dozens of tail: write error: Broken pipe lines.
  • **get_test_comment in test/run_tests.sh extracted a test's doc comment with** ‘tail -n "+${fn_line}" "$abs_file" | head -20 | awk ’...'**.** Theawkexits at the first match (print; exit) andhead -20closes its input after 20 lines, so once the reader is gone GNUtailgetsEPIPEon its next write and printstail: write error: Broken pipeto stderr. The helper runs once per test function (~1900 calls in a full run), so the CI log filled with the message. Not a correctness bug (the report still generated), but noise that hides real warnings.
  • **Fix:** fold the slice + scan into a singleawk -v start="$fn_line"pass that reads the file directly (NR < startskips,NR >= start + 20exits), so the earlyexitno longer closes a pipe and nothing writes into a dead reader. Verified behavior-identical to the old pipeline across everyvoid test_*()intest/test_*/` (1906 functions, 0 diffs, 0 stderr).

Host Link frame builder did not NUL-terminate, so callers read uninitialized memory

  • Status: FIXED (found by the header-shim migration verification; host-tested).
  • Found: 2026-06-29, running the full native suite on WSL after migrating the source tree to the new shared_primitives/shim.h include. native_hostlink was the lone failure out of 103 envs.
  • **hostlink_build (services/hostlink/hostlink.cpp) wrote exactly the wire frame (@UU XX <text> FF * CR) and returned its length, but never wrote a NUL terminator, while the sibling ASCII builder sdi12_build does (it reserves n + 1 and writes ‘buf[n] = ’\0').** NUL-terminating the ASCII frame is the house convention, so a caller (and the test) that treats the returned buffer as a C-string reads off the end into uninitialized stack. It stayed latent because the stack byte after the frame happened to be zero; the shim migration shifted the binary layout and the byte was now garbage, soTEST_ASSERT_EQUAL_STRINGsaw @00RD0000001057*\rfollowed by junk and the test crashed (SIGHUP/ERRORED).
  • **Fix:** makehostlink_buildconsistent withsdi12_build- reserve room for the terminator (if (total >= cap) return 0, wrap-safe: no addition) and writebuf[p] = '\0'after the frame. The return value is still the frame length (not counting the NUL), so callers may treat bufas either a sized buffer or a C-string. The existingnative_hostlinktests now pass 7/7; the overflow test (char small[8]`) still fails closed.

Two more 32-bit length-wrap bounds bypasses: SNMP BER decoder + HTTP chunked decode

  • Status: FIXED (follow-up bug-hunt; host-tested, incl. a regression that crashes the pre-fix code even on a 64-bit host).
  • Found: 2026-06-29, a second bug-hunt sweeping the wrap class across the parsers the first pass did not cover.
  • The same 32-bit size_t wrap as the codec entry below, in two network-facing parsers the first pass missed:
    • SNMP BER decoder (ber_read_header, services/snmp/snmp_ber.cpp).** A long-form ASN.1 length is read as a full 4-octet value (up to 0xFFFFFFFF), then checked with d->pos + length_val > d->len. On the 32-bit ESP32 an attacker-supplied length of 0xFFFFFFFF makes d->pos + length_val wrap to d->pos - 1 (< d->len), so the bound is bypassed and a bogus huge length is returned; downstream ber_read_oid's i < len loop then reads out of bounds. SNMP is an attacker-reachable UDP agent. ber_skip had the same pattern. Fix: compare against the remaining capacity (length_val > d->len - d->pos, valid because d->pos <= d->len holds after the count-byte check).
    • **HTTP client chunked decode (http_client_parse_response, services/http_client/http_client.cpp).** The hex chunk size csz is accumulated unbounded from the response, then clamped with if (in + csz > len). A malicious / MITM'd server sending an oversized chunk size makes in + csz wrap below len, skipping the clamp and leaving a multi-gigabyte memmove. Fix: if (csz > len - in) (wrap-safe; in <= len here).
  • The 64-bit host never wrapped on the 4-octet BER case, so it stayed latent; the HTTP regression uses a 16-hex-digit size (0xFFFFFFFFFFFFFFFF) that wraps a 64-bit size_t too, so it crashes the old code on the host and proves the fix. Tests added to test_snmp_ber and test_http_client.
  • Related hardening (same sweep): audited the rest of the wrap class clean - opcua (r_skip/ua_r_string lengths are int32, capped at 0x7FFFFFFF, and all callers guard > 0), protobuf (PB_WT_LEN compares in uint64_t; varints reject > 10 bytes), lwm2m (24-bit length bounded), and wamp_get_uri (body + 1 > out_cap) are all correctly bounded.


Codec length fields could wrap the bounds check on a 32-bit target

  • Status: FIXED (multi-agent codebase audit; host-tested).
  • Found: 2026-06-29, the codec bug-hunt audit of the v4.x protocol codecs.
  • Three text/binary parsers computed overhead + declared_length before comparing to the buffer length, which wraps on a 32-bit size_t (the ESP32 target) for an attacker-controlled length field, so the > len guard could falsely pass and hand the caller an out-of-bounds slice. Affected: amqp_parse_frame (AMQP 0-9-1 32-bit size, services/amqp/amqp.cpp), nats_parse MSG byte count (services/nats/nats.cpp), and resp_parse $ bulk length (services/redis_resp.cpp). The 64-bit host tests never exercised the wrap, so it was latent. Fix: in each, compare the declared length against the remaining capacity (len - overhead) without adding, so no addition can wrap. Added oversized-length rejection tests to test_amqp / test_nats / test_redis_resp.
  • Related hardening in the same pass: stomp content-length parse now rejects on overflow AND a present-but-invalid content-length is a malformed frame (previously it silently fell back to NUL-delimited body parsing - a request-smuggling-style differential); parse_len caps at SIZE_MAX. flow_export IPFIX finish now fails closed when the message exceeds the 16-bit length field instead of truncating it. Tests added/updated accordingly.


client.cpp failed to compile on a server-only Arduino build

  • Status: FIXED (found while building the interop rig; HW build verified on COM3).
  • Found: 2026-06-29, bringing up the real-protocol interop harness (test/servers).
  • A server-only Arduino build (no HTTP client / MQTT / WS client) did not compile (build break). client.cpp guarded its body with only #if defined(ARDUINO) yet calls detws_dns_resolve(), whose declaration lives behind DETWS_ENABLE_DNS_RESOLVER. That flag is force-enabled only by a client transport (HTTP_CLIENT || MQTT || WS_CLIENT, ServerConfig.h), so a build that enabled, e.g., WebSocket + SNMP + CoAP + Modbus but no client left the symbol undeclared: ‘error: 'detws_dns_resolve’ was not declared in this scope. Host builds were unaffected (the body is already#if defined(ARDUINO)), so the native suites never caught it. Fix: addDETWS_NEED_DET_CLIENT(set alongside the DNS-resolver force-enable) and gate the translation unit on #if defined(ARDUINO) && DETWS_NEED_DET_CLIENT`, so det_client compiles exactly when a client transport needs it. Verified: a WebSocket+SNMP+CoAP+Modbus rig now builds and flashes, and the interop harness drives all four against real peers.

Standards-conformance audit, batch 2e (LOW/SHOULD closeout)

  • Status: FIXED (standards-conformance audit; host + HW-verified on COM3)
  • Found: 2026-06-29, multi-agent conformance audit (the batch-2b "Still OPEN" list).
  • Closes the remaining LOW/SHOULD items so the audit is 100% addressed.
  • WebSocket handshake accepted a malformed upgrade (LOW, RFC 6455 4.2.1). The upgrade test did not require an upgrade token in the Connection header, nor that Sec-WebSocket-Key base64-decode to 16 bytes. Both are now required; a bad handshake gets 400 and no upgrade. Tests: test_web_terminal test_ws_upgrade_requires_connection_token, test_ws_upgrade_rejects_bad_key_length.
  • MQTT topic validation (LOW, MQTT-3.3.2-2 / 1.5.3). mqtt_build_publish now rejects a Topic Name containing wildcards (+/#); mqtt_parse_publish rejects a Topic Name that is not well-formed UTF-8 or contains U+0000. The UTF-8 validator was extracted to a shared primitive (shared_primitives/utf8.h) and reused by WebSocket (no duplicate copy). Tests: test_mqtt test_publish_wildcard_topic_rejected, test_publish_topic_nul_or_bad_utf8_rejected.
  • base64url decoder was lenient (LOW, RFC 4648 5 / RFC 7515). base64url_decode also accepted the standard +// alphabet, contradicting its name and the JWS base64url contract. Now strict (URL alphabet only); no caller fed it standard base64 (OIDC n/e are Base64urlUInt, not x5c). Not a signature-bypass vector (JWS signs the literal transmitted ASCII), hence LOW. Test: test_jwt test_base64url_strict_alphabet.
  • Digest nonce never rotated; no replay window bound (SHOULD, RFC 7616 3.3). The server used a single fixed nonce regenerated only at begin(), so a captured Digest response could be replayed indefinitely. Replaced with a stateless, keyed, timestamped nonce (<issue_ms_hex>.<SHA-256(secret||issue) truncated>): no per-nonce table (compatible with the shared-nothing worker model), bounded lifetime (DETWS_DIGEST_NONCE_LIFETIME_MS, default 5 min), and an expired-but-valid response is answered 401 stale=true for a transparent retry (not counted against the lockout). Full per-nonce nc replay tracking remains intentionally out of scope: it requires shared mutable per-nonce state the deterministic worker model cannot hold safely; the bounded-lifetime nonce is the standard stateless mitigation. Tests: test_digest_auth test_nonce_is_stateless_timestamped, test_stale_nonce_triggers_transparent_retry.
  • SNMP v2c GET returned noSuchObject for a missing instance (LOW, RFC 3416 4.2.1). A Get for an unbound name always reported noSuchObject. Now distinguishes noSuchInstance (the name's object-type prefix matches a registered object, only the instance is absent) from noSuchObject (no such object at all). Test: test_snmp_agent test_get_bad_instance_v2c_nosuchinstance.
  • **HTTP chunked was sent to HTTP/1.0 clients (MED-niche, RFC 7230 3.3.1). send_chunked always emitted Transfer-Encoding: chunked, which is invalid for a 1.0 client. It now falls back to a close-delimited body (no Transfer-Encoding, Connection: close, raw bytes paged across loops, end signalled by the connection close) when the request is not HTTP/1.1. Tests: test_chunked test_http10_falls_back_to_close_delimited, test_http10_large_body_not_truncated. HW: --http1.0 /stream -> HTTP/1.0 200 + Connection: close, body intact; --http1.1 -> chunked.

Standards-conformance audit, batch 2d (WebDAV PROPFIND Depth: infinity)

  • Status: FIXED (standards-conformance audit; HW-verified)
  • Found: 2026-06-29, multi-agent conformance audit (WebDAV).
  • PROPFIND Depth: infinity was silently truncated to one level (MED, RFC 4918 9.1.1). A collection PROPFIND with Depth: infinity returned a one-level 207 the client would read as a complete tree. The server lists at most one level, so it now rejects infinity with 403 + the <D:propfind-finite-depth/> precondition body; Depth: 0/1 are unchanged. HW: Depth: infinity -> 403 (precondition body present), Depth: 1 -> 207.

Standards-conformance audit, batch 2c (HTTP If-None-Match comparison)

  • Status: FIXED (standards-conformance audit)
  • Found: 2026-06-29, multi-agent conformance audit (HTTP semantics).
  • If-None-Match used exact strong strcmp (MED, RFC 9110 13.1.2). Conditional GET only matched a single, byte-identical strong tag, so it ignored *, a comma-separated tag list, and the mandated weak comparison (an inbound W/"x" for our strong "x"). A standards-compliant cache revalidating with W/ tags, a list, or * got a full 200 instead of 304. Fix: inm_matches() handles * (matches the current representation), splits a list, and weak-compares (ignores a W/ prefix). Test: test_application test_serve_static_inm_star_list_weak (* / W/"x" / list-with-tag -> 304; list-without-tag -> 200).

Standards-conformance audit, batch 2b (WS UTF-8, SSH padding, syslog PRI, BER OID)

  • Status: FIXED (standards-conformance audit)
  • Found: 2026-06-29, multi-agent conformance audit.
  • WebSocket: TEXT messages were not UTF-8-validated (MED, RFC 6455 8.1). An invalid- UTF-8 text message was delivered to the app instead of failing the connection. Fix: validate the fully reassembled + decompressed TEXT message and close 1007 (WS_CLOSE_INVALID_PAYLOAD) on invalid UTF-8 (strict: rejects overlong / surrogate / out-of-range / truncated). BINARY frames are not validated. Tests: test_websocket.
  • SSH: padding_length < 4 was not rejected (LOW, RFC 4253 6). The receive path only checked padding < packet length. Now also rejects padding < 4 (gated behind MAC verification, so a robustness gap, not a vulnerability).
  • syslog: PRI not range-bounded (LOW, RFC 5424 6.2.1). An out-of-range caller facility/severity could emit a malformed PRI. Now clamped to 0..191.
  • SNMP/BER: OID decoder mishandled a first subidentifier >= 128 (LOW, X.690 8.19.4). The first subidentifier (40*arc0 + arc1) is base-128 and may span octets; the decoder read only one octet (encoder was already correct). Fixed; common OIDs (1.3.6.1..., first subid 43) decode identically. Test: test_oid_large_first_subidentifier_roundtrip.
  • These LOW/SHOULD items are now CLOSED in batch 2e (above): HTTP chunked to non-1.1 clients, SNMP v2c noSuchInstance vs noSuchObject, Digest nonce rotation (stateless timestamped nonce; full nc-replay tracking intentionally out of scope), stricter base64url, WS handshake Connection:Upgrade + key-length check, MQTT topic UTF-8/wildcard.

Standards-conformance audit, batch 2a (auth: JWT alg, Digest uri)

  • Status: FIXED (found by the auth/crypto conformance audit)
  • Found: 2026-06-29, multi-agent conformance audit (auth module).
  • JWT: the alg header was never validated (RFC 7515 5.2, MED). jwt_verify_hs256 computed HMAC-SHA256 and compared without checking the token's declared algorithm, so a token whose header said none / RS256 / HS384 was accepted as long as its signature equaled base64url(HMAC-SHA256(secret, signing_input)) - an algorithm- substitution hazard (not directly exploitable since the HMAC is still enforced and empty-sig none fails the length gate, hence MED). Fix: decode the header and require alg":"HS256"</tt> before verifying. Test: <tt>test_jwt</tt> <tt>test_alg_not_hs256_rejected</tt> (a valid-HMAC token with alg <tt>none</tt> is rejected). - <strong>Digest: the <tt>uri</tt> parameter was not matched to the request target (RFC 7616 3.4, MED).</strong> <tt>check_digest_auth</tt> folded the client-supplied <tt>uri</tt> into HA2 but never compared it to the actual request target, so a Digest response captured for route A was structurally valid against any route under the same realm/nonce. Fix: reconstruct the request target (<tt>path[?query]</tt>) and require it equals <tt>uri</tt>. Test: <tt>test_digest_auth</tt> <tt>test_uri_mismatch_rejected</tt>. - Audited clean: Basic auth (first-colon split), OIDC RS256 (strict alg/exp/nbf/iss/aud - constant-time RSA block compare), TOTP/HOTP (RFC 4226 truncation), OAuth2 client, constant-time HMAC compare. Noted for later (SHOULD/LOW): Digest nonce rotation + <tt>nc</tt> replay tracking; stricter base64url; constant-time Digest response compare. @section autotoc_md155 Standards-conformance audit, batch 1 (WS / MQTT / CoAP / Telnet) - <strong>Status:</strong> FIXED (found by the parallel standards-conformance audit; specs read from the downloaded RFC texts, mapped in docs/STANDARDS.md) - <strong>Found:</strong> 2026-06-29, multi-agent conformance audit against the live specs. - Five real conformance gaps, fixed with tests: - <strong>WebSocket close left the TCP socket open (HIGH, RFC 6455 5.5.1).</strong> The plaintext WS close/error path (dwserver.cpp) only freed the WS slot and <tt>http_reset</tt>'d it - it never closed the TCP connection (the TLS path did). The slot stayed CONN_ACTIVE and re-armed as an HTTP parser, so bytes after the Close frame were re-interpreted as a new HTTP request (state confusion). Fix: <tt>det_conn_begin_close</tt> on the slot so it leaves CONN_ACTIVE (the queued Close frame still flushes). - <strong>MQTT PUBLISH QoS=3 accepted (HIGH, MQTT-3.3.1-4 / 4.8.0-1).</strong> A PUBLISH with both QoS bits set was treated as QoS 2; the spec says it is malformed and the receiver MUST close the connection. Fix: <tt>mqtt_parse_publish</tt> rejects qos==3; the handler <tt>mq_close()</tt>s on a malformed PUBLISH. - <strong>CoAP unsupported method returned 5.01 (MED, RFC 7252 5.8).</strong> Must be 4.05 Method Not Allowed. Fixed. - <strong>CoAP unrecognized critical option silently ignored (MED, RFC 7252 5.4.1).</strong> An unknown odd-numbered (critical) option must yield 4.02 Bad Option (so e.g. Accept, or Block when COAP_BLOCK is off, is rejected, not ignored). Fixed. - <strong>Telnet literal IAC (0xFF) in output not doubled (MED, RFC 854).</strong> Echoed/printed 0xFF bytes were sent un-doubled, desyncing the client's command stream. Fix: a <tt>send_escaped</tt> data path doubles IAC for echo + app output (protocol commands still use the raw path). - SNMP/BER, SSH, WebDAV, syslog, OIDC/TOTP/Basic-auth/OAuth2 audited clean. @section autotoc_md156 CoAP Observe used millis() (would not build on host + pluggable-clock violation) - <strong>Status:</strong> FIXED (found by the test-gap hardening pass) - <strong>Found:</strong> 2026-06-29, adding a CI env that compiles the Observe-gated code. - <strong>Symptom:</strong> <tt>coap_notify()</tt> (under <tt>DETWS_ENABLE_COAP_OBSERVE</tt>) built the notification message-id with <tt>millis()</tt> and pulled <tt>\<Arduino.h\></tt> for it. The flag was enabled by no test env, so the code had never been compiled on host - it failed to build there (&lsquo;'millis&rsquo; was not declared<tt>), and even on ESP32 it violated the pluggable-clock rule that</tt>detws_millis()<tt>is the single monotonic source (same class as the dns_resolver bug above). Latent because the whole Observe path was never compiled in CI. - **Fix:** use</tt>detws_millis()<tt>(include</tt>services/clock.h<tt>), drop the Arduino.h include; added a</tt>native_coap_observe` env so the Observe-gated code is compiled + the CoAP suite runs under the flag in CI (no longer bit-rots). @section autotoc_md157 HTTP request smuggling: Transfer-Encoding ignored on inbound requests - <strong>Status:</strong> FIXED (found by the test-gap hardening pass) - <strong>Found:</strong> 2026-06-29, request-parser test-gap review. - <strong>Symptom:</strong> the HTTP parser only framed request bodies by <tt>Content-Length</tt> and ignored <tt>Transfer-Encoding</tt> entirely. A <tt>Transfer-Encoding: chunked</tt> request with no <tt>Content-Length</tt> was treated as body-length-0, so the chunk octets (<tt>5\\r\\nhello\\r\\n0\\r\\n\\r\\n</tt>) were left in the RX buffer and re-parsed as the next request - a classic TE request-smuggling / desync vector on a keep-alive connection (and the CL+TE combination is the canonical CL.TE desync). - <strong>Fix:</strong> the server does not decode chunked request bodies, so reject any request bearing <tt>Transfer-Encoding</tt> -> <tt>PARSE_ERROR</tt> (400), fail-closed, matching the existing conflicting-<tt>Content-Length</tt> rejection (RFC 9112 §6.1/§6.3). Tests: <tt>test_compliance</tt> <tt>test_transfer_encoding_chunked_rejected</tt> / <tt>_with_content_length_rejected</tt> / <tt>_case_insensitive_rejected</tt>. @section autotoc_md158 Byte-range parser integer overflow on a huge Range value - <strong>Status:</strong> FIXED (v4.9.1; found by the edge-case audit) - <strong>Found:</strong> 2026-06-28, agent edge-case test-gap audit. - <strong>Symptom (latent):</strong> <tt>parse_byte_range</tt> accumulated <tt>start/end = *10 + digit</tt> with no overflow guard, so a <tt>Range: bytes=99999999999999999999999-</tt> wraps <tt>size_t</tt> to a small value that can pass the <tt>start \>= size</tt> check and yield a wrong <tt>206</tt> window. - <strong>Fix:</strong> saturate the accumulator at <tt>SIZE_MAX</tt> on overflow, so a huge start is treated as past-EOF (416) and a huge end clamps to the last byte - never a corrupt window. @section autotoc_md159 If-Modified-Since month token could mis-parse (off-by-alignment) - <strong>Status:</strong> FIXED (v4.9.1; in v4.9.0's just-shipped conditional-GET code) - <strong>Found:</strong> 2026-06-28, agent edge-case test-gap audit. - <strong>Symptom (latent):</strong> <tt>http_not_modified_since</tt> matched the month via <tt>strstr(MONTHS, mon)</tt> without checking the match offset is a multiple of 3, so a malformed token like <tt>ebM</tt> (which appears inside "FebMar") parsed as a valid month -> a wrong 304/200 cache decision on malformed input. No memory-safety impact. - <strong>Fix:</strong> reject the match unless <tt>(mp - MONTHS) % 3 == 0</tt>. @section autotoc_md160 DNS resolver ignored the pluggable clock - <strong>Status:</strong> FIXED (v4.9.1; found by the duplicate-code audit) - <strong>Found:</strong> 2026-06-28, agent services duplicate-code audit. - <strong>Symptom:</strong> <tt>services/dns_resolver</tt> polled its resolve deadline with <tt>millis()</tt> instead of <tt>detws_millis()</tt>, so it ignored a custom clock (<tt>detws_set_clock</tt>) - violating the pluggable-clock rule that <tt>detws_millis()</tt> is the single monotonic source. - <strong>Fix:</strong> use <tt>detws_millis()</tt>. (The bigger dedup, det_client reusing this one resolver, shipped later as the shared-primitive DNS-owner change.) @section autotoc_md161 Client ring used <tt>volatile</tt> indices (weak cross-core ordering) - <strong>Status:</strong> FIXED (v4.8.1; found by analysis while unifying the ring primitive) - <strong>Found:</strong> 2026-06-28, merging the server/client ring implementations. - <strong>Symptom (latent):</strong> the outbound client ring (<tt>det_client</tt>) used <tt>volatile size_t</tt> head/tail. <tt>volatile</tt> blocks compiler reordering but provides no cross-core acquire/release; on the dual-core ESP32 (producer = tcpip_thread, consumer = caller loop, often different cores) the consumer could observe an advanced <tt>head</tt> before the buffer bytes it published were visible -> a rare stale read. The server ring already used <tt>DetAtomic</tt> (correct); the client was inconsistent. - <strong>Fix:</strong> both transports now share <tt>ring.h</tt> (the <tt>DetAtomic</tt> SPSC index + the drain math); the client ring's indices are <tt>DetAtomic</tt>, matching the server's acquire/release ordering. One ring primitive, no hand-rolled wrap/ordering. @section autotoc_md162 Client transport could deadlock on a large inbound transfer - <strong>Status:</strong> FIXED (v4.8.0; same fix as the server, found by analysis during the unified-client-transport pass) - <strong>Found:</strong> 2026-06-28, reviewing <tt>det_client</tt> against the server's ack-on-consume fix. - <strong>Symptom (latent):</strong> an outbound client (http_client / mqtt / ws_client) reading a response larger than the client ring could stall - the same class as the server deadlock below, in the other direction. - <strong>Root cause:</strong> <tt>det_client</tt>'s recv callback ACKed on copy (<tt>tcp_recved</tt> in <tt>cc_recv</tt>) with a 4 KB ring (< TCP_WND), so a sustained inbound transfer could fill the ring, get refused (lwIP <tt>refused_data</tt>), and race. - <strong>Fix:</strong> ack-on-consume on the client transport too - <tt>cc_recv</tt> no longer ACKs; <tt>det_client_read()</tt> marshals <tt>tcp_recved()</tt> for the bytes it drained. Default <tt>DETWS_CLIENT_RX_BUF</tt> raised 4096 -> 8192 (>= TCP_WND). Client and server transports now share one flow-control model. - <strong>HW proof:</strong> device <tt>http_get()</tt> of a 12 KB body (> the 8 KB client ring, so it wraps the ring and exercises the ack-on-consume read path) returned the full <tt>len:12000</tt>, 5/5 - no truncation or deadlock. @section autotoc_md163 RX flow-control deadlock on streamed uploads (WebDAV PUT) - <strong>Status:</strong> FIXED (v4.6.0; host + HW validated) - <strong>Found:</strong> 2026-06-28, stress-testing WebDAV streaming PUT on hardware. - <strong>Symptom:</strong> large PUTs intermittently hung ~20 s (curl timeout), stored 0 bytes, and repeated hangs eventually wedged every slot (device pings but HTTP is dead). - <strong>Root cause:</strong> <tt>recv_cb</tt> ACKed received data on <strong>copy</strong> (<tt>tcp_recved</tt> at copy time), decoupling the advertised TCP window from how full the ring actually was. A slow consumer (flash writes) let the ring fill to <tt>RX_BUF_SIZE</tt>; the next segment was refused (<tt>ERR_MEM</tt> -> lwIP <tt>refused_data</tt>). When <tt>RX_BUF_SIZE \< TCP_WND</tt> the ring can never hold a full receive window, so refusals were constant and lwIP's refused-data redelivery raced fatally. Serial trace nailed it: failing PUTs stalled at exactly <tt>bytes_written == RX_BUF_SIZE</tt>. This was an <strong>interlayer</strong> bug: the receive-window invariant was smeared across transport (ACK on copy), presentation (drains the ring) and session (worker loop), with no single owner. - <strong>Fix:</strong> ack-on-consume, owned entirely by transport. <tt>recv_cb</tt> no longer ACKs; the worker calls <tt>det_conn_ack_consumed(slot)</tt> once per loop and transport reopens the window by exactly the bytes drained since the last ACK (<tt>tcp_recved</tt> marshaled to tcpip_thread). The window now tracks ring occupancy, so a slow sink cannot overflow the ring. <strong>TCP-level requirement:</strong> the ring must hold at least one receive window (<tt>RX_BUF_SIZE \>= TCP_WND</tt>); a smaller ring is a configuration error (you cannot advertise a window larger than your buffer). - <strong>HW proof:</strong> RX=8192 (>= TCP_WND) + ack-on-consume -> 10/10 50 KB byte-exact, <tt>backpressure=0</tt>. Pre-fix: RX=2048 -> ~40% hang + permanent wedge. @section autotoc_md164 Boot stack-overflow when RX_BUF_SIZE is large (pool_init) - <strong>Status:</strong> FIXED (v4.6.0; host validated) - <strong>Found:</strong> 2026-06-28, while testing large rings for the deadlock above. - <strong>Symptom:</strong> "Stack canary watchpoint triggered (loopTask)" at boot (<tt>begin()</tt> -> <tt>pool_init</tt> -> <tt>memset</tt>) once <tt>RX_BUF_SIZE</tt> was set large (e.g. 8192). - <strong>Root cause:</strong> <tt>conn_pool[i] = {}</tt> materializes a full <tt>sizeof(TcpConn)</tt> temporary - the entire <tt>rx_buffer[RX_BUF_SIZE]</tt> - on the loopTask stack. - <strong>Fix:</strong> reset from a single <tt>static const TcpConn blank = {}</tt> in BSS via copy-assign (uses <tt>DetAtomic::operator=</tt>, no atomic-memset UB); no large stack temporary. @section autotoc_md165 WebDAV streamed PUT leaks the file handle on abort - <strong>Status:</strong> FIXED (v4.6.0) - <strong>Found:</strong> 2026-06-28, investigating the deadlock cascade to a permanent wedge. - <strong>Symptom:</strong> a PUT torn down before completion (peer reset / timeout / the deadlock above) never closed <tt>g_dav_put_file</tt>; after a few, LittleFS ran out of open-file slots and <tt>open()</tt> failed ("no permits for creation").
  • Root cause: the file was closed only on the PARSE_COMPLETE handler path; no cleanup hook for an aborted stream.
  • Fix: added HttpStreamAbortCb to the parser stream hooks, fired from http_parser_reset when body_streaming && parse_state != PARSE_COMPLETE. WebDAV registers it and closes the half-written file. The completion path now also clears g_dav_put_active so the abort hook cannot double-close.

WebDAV concurrent streamed PUTs clobber each other

  • Status: FIXED (v4.7.0; host + HW validated)
  • Found: 2026-06-28, concurrent (4x) 50 KB PUT stress test.
  • Symptom: 4 simultaneous PUTs -> one 201, the rest 409/timeout (the file was not actually written for the losers).
  • Root cause: the WebDAV streaming-PUT state (g_dav_put_file/active/error/...) was a single global, and HttpStreamDataCb carried no slot/connection, so the data hook could not route bytes to per-connection state. Overlapping PUTs shared and clobbered the one global transfer.
  • Fix: made the streaming-body data hook slot-aware (HttpStreamDataCb(HttpReq*, ...); begin/abort already had it) and replaced the global state with per-slot g_dav_put[MAX_CONNS], so each connection streams to its own file. Aligns with the no-spaghetti directive (the data hook lacking connection context was the interlayer gap). HW: 4 concurrent PUTs with distinct payloads, all 4 byte-exact (was 0/4).

TX truncation on large responses (serve_file, send_chunked)

  • Status: SHIPPED (serve_file v4.4.1; send_chunked v4.5.0)
  • Found: 2026-06-28, stress-testing file/chunked GET on hardware.
  • Symptom: any file/WebDAV GET or chunked/SSE body larger than ~TCP_SND_BUF (~5.7 KB) was truncated.
  • Root cause: the senders called det_conn_send() and ignored the return; once the TCP send buffer filled, the remainder was silently dropped. Hidden on host because the mock tcp_sndbuf is constant and tcp_write never returns ERR_MEM.
  • Fix: per-slot send continuations (file_send_pump / chunk_send_pump) that page out one send-window per worker loop and resume on the sent callback; the chunked API became a pull generator (ChunkSource) so it can resume across loops.