Skip to main content

⚠️ Historical. This report claims the wedge was root-caused as a dead enable_disco flag in v15.1. That fix was partial — the real root causes (bc_clear + IDF ping task-stack overflows and an lwIP cross-thread-safety bug) were caught later via UART0 serial debug and fixed in v15.20–v15.26. See tailscale_final_status.md and troubleshooting.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_disco was a dead config flag in the upstream microlink library — the field is stored on ml->config and propagated through ml_app.c, but no code in ml_wg_mgr.c / microlink.c / ml_coord.c ever reads it back at runtime. With max_peers=16, the chip was firing periodic DISCO probes against every peer every second regardless of what dual_core_safety/main.c requested. 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() in components/microlink/src/ml_wg_mgr.c now early-returns when ml->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_SIZE to Kconfig; only the project-CMakeLists target_compile_definitions route works).

What ended in the source tree​

FileChange
components/microlink/src/ml_wg_mgr.cearly-return from disco_periodic_probes() when enable_disco is false
components/microlink/components/wireguard_lwip/src/wireguardif.cwireguardif_pbuf_alloc_fails counter incremented at every pbuf_alloc(...,PBUF_RAM) NULL return
components/microlink/components/wireguard_lwip/src/wireguardif.hextern volatile uint32_t wireguardif_pbuf_alloc_fails
examples/dual_core_safety/CMakeLists.txttarget_compile_definitions(lwip_lib PRIVATE PBUF_POOL_SIZE=48)
examples/dual_core_safety/main/CMakeLists.txtadded wireguard_lwip to REQUIRES
examples/dual_core_safety/main/main.cs_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.defaultsCONFIG_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.

  1. First build with all changes (max_peers=4 + the rest above) crashed reliably enough that the 4-strike boot counter triggered esp_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.
  2. Second build reverted max_peers back to 16 and softened the heap_caps_get_free_size() call to heap_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_count cleared automatically at +2 min, indicating the safety chain considers the build healthy. pstop 10 Hz, ~99.96 % reply rate, 0 pstop_send_fail over the entire window. wg_pbuf_fails stayed 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_fails stays 0 across a 5-min uptime window
  • heap_min_int floor is ≫ 50 KiB and stable
  • pstop_sent and pstop_replies both grow at 10/s
  • boot_count stays 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.defaults edits do not auto-apply to an existing sdkconfig. IDF only regenerates from defaults on a fresh tree. Specifically, CONFIG_LWIP_IRAM_OPTIMIZATION=y and CONFIG_LWIP_TCPIP_TASK_STACK_SIZE=4096 are in defaults but the built binary still has IRAM_OPTIMIZATION unset and the TCPIP stack at 3072 B. To pick them up, delete sdkconfig (NOT sdkconfig.defaults) and re-build. Worth doing for the IRAM optimization — it should reduce TCPIP-thread latency under load.
  • The pstop_responder.py host 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, commit f298899 (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.