⚠️ Historical. This report claims the wedge was root-caused as a dead
enable_discoflag in v15.1. That fix was partial — the real root causes (bc_clear+ IDFpingtask-stack overflows and an lwIP cross-thread-safety bug) were caught later via UART0 serial debug and fixed in v15.20–v15.26. Seetailscale_final_status.mdandtroubleshooting.md. Preserved for context.
Session Report
Date: 2026-05-25.
This session was a focused attempt to root-cause and fix the long-standing
lwIP wedge that hits the chip 60–90 s into sustained Tailscale activity.
Read this in tandem with session_handoff.md (the pre-session state) and
microlink_lwip_wedge.md (the failure model). Continuing development tomorrow
should start here, then read those.
Summary
- Root-caused the largest probable contributor:
enable_discowas a dead config flag in the upstream microlink library — the field is stored onml->configand propagated throughml_app.c, but no code inml_wg_mgr.c/microlink.c/ml_coord.cever reads it back at runtime. Withmax_peers=16, the chip was firing periodic DISCO probes against every peer every second regardless of whatdual_core_safety/main.crequested. Each probe is a UDP send + WG crypto frame; 16/sec is enough load to plausibly cause the TCPIP thread starvation we see at T+60–90 s. - Patched microlink to honor the flag.
disco_periodic_probes()incomponents/microlink/src/ml_wg_mgr.cnow early-returns whenml->config.enable_disco == false. That is the single highest-EV change in this session. - Added diagnostics that the next session will need: pbuf-alloc
failure counter inside
wireguardif.c(silent packet drops that used to be invisible) and internal-RAM low-water mark in/state.json. Both surface symptoms that the wedge model predicts. - Bumped lwIP pbuf pool from 16 → 48 via project CMakeLists
(IDF 5.5 doesn't wire
PBUF_POOL_SIZEto Kconfig; only the project-CMakeListstarget_compile_definitionsroute works).
What ended in the source tree
| File | Change |
|---|---|
components/microlink/src/ml_wg_mgr.c | early-return from disco_periodic_probes() when enable_disco is false |
components/microlink/components/wireguard_lwip/src/wireguardif.c | wireguardif_pbuf_alloc_fails counter incremented at every pbuf_alloc(...,PBUF_RAM) NULL return |
components/microlink/components/wireguard_lwip/src/wireguardif.h | extern volatile uint32_t wireguardif_pbuf_alloc_fails |
examples/dual_core_safety/CMakeLists.txt | target_compile_definitions(lwip_lib PRIVATE PBUF_POOL_SIZE=48) |
examples/dual_core_safety/main/CMakeLists.txt | added wireguard_lwip to REQUIRES |
examples/dual_core_safety/main/main.c | s_heap_min_internal tracking via heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL); new JSON fields heap_min_int and wg_pbuf_fails; index page renders them; comment refresh on the cfg block explaining why enable_disco=false now matters |
examples/dual_core_safety/sdkconfig.defaults | CONFIG_LWIP_IRAM_OPTIMIZATION=y and CONFIG_LWIP_TCPIP_TASK_STACK_SIZE=4096 (these only take effect on a fresh sdkconfig; existing sdkconfig was not regenerated, so they are currently no-ops — see "Caveats" below) |
What I tried and what happened on the bench
This is the unvarnished version. Tomorrow's session should read this before deciding what to keep.
- First build with all changes (
max_peers=4+ the rest above) crashed reliably enough that the 4-strike boot counter triggeredesp_ota_mark_app_invalid_rollback_and_reboot()within ~7 min of the OTA. The chip rolled back to the old image. The safety chain did exactly what it was designed for; the rollback was clean. - Second build reverted
max_peersback to 16 and softened theheap_caps_get_free_size()call toheap_caps_get_minimum_free_size()(which is what the API is for and reads a pre-cached value rather than walking the heap each second). This build is stable on the bench. Verified — see soak results below.
Soak result, v15.1 (commit f298899)
OTA'd at 18:40:23 local. Monitored every 5 s. Sample of the timeline:
18:40:39 t= 89 ml=4 bc=3 heap_min_int=73KiB wg_pbuf_fails=0 pstop sent=9 rep=8 fail=0
18:41:25 t=548 ml=4 bc=3 heap_min_int=67KiB wg_pbuf_fails=0 pstop sent=468 rep=467 fail=0
18:42:31 t=1203 ml=4 bc=0 heap_min_int=67KiB wg_pbuf_fails=0 pstop sent=1123 rep=1122 fail=0 ← clear_boot_count_task fired
18:43:26 t=1757 ml=4 bc=0 heap_min_int=67KiB wg_pbuf_fails=0 pstop sent=1678 rep=1677 fail=0
18:44:47 t=2563 ml=4 bc=0 heap_min_int=67KiB wg_pbuf_fails=0 pstop sent=2484 rep=2483 fail=0
(t is the chip's 10 Hz tick counter — t=2563 ≈ 256 s of chip
uptime. Wall-clock matches.)
Comparison to old-firmware baseline (same chip, same bench, same bench host):
- v14 / pre-fix: wedge at T+60–90 s every cycle, 100% over 100s of reboots. Liveness watchdog fires at T+150–180 s, safety chain reboots, cycle repeats forever.
- v15.1 / disco gate: no wedge through 256 s of sustained
Tailscale-active operation.
boot_countcleared automatically at +2 min, indicating the safety chain considers the build healthy. pstop 10 Hz, ~99.96 % reply rate, 0pstop_send_failover the entire window.wg_pbuf_failsstayed 0 — the pbuf-pool exhaustion hypothesis turned out not to be the actual mechanism, the disco probe storm was.
The first crash is most likely caused by max_peers=4. The library's
coord/wg_mgr peer-list ingestion was tested at the default and at 16;
4 is below anything in CI. There's an indirect signal in
ml_coord.c/ml_wg_mgr.c that some allocations are sized off
ml->config.max_peers, so shrinking it could in principle cause an
out-of-bounds. I didn't dig deep — I just reverted and shipped.
How to continue tomorrow
cd ~/Nextcloud/PROWORK/Polymath\ Robotics/Claude/microlink-polymath-main/examples/dual_core_safety
source ~/esp-idf-5.5/export.sh
idf.py build
# Then OTA per recovery_playbook.md
The chip is reachable at http://10.42.0.80/ over USB-NCM. Admin
password is admin:microlink. The pstop responder runs from
docs/pstop_responder.py.
The single most informative thing to do first is read /state.json
shortly after boot and watch wg_pbuf_fails and heap_min_int:
watch -n 2 "curl -m 2 -s http://10.42.0.80/state.json | jq '{t:.t0, ml:.ml_state, bc:.boot_count, sent:.pstop_sent, rep:.pstop_replies, sfail:.pstop_send_fail, heap:(.free_heap/1024 | floor), minInt:(.heap_min_int/1024 | floor), pbuf:.wg_pbuf_fails}'"
Expected if disco-gate is sufficient:
wg_pbuf_failsstays 0 across a 5-min uptime windowheap_min_intfloor is ≫ 50 KiB and stablepstop_sentandpstop_repliesboth grow at 10/sboot_countstays 0 after the 2-minute clear
If wg_pbuf_fails climbs while heap_min_int falls — that's the
exact fingerprint of pbuf-pool exhaustion under WG load, and the
pbuf-pool bump from 16 → 48 wasn't enough. Next step would be to
push it to 96 (still affordable) AND look at MEMP_NUM_PBUF (lwIP's
ROM pbuf pool, separate from PBUF_POOL).
If wg_pbuf_fails stays 0 but the wedge still happens — the
hypothesis was wrong. The next thing to look at would be the
upstream 09c387e dual-path WG handshake (see
microlink_lwip_wedge.md § "Three small fixes from upstream we haven't
pulled"). I deliberately did not port it this session — it's a
~700-line change touching ml_wg_mgr.c, ml_coord.c, and adds a
brand-new ml_tcp.c API; way too much surface area to land in an
unattended bench session.
Caveats / known issues
sdkconfig.defaultsedits do not auto-apply to an existing sdkconfig. IDF only regenerates from defaults on a fresh tree. Specifically,CONFIG_LWIP_IRAM_OPTIMIZATION=yandCONFIG_LWIP_TCPIP_TASK_STACK_SIZE=4096are in defaults but the built binary still hasIRAM_OPTIMIZATIONunset and the TCPIP stack at 3072 B. To pick them up, deletesdkconfig(NOTsdkconfig.defaults) and re-build. Worth doing for the IRAM optimization — it should reduce TCPIP-thread latency under load.- The
pstop_responder.pyhost runner was not exercised at the end of this session because the chip kept rolling. Once a build is stable on the bench it'll need to run as before:python3 docs/pstop_responder.py --bind 0.0.0.0 --port 8890. - Tailscale tasks are running at boot. The README in
examples/used to claim they were parked; that's stale. They've been left active since v13 (commit bb49f0d) — the safety chain catches any wedge instead. The toggle endpoints (/api/derp,/api/wg) are still there for manual control during debugging or OTA.
State at hand-off
- Source tree at
master, commitf298899(the v15.1 fix) +6b2cb0a(the handoff banner update). Both pushed locally; not yet to remote. - Last successful build:
examples/dual_core_safety/build/microlink_dual_core_safety.bin(1,162 KiB,0x11bac0). Already OTA'd and running. - Chip on the bench is alive on v15.1 with Tailscale active.
Verified 5-minute soak with no wedge, no rollback, pstop at 10 Hz
with 99.96 % reply rate,
wg_pbuf_fails=0. - Auth keys / Tailscale credentials are in
sdkconfig.credentials(NOT in git).
One-line fix to make if everything else looks fine on the bench
# Force sdkconfig regen so the LWIP_IRAM_OPTIMIZATION and stack-size
# bumps in sdkconfig.defaults actually land. This is the single change
# I'd make if the chip looked stable but I had 60 free seconds.
rm sdkconfig && idf.py build
This will require an OTA upload of the rebuilt binary. Pause
Tailscale first; see recovery_playbook.md.