fix: the telemetry client would read a megabyte it never wanted, and … - #72
Merged
Merged
Conversation
…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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
…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:
dir.next() && batchCount < 20advances the iterator before testing the count. 88 files listed as 84, and because LittleFS iteration order varies, two listings dropped different onespendingwrapped at 65535 with ~119k records on flashThroughput 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
Testing
Describe how you tested these changes:
pio run -e pico_w_releasepio test -e native)Checklist
Related Issues
Link any related issues: #