Fix connection-killing underflow + silent corruption on small H2/JSON buffers (no-PSRAM boards) - #35
Open
ViniciusDev26 wants to merge 1 commit into
Open
Conversation
…n small H2/JSON buffers On boards without PSRAM (e.g. plain ESP32-WROOM-32), ML_H2_BUFFER_SIZE_KB must be set below 64 to fit the coordination buffers in available heap. This uncovered three bugs that only manifest below that threshold: 1. do_h2_preface(): `ML_H2_BUFFER_SIZE - 65535` is unsigned arithmetic. Any buffer size below 64KB underflows to a ~4GB WINDOW_UPDATE delta, which violates RFC 7540's 2^31-1 flow-control window limit. The real Tailscale control plane correctly responds with GOAWAY/FLOW_CONTROL_ERROR, killing the connection right after registration succeeds — every time, deterministically. Clamped to 0 when the buffer is already >= 65535. 2. Per-Noise-frame scratch buffer in the MapResponse read loop was a fixed 64KB allocation on every loop iteration, competing with h2_recv/resp_buf for the same (now more fragmented) heap. On a non-PSRAM board this allocation can fail outright, silently producing "Empty MapResponse" with zero frames ever read. Reduced to 8KB (2x margin over the ~4KB chunks the server actually sends). 3. The H2 DATA-frame extraction loop silently *skipped* any frame that would overflow ML_JSON_BUFFER_SIZE while continuing to process later, smaller frames — stitching together non-contiguous fragments of the JSON instead of stopping. This produces plausible-looking but corrupted JSON (e.g. a truncated "Singapore" directly followed by unrelated content from a later frame) instead of a clean, debuggable truncation. Now breaks with a warning, matching the h2_recv accumulation loop's existing truncation behavior. Also scaled the proactive stream WINDOW_UPDATE threshold to ML_H2_BUFFER_SIZE/2 instead of a hardcoded 32KB, since that threshold never fires (and the server stalls waiting for a window refresh it never gets) once the configured buffer drops below 64KB. Verified end-to-end on real hardware (ESP32-WROOM-32, no PSRAM, ML_H2_BUFFER_SIZE_KB=32, ML_JSON_BUFFER_SIZE_KB=24, 6-device tailnet against the production Tailscale control plane): reaches CONNECTED, parses the full peer list, and round-trips application data over the resulting WireGuard tunnel.
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.
Summary
Running MicroLink on a plain ESP32-WROOM-32 (no PSRAM, ~320KB SRAM) requires
ML_H2_BUFFER_SIZE_KBbelow the documented 64KB minimum to fit thecoordination buffers in available heap. That configuration is silently
broken by three separate bugs in
ml_coord.c, all triggered by the sameroot cause: buffer-size math that assumes
ML_H2_BUFFER_SIZEis alwayscomfortably above 64KB.
Bugs fixed
1. Integer underflow → connection killed right after registration (every time)
This is unsigned arithmetic. With
ML_H2_BUFFER_SIZE_KBbelow 64, thesubtraction underflows to a ~4GB value, which gets sent as a connection-level
WINDOW_UPDATEdelta. That violates RFC 7540's 2^31-1 flow-control windowlimit — the real
controlplane.tailscale.comcorrectly responds withGOAWAY/FLOW_CONTROL_ERRORand tears down the connection, deterministically,right after
RegisterResponsesucceeds and beforeMapRequestcan complete.Confirmed via a
GOAWAYframe dump on real hardware. Clamped to 0 when thebuffer is already ≥ 65535 (so default/large-buffer configs are unaffected).
2. Per-frame scratch buffer competing with h2_recv/resp_buf for the same heap
The MapResponse read loop allocated a fresh 64KB scratch buffer
(
ml_psram_malloc(65536)) on every iteration to hold one decrypted Noiseframe, even though the server only ever sends ~4KB chunks in practice. On a
non-PSRAM board this allocation can fail outright once
h2_recv/resp_bufhave already claimed most of the largest free block, producing an "Empty
MapResponse" with literally zero frames read — no error, no obvious cause.
Reduced to 8KB (2x margin over observed chunk sizes).
3. Silent frame-skip on JSON buffer overflow → corrupted (not truncated) JSON
If a DATA frame doesn't fit, this silently skips it and keeps going —
appending whatever later, smaller frame happens to fit right where the
skipped one left off. That stitches together non-contiguous fragments of
the JSON instead of stopping, producing plausible-looking but corrupted
output (e.g. a region name truncated to
"Singapo"immediately followed byunrelated content from a frame several KB later:
"Singapoap":142,...).Much harder to diagnose than a clean cutoff. Now
breaks with a warning,matching the sibling
h2_recvaccumulation loop's existing (correct)truncation behavior a few lines above.
4. (defensive) Proactive stream WINDOW_UPDATE threshold hardcoded to 32KB
Never fires once
ML_H2_BUFFER_SIZEdrops below 64KB, since the declaredper-stream window (
INITIAL_WINDOW_SIZE = ML_H2_BUFFER_SIZE) exhaustsbefore 32KB of window is consumed — stalling the server mid-response.
Scaled to
ML_H2_BUFFER_SIZE / 2instead.Testing
Verified end-to-end on real hardware: ESP32-WROOM-32 (no PSRAM),
platform = espressif32@6.9.0/ ESP-IDF 5.3.1,ML_H2_BUFFER_SIZE_KB=32,ML_JSON_BUFFER_SIZE_KB=24,ML_MAX_PEERS=8, against a real ~6-devicetailnet on the production Tailscale control plane. Before this patch: 100%
reproducible
GOAWAYimmediately after registration. After: reachesML_STATE_CONNECTED, parses the full peer list (DERPMap with 28 regions,~22KB MapResponse), and successfully round-trips application data over the
resulting WireGuard tunnel to a peer.
Known follow-up (not fixed here)
Reconnect cycles after the first successful one are still flaky — most
(not all) subsequent
MapRequestattempts fail with a JSON parse errorpartway through an otherwise well-formed, non-truncated response (raw
h2_recvbytes matchresp_bufbytes exactly at the failure point; brace/bracket depth stays sane). This coincides with (but doesn't appear to be
caused by) periodic task-watchdog trips against
ml_derp_tx/tiT(
CONFIG_ESP_TASK_WDT_TIMEOUT_S=30— as already set in the examplesdkconfig.defaults— eliminates the watchdog trips but not the parsefailures). The device does eventually reconnect and stays fully functional
once connected; I'm filing this separately with full logs since it's a
distinct, deeper issue from the three fixed here.
🤖 Generated with Claude Code