Skip to content

Fix connection-killing underflow + silent corruption on small H2/JSON buffers (no-PSRAM boards) - #35

Open
ViniciusDev26 wants to merge 1 commit into
CamM2325:mainfrom
ViniciusDev26:fix/maprequest-enotconn
Open

Fix connection-killing underflow + silent corruption on small H2/JSON buffers (no-PSRAM boards)#35
ViniciusDev26 wants to merge 1 commit into
CamM2325:mainfrom
ViniciusDev26:fix/maprequest-enotconn

Conversation

@ViniciusDev26

Copy link
Copy Markdown

Summary

Running MicroLink on a plain ESP32-WROOM-32 (no PSRAM, ~320KB SRAM) requires
ML_H2_BUFFER_SIZE_KB below the documented 64KB minimum to fit the
coordination buffers in available heap. That configuration is silently
broken by three separate bugs in ml_coord.c, all triggered by the same
root cause: buffer-size math that assumes ML_H2_BUFFER_SIZE is always
comfortably above 64KB.

Bugs fixed

1. Integer underflow → connection killed right after registration (every time)

uint32_t conn_window_delta = ML_H2_BUFFER_SIZE - 65535;

This is unsigned arithmetic. With ML_H2_BUFFER_SIZE_KB below 64, the
subtraction underflows to a ~4GB value, which gets sent as a connection-level
WINDOW_UPDATE delta. That violates RFC 7540's 2^31-1 flow-control window
limit — the real controlplane.tailscale.com correctly responds with
GOAWAY/FLOW_CONTROL_ERROR and tears down the connection, deterministically,
right after RegisterResponse succeeds and before MapRequest can complete.
Confirmed via a GOAWAY frame dump on real hardware. Clamped to 0 when the
buffer 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 Noise
frame, 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_buf
have 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 (json_total + f_len < ML_JSON_BUFFER_SIZE) {
    memcpy(resp_buf + json_total, h2_recv + fpos, f_len);
    json_total += f_len;
}

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 by
unrelated 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_recv accumulation loop's existing (correct)
truncation behavior a few lines above.

4. (defensive) Proactive stream WINDOW_UPDATE threshold hardcoded to 32KB

if (window_consumed >= 32768) {

Never fires once ML_H2_BUFFER_SIZE drops below 64KB, since the declared
per-stream window (INITIAL_WINDOW_SIZE = ML_H2_BUFFER_SIZE) exhausts
before 32KB of window is consumed — stalling the server mid-response.
Scaled to ML_H2_BUFFER_SIZE / 2 instead.

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-device
tailnet on the production Tailscale control plane. Before this patch: 100%
reproducible GOAWAY immediately after registration. After: reaches
ML_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 MapRequest attempts fail with a JSON parse error
partway through an otherwise well-formed, non-truncated response (raw
h2_recv bytes match resp_buf bytes 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 example
sdkconfig.defaults — eliminates the watchdog trips but not the parse
failures). 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

…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.
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