Skip to content

fix(wg): move magicsock TX pbuf allocation to SPIRAM - #77

Merged
fudio101 merged 3 commits into
mainfrom
feat/issue-33-spiram-tx-pbuf
Aug 19, 2026
Merged

fix(wg): move magicsock TX pbuf allocation to SPIRAM#77
fudio101 merged 3 commits into
mainfrom
feat/issue-33-spiram-tx-pbuf

Conversation

@fudio101

Copy link
Copy Markdown
Collaborator

Summary

Ports the last remaining piece of issue #33 (FORK_PRS.md row 14) — the SPIRAM pbuf headroom fix from Csontikka/microlink's f1de3143. The other two pieces (DISCO trust-expiry gate, NAT-rebind handshake-skip) already landed in an earlier session.

wg_udp_output_cb() (and its tcpip_try_callback-deferred counterpart wg_send_in_tcpip()) allocated every outbound WireGuard packet via pbuf_alloc(PBUF_TRANSPORT, len, PBUF_RAM) — internal-DRAM-pool allocations that fragment under sustained high packet rate (the source fork's own bug report: ~140pps causing periodic mem_malloc failure on ~1312-byte WG packets). New wg_alloc_tx_pbuf() replaces both call sites with a custom pbuf backed by SPIRAM for the payload + a small control struct on internal heap, freed via a custom_free_function.

Improved on the source fix, not a literal port: the source commit hand-computes hdr_offset = LWIP_MEM_ALIGN_SIZE(PBUF_TRANSPORT) to reserve header room in the SPIRAM buffer — this is the exact arithmetic that (per the source commit's own self-correcting comment) shipped corrupted data once already in that fork's history. Checked against lwIP's actual pbuf.c/udp.c before porting: pbuf_add_header() (the only header-growth call any TX path uses) unconditionally fails for PBUF_REF-type pbufs regardless of how much headroom is reserved — only pbuf_header_force(), which lwIP only calls on RX, respects it. udp_send() already falls back to chaining a small separate PBUF_RAM header pbuf in front on every send regardless of what layer we pass. So the reserved headroom was always dead weight — using PBUF_RAW (offset always 0) gets identical runtime behavior while eliminating the fragile duplicated offset arithmetic entirely.

Credit to Csontikka/microlink for identifying and fixing the real underlying problem (DRAM pbuf pool fragmentation under load).

Test plan

  • Compile-checked via a from-clean pio run against zen-clock (downstream consumer) with override_path — clean build, new symbols (wg_alloc_tx_pbuf, ml_spiram_pbuf_free_fn) present in the compiled object.
  • Flashed to real hardware (LilyGo T-Display-S3) on a real tailnet, verified via ICMP ping through the WG tunnel (every outbound WG packet — including ICMP echo replies — goes through the modified wg_udp_output_cb, so no extra echo-listener firmware was needed):
    • Clean baseline: 400 pings @ ~20pps (20s), 0% packet loss, RTT 18-30ms.
    • Deliberate overload stress test: 8 parallel ping streams, ~160pps aggregate (DERP-relayed), 45s sustained — 1038+ real WG UDP TX events through the new allocator path. Zero crashes, panics, or heap-corruption signatures in the boot log. The stress test's high packet loss (RTT growing to 4+ seconds before drops) is a DERP-relay congestion signature, not corruption — a corrupted checksum would drop immediately rather than after a growing queueing delay.

Closes #33

🤖 Generated with Claude Code

nguyenndt-qualgo and others added 3 commits August 19, 2026 22:51
…zed window

do_fetch_peers() previously allocated two fixed 512KB PSRAM buffers (H2
receive + a separate JSON parse buffer). Merge them into a single buffer,
compacting the extracted MapResponse JSON in place via memmove instead of
copying into a second buffer -- halves peak footprint (~1MB -> ~512KB at
defaults). The buffer's size is now also clamped at connect time to the
largest actually-free heap block (min 64KB, never above the configured
ML_H2_BUFFER_SIZE_KB ceiling), via new choose_h2_rx_window_size().

CONFIG_ML_JSON_BUFFER_SIZE_KB is removed -- the merged buffer only needs
one size knob.

Adapted from djorr5/microlink's `67b230b2` piece (a) (dynamic H2 RX window
sizing) -- piece (b), `ip4_route_src_hook`, is unrelated and untouched
(tracked separately as #39). Kept this fork's existing frame_buf
scratch-then-copy pattern in the initial receive loop rather than reading
noise_recv() straight into the shrinking window, since this fork's
noise_recv() doesn't drain the ciphertext off the socket when a frame
doesn't fit the destination buffer -- reading directly into a
near-full window risked desyncing the coordination stream.

Verified with a from-clean `pio run` against zen-clock (a real downstream
consumer, LilyGo T-Display-S3) using idf_component.yml's override_path
pointed at this working tree: full firmware build + link succeeded. The
adaptive clamp-under-heap-pressure path itself wasn't exercised on
hardware since that board has ample free PSRAM.

Closes #38

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Board became available mid-session -- re-flashed zen-clock (LilyGo
T-Display-S3) against a real tailnet and captured the actual boot log
through do_fetch_peers(): H2 rx window computed correctly (128KB, clamped
to the configured ceiling since free heap was ample), a real 22KB/13-frame
MapResponse reassembled and JSON-compacted in place without truncation or
corruption, and all 5 real tailnet peers completed WireGuard handshakes
normally afterward. Upgrades the earlier compile-only verification note.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
wg_udp_output_cb() (and its tcpip_try_callback-deferred counterpart
wg_send_in_tcpip()) allocated every outbound WireGuard packet via
pbuf_alloc(PBUF_TRANSPORT, len, PBUF_RAM) -- internal-DRAM-pool
allocations that fragment under sustained high packet rate.

New wg_alloc_tx_pbuf() replaces both call sites with a custom pbuf
backed by SPIRAM (payload) + a small internal-heap control struct,
freed via a custom_free_function so pbuf_free() keeps working normally.

Adapted from Csontikka/microlink's `f1de3143`, but simplified after
checking lwIP's actual pbuf.c/udp.c: the source fix hand-computes a
PBUF_TRANSPORT header-offset reservation (the same arithmetic that
shipped corrupted data once in that fork's own history) to leave room
for lwIP to grow the pbuf in place. That growth path never fires here --
pbuf_add_header() unconditionally fails for PBUF_REF-type pbufs on every
TX path (only the RX-only pbuf_header_force() respects reserved
headroom), so udp_send() already falls back to chaining a small
separate PBUF_RAM header pbuf on every send regardless. Using PBUF_RAW
(offset 0) instead of PBUF_TRANSPORT gets identical runtime behavior
without the fragile duplicated offset arithmetic.

Hardware-verified on a LilyGo T-Display-S3 against a real tailnet:
0% loss over 400 pings at ~20pps (clean baseline), and 1038+ real
WG UDP TX events under an 8-stream ~160pps overload stress test with
zero crashes or heap-corruption signatures in the boot log.

Closes #33

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@fudio101
fudio101 merged commit 0d6b4d5 into main Aug 19, 2026
14 checks passed
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.

DISCO trust-expiry gate, NAT-rebind handshake-skip, SPIRAM pbuf headroom fix (mined from Csontikka/microlink)

2 participants