Crash Hypothesis Catalog
Date: 2026-06-22.
Generated by a 4-angle read-only audit (concurrency, memory/OOM/stack, lwIP/socket/abort,
lifecycle/safety-ladder). Ranked by severity × likelihood. Status legend:
triggerable = reproducible from outside via network/admin; power-cycle = needs physical
power events; instrument = needs a test build to provoke; code-proven = the defect is
evident from source and a fix is justified without a live repro.
Test plan: after the soak, a single serialized hardware-test agent confirms the triggerable
items on the chip (capturing rst_hist + /api/last_log). code-proven items get fixed by
inspection. Then rebuild + OTA + re-validate.
CRITICAL — net_liveness false-abort → rollback ladder (3 of 4 agents converged)
The deliberate abort() watchdog (dcs_net_liveness.c) is the single largest crash/regression
surface. Its purpose is to catch a wedged lwIP stack, but its false-positive surface includes
ordinary upstream outages, and a false fire feeds the boot-count → OTA-rollback chain.
- H1 — pstop cross-check is structurally absent when pstop never bonded.
abort()is vetoed only by a fresh pstop reply, butg_dcs_pstop_last_reply_msstays0untilbond_with_retry()(infinite retry, main.c) first succeeds. If the pstop machine is down (maintenance / wrong peer) the veto (ps_last > 0) is permanently false, so a gateway ICMP-silence >90s aborts a fully healthy chip. (dcs_net_liveness.c:216-230) — triggerable - H2 — abort@90s vs boot-count-clear@120s is unsurvivable. The watchdog can fire ~90s
after boot, but
bc_clear_taskneeds 120s of continuous healthy uptime to zero the crash counter. A persistent/slow upstream outage therefore marches boot_count 1→2→3→4 and tripsesp_ota_mark_app_invalid_rollback_and_reboot(). Directly contradicts the stated robot reality ("Ethernet takes minutes to come up"). (dcs_safety.c vs dcs_net_liveness.c:42) — triggerable / by-design - H3 — "armed" state is sticky across a failover.
retarget_ping()re-seeds the silence clock but never re-zeross_ping_reply_count, so after Eth→WiFi/USB the watchdog stays armed; failing over to a gateway that filters ICMP arms a 90s death timer against a healthy link. (dcs_net_liveness.c:139-179, 200) — triggerable - H4 — retarget_ping has NO generation guard (asymmetric with net_inet's fix). A late
on_ping_successfrom the old gateway session can land after retarget and stamps_last_ping_ok_msfresh — disarming the watchdog when it should fire (credits the new, possibly-dead gateway with the old one's reply). (dcs_net_liveness.c:139-179) — code-proven
HIGH — rollback-ladder robustness
- H5 — ESP_RST_BROWNOUT counts toward the ladder. A PoE/USB power-quality event that browns out 4× before the 120s clear → OTA rollback for a non-firmware reason. (dcs_safety.c:34) — power-cycle / code-proven
- H6 — rollback boots a possibly-stale slot. 2-slot table, no factory, anti-rollback
disabled →
mark_invalid_rollbackboots the other slot with zero version protection; theother_invalidguard only catches INVALID/ABORTED, not stale-but-valid. (partitions.csv, sdkconfig) — code-proven / config - H7 — 120s mark-valid→clear window. Running image is marked valid almost immediately (ml_app), but the crash counter persists 120s regardless of health, so a crash in that window on an otherwise-healthy boot still counts. (dcs_safety.c:104) — code-proven
HIGH — concurrency / lifecycle
- H8 — unsynchronized
vTaskSuspend(g_dcs_wg_handle). The shutdown hook + api_usb_enable- boot-finalize all suspend ml_wg_mgr with no coordination with
LOCK_TCPIP_CORE(), which wg_mgr holds across decrypt/ip_input/periodic. Suspend mid-critical-section freezes the lwIP core lock for the whole system → restart hang/panic (the documented "panic in the 500ms window"). Plusg_dcs_wg_handleis cached once and never revalidated → suspend-on-freed-TCB if wg_mgr was recreated. (dcs_support.c:46-48,180,211; dcs_admin_pages.c:385) — triggerable
- boot-finalize all suspend ml_wg_mgr with no coordination with
- H9 —
dcs_usb_set_enabledhas no busy guard (asymmetric withdcs_wifi_set_enabled's CAS). The supervisor (net_sup) and an admin POST can drivetry_start/stopconcurrently →esp_netif_newoverlappingesp_netif_destroyons_netif→ UAF/double-free. (dcs_boot.c:109-120) — triggerable
HIGH — OOM / stack (the 21 KB internal-heap floor)
- H10 —
g_done[]semaphores andg_core_h[]tasks created without NULL checks. Under the 21 KB floor (WiFi + USB resident + DERP TLS re-handshake, all internal heap becauseCONFIG_MBEDTLS_INTERNAL_MEM_ALLOC=y), creation can fail →xSemaphoreGive(NULL)/xTaskNotifyGive(NULL)in the SAFETY lockstep path → assert/panic. (main.c:435,451) — instrument / code-proven - H11 —
net_liveness_taskstack is 2560 B but callsesp_ping_new_sessioninline (sets up socket + ping task + ipaddr_ntoa + ESP_LOG). The code's own comment bumped the ping task to 4096 for the same reason; the supervisor task driving it did not get the headroom. A gateway-change-heavy path can overflow → canary panic. (dcs_net_liveness.c:193,237) — triggerable
MEDIUM
- H12 — pstop
open_socket() == -1→comparator_taskself-deletes with no retry → the safety heartbeat is dead until reboot (fail-safe but unrecoverable). Reachable via fd exhaustion (16 sockets; ~12 steady + ping-session recreate churn). (main.c:291-296) — instrument - H13 — no
microlink_rebind()on failover (CONFIG_ML_ENABLE_NET_SWITCHunset) → WG/DERP sockets stay on the stale interface after Eth→USB/WiFi → traffic black-holes until DERP self-heals; can manufacture the pstop-silence precondition for H1. (ml_wg_mgr.c, dcs_net_supervisor.c) — triggerable - H14 — comparator stale-
g_donewindow. Cores run at the SAME priority (8) as the comparator, so a lateg_donegive can land after the drain → spurious mismatch (fail-safe send-nothing) or a torn-buffer memcmp. (main.c:333-344) — instrument - H15 —
s_rx_lock(the new mutex) vs core-lock order.on_usb_rxholdss_rx_lockthen takes the core lock; if wg_mgr is suspended while holding the core lock (H8),on_usb_rxholdss_rx_lockforever →ml_dev_tether_stop()hangs. Compound with H8. (ml_dev_tether.c) — triggerable (compound) - H16 —
on_usb_rxper-packetmallocunder the 21 KB floor → RX drops → pstop loss → feeds H1. Heap-pressure amplifier now that USB-NCM is permanently resident. (ml_dev_tether.c:81) — triggerable (amplifier)
Remediation decisions (settled with user, 2026-06-22)
net_liveness / rollback cluster (H1–H7) — agreed design:
- net_liveness KEEPS
abort()(preserves the panic backtrace for diagnosis), but made conservative:- Arm only after BOTH the gateway has replied AND pstop has bonded at least once. A never-bonded pstop => stay DISARMED (degraded/provisioning, not a wedge). Closes H1.
- Raise
NET_LIVENESS_TIMEOUT_MSabove the 120 s boot-count-clear window. Addresses H2 timing. - Require the active netif UP with a valid (non-link-local) lease for the whole window; disarm on link/lease loss.
- Reset the gateway-arm on failover/retarget (H3); add the generation guard mirroring net_inet (H4).
- Rollback ladder counts ONLY genuine code faults (H5/H6/H7): exception PANIC, INT/TASK WDT,
lockstep failure. EXCLUDE
ESP_RST_BROWNOUTand deliberate net_liveness wedge-aborts. net_liveness sets an RTC_NOINIT marker beforeabort()sodcs_safety_account_bootdistinguishes its deliberate reboot from a real crash and does NOT count it toward the 4-strikes rollback. Net effect: a network/power/upstream condition can never downgrade a deployed safety device; only a bad image can. (The wedge-abort still reboots to recover and still logs a backtrace.)
Other items: H8 (wg-suspend), H9 (usb busy-guard), H10 (safety-task NULL checks), H11 (net_live stack) to be fixed straightforwardly. H12–H16 assessed during the fix pass.
HIL test + fix outcomes (2026-06-22/23)
- H1 — CONFIRMED on hardware, FIXED. Chip abort()ed on gateway+pstop silence while HTTP served 200 (healthy lwIP). Fix shipped (66dc2a8): arm-on-both, 180s timeout, gen-guard, reset-arm-on-failover; abort excluded from rollback.
- H9 — CONFIRMED on hardware, FIXED. Supervisor + admin USB bring-up at the same timestamp -> panic. Busy-guard added (66dc2a8); usb-hammer-alone (50×) and eth-flap-alone both clean afterward.
- Compound-stress crash (eth-flap + 2/sec usb-hammer simultaneously) — crashed twice on the session-worn 66dc2a8 image, but does NOT reproduce on a clean, cold-booted image (2 aggressive attempts ~130s). Most likely OOM under accumulated heap pressure (~18KB floor) + compound churn — beyond-spec, beyond the passing 2h soak. Flash coredump now armed to backtrace it if it ever recurs.
- Coredump-to-flash enabled (f5b189c/201562e) —
idf.py -p /dev/ttyACM1 coredump-inforeads any future panic's ELF backtrace remotely. - Cold-boot validated (#6 partial): power-on reset_reason=1, OTA valid, NVS preserved across the repartition full-flash.
- New HW finding: aggressive
/api/iface/ethflapping can wedge the W5500; only a power-cycle recovers it (ESP soft-reset doesn't reset the SPI eth chip). - Still unverified empirically: the net_liveness genuine-wedge abort + boot_count=0 exclusion (code-clear, boots clean; no live abort test run yet).
Items checked and judged NOT bugs
/state.jsonbuilder: overflow-proof (PSRAM malloc + internal fallback + CLAMP_N).- NVS readers: default gracefully; reset-history ring + pstop_unit_num correctly bounded.
- AUTOIP
0xFEA9mask: correct for esp_netif's network-order storage. - WireGuard/DERP pbuf paths: no reachable UAF/double-free (single-task, freed once).
- Both-partitions-invalid brick: defended by the
other_invalidguard (residual is H6 stale-but-valid).