Skip to content

fix: the telemetry client would read a megabyte it never wanted, and … - #72

Merged
angeloINTJ merged 1 commit into
mainfrom
fix/telemetry-campaign-2026-08-02
Aug 2, 2026
Merged

fix: the telemetry client would read a megabyte it never wanted, and …#72
angeloINTJ merged 1 commit into
mainfrom
fix/telemetry-campaign-2026-08-02

Conversation

@angeloINTJ

Copy link
Copy Markdown
Owner

…die doing it

A campaign of 43 fault-injected servers against the four transports found that three of them could put the device into a watchdog reboot loop, and that the reboot had been hiding a second failure underneath it.

The reboots all came from two loops in the framework's HTTPClient with no upper bound. handleHeaderResponse's _tcpTimeout is an INACTIVITY timeout — it resets on every line — so a peer trickling one byte every 400 ms never trips it, and a single readStringUntil('\n') spends 12.8 s inside one call on a 32-byte header line, against an 8.388 s ceiling. disconnect() drains the body one byte at a time "until available() is empty", which against a peer still sending means the whole body. Both now carry one budget and feed the watchdog inside it, which is only safe because the budget makes them terminate. Measured: huge1mb 4 reboots to 0, drip 4 to 0 at two trickle rates, tls_slow20 2 to 0.

Without the reboots the device survived long enough to exhaust the lwIP pbuf pool and go silent on the network while the serial CLI kept answering. ClientContext::close() and abort() dropped the pcb without freeing _rx_buf, and WiFiClient::stop() calls close() without releasing the context, so the refcount never reached the unref() that would have discarded it. Freeing there took the device from ~16 to ~144 connections before saturating; the pool going back to its stock 24 (the cut to 12 saved 18 KB of BSS on the premise that 12 would not saturate — it does) removed the saturation entirely.

Chasing what still killed the web server led below TCP: host ARP went INCOMPLETE and ICMP got 100% loss while the firmware still announced its IP. NET_READY's only liveness test is WiFi.status(), and cyw43 kept reporting WL_CONNECTED with the link dead, so the state was never demoted and the working reconnect path was never entered. The tell was an RSSI of +4 dBm — received signal is negative by definition — which isNetworkHealthy() compared against a -78 threshold and read as excellent. An implausible reading twice running now demotes the state, and getRssi() reports it as a dead link.

Also, from the same campaign:

  • the MQTT password typed on the config page was sent by the browser and never read by commit_all, so a broker wanting credentials could not be reached
  • an MQTT payload past the 8 KB client buffer failed deterministically, which is a permanent stall rather than a retry; it now publishes record by record
  • MQTT touched no metrics at all, so telSent/telFailed/latency stayed at zero and update()'s adaptive interval, derived from smoothed latency, never engaged
  • CSV mode emitted a 7-column header over 34-column rows
  • a 4xx/5xx reply counted as neither sent nor failed and logged "HTTP OK"
  • /api/ls lost one file per full batch of 20: dir.next() && batchCount < 20 advances the iterator before testing the count. 88 files listed as 84, and because LittleFS iteration order varies, two listings dropped different ones
  • the cursor could advance past a batch buildPayload had shortened under heap pressure, and pending wrapped at 65535 with ~119k records on flash

Throughput and integrity held throughout: 48.6 records/s on HTTP and 49.7 on MQTT at batch 50, zero reboots across 16 performance runs, and 1375 records compared field by field against the on-flash .h5 decoded by the reference codec matched exactly. TLS costs 35% of throughput on HTTPS and 12% on MQTT, because HTTPS pays a handshake per POST while MQTT pays one and keeps the connection.

docs/telemetry-campaign-2026-08-02/ carries the method, the numbers, and the defects — including the ones left open and the two conclusions this campaign had to retract after retesting.

Description

Briefly describe your changes.

Type of Change

  • Bug fix
  • New feature
  • Documentation update
  • Refactoring
  • Other (describe):

Testing

Describe how you tested these changes:

  • Compiled with pio run -e pico_w_release
  • Ran unit tests (pio test -e native)
  • Tested on hardware

Checklist

  • Code follows project conventions
  • Comments are in English
  • No new compiler warnings introduced
  • Flash usage remains under 100%

Related Issues

Link any related issues: #

…die doing it

A campaign of 43 fault-injected servers against the four transports found that
three of them could put the device into a watchdog reboot loop, and that the
reboot had been hiding a second failure underneath it.

The reboots all came from two loops in the framework's HTTPClient with no upper
bound. handleHeaderResponse's _tcpTimeout is an INACTIVITY timeout — it resets
on every line — so a peer trickling one byte every 400 ms never trips it, and a
single readStringUntil('\n') spends 12.8 s inside one call on a 32-byte header
line, against an 8.388 s ceiling. disconnect() drains the body one byte at a
time "until available() is empty", which against a peer still sending means the
whole body. Both now carry one budget and feed the watchdog inside it, which is
only safe because the budget makes them terminate. Measured: huge1mb 4 reboots
to 0, drip 4 to 0 at two trickle rates, tls_slow20 2 to 0.

Without the reboots the device survived long enough to exhaust the lwIP pbuf
pool and go silent on the network while the serial CLI kept answering.
ClientContext::close() and abort() dropped the pcb without freeing _rx_buf, and
WiFiClient::stop() calls close() without releasing the context, so the refcount
never reached the unref() that would have discarded it. Freeing there took the
device from ~16 to ~144 connections before saturating; the pool going back to
its stock 24 (the cut to 12 saved 18 KB of BSS on the premise that 12 would not
saturate — it does) removed the saturation entirely.

Chasing what still killed the web server led below TCP: host ARP went
INCOMPLETE and ICMP got 100% loss while the firmware still announced its IP.
NET_READY's only liveness test is WiFi.status(), and cyw43 kept reporting
WL_CONNECTED with the link dead, so the state was never demoted and the working
reconnect path was never entered. The tell was an RSSI of +4 dBm — received
signal is negative by definition — which isNetworkHealthy() compared against a
-78 threshold and read as excellent. An implausible reading twice running now
demotes the state, and getRssi() reports it as a dead link.

Also, from the same campaign:
- the MQTT password typed on the config page was sent by the browser and never
  read by commit_all, so a broker wanting credentials could not be reached
- an MQTT payload past the 8 KB client buffer failed deterministically, which
  is a permanent stall rather than a retry; it now publishes record by record
- MQTT touched no metrics at all, so telSent/telFailed/latency stayed at zero
  and update()'s adaptive interval, derived from smoothed latency, never engaged
- CSV mode emitted a 7-column header over 34-column rows
- a 4xx/5xx reply counted as neither sent nor failed and logged "HTTP OK"
- /api/ls lost one file per full batch of 20: `dir.next() && batchCount < 20`
  advances the iterator before testing the count. 88 files listed as 84, and
  because LittleFS iteration order varies, two listings dropped different ones
- the cursor could advance past a batch buildPayload had shortened under heap
  pressure, and `pending` wrapped at 65535 with ~119k records on flash

Throughput and integrity held throughout: 48.6 records/s on HTTP and 49.7 on
MQTT at batch 50, zero reboots across 16 performance runs, and 1375 records
compared field by field against the on-flash .h5 decoded by the reference codec
matched exactly. TLS costs 35% of throughput on HTTPS and 12% on MQTT, because
HTTPS pays a handshake per POST while MQTT pays one and keeps the connection.

docs/telemetry-campaign-2026-08-02/ carries the method, the numbers, and the
defects — including the ones left open and the two conclusions this campaign
had to retract after retesting.
@angeloINTJ
angeloINTJ merged commit eb4bf33 into main Aug 2, 2026
1 check passed
@angeloINTJ
angeloINTJ deleted the fix/telemetry-campaign-2026-08-02 branch August 2, 2026 15:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant