fix(PhoneAPI): convert getFromRadio state recursion to iteration - #10448
fix(PhoneAPI): convert getFromRadio state recursion to iteration#10448cvaldess wants to merge 2 commits into
Conversation
caveman99
left a comment
There was a problem hiding this comment.
Can you please test with a gradient-sync client this actually still works as intended? Apart from that LGTM
|
@caveman99 thanks for the approve. To answer your testing ask: I don't have a gradient-sync-capable client on hand, but the behavior change for gradient-sync clients is zero by construction:
End state in both gradient and non-gradient paths is If anyone reading this has a gradient-sync client handy I'd appreciate a quick smoke test, otherwise I'm comfortable that the diff is behavior-preserving for that path. |
|
FYI re: the failing Timeline:
A new run ( Sorry for the noise, the root issue is in the workflow itself ( |
PR meshtastic#10413 (NodeDB shrink) introduced 4 STATE_REPLAY_* states that each transition via `return getFromRadio(buf);`. When the client did not opt into gradient sync (the standard meshtastic-python CLI path), all four replay states are no-ops but still walked recursively, producing 5-6 nested getFromRadio() frames. Each frame allocates large protobuf locals (`meshtastic_NodeInfo` / `meshtastic_MeshPacket` ~250-400 B), so on platforms with a small task stack the function prologue of the next frame faults during register save → HardFault → watchdog reset. Reproduced on RP2350 + arduino-pico FreeRTOS (CORE0 task stack hardcoded to 1024 words / 4 KB in cores/rp2040/freertos/freertos-main.cpp:149). Symptom: `meshtastic --info` (USB CDC and TCP) drops the connection immediately after the firmware logs `Done sending N of M nodeinfos`. `uxTaskGetStackHighWaterMark()` measured 292 words (1168 B) free at that point — the next recursive frame overflows. Affects any board with BLE excluded that relies on the CLI for configuration. Replace the six recursive `return getFromRadio(buf)` calls inside the state machine with `goto retry_state;` to a label placed just before the switch. The state machine now iterates within a single stack frame, preserving identical observable behavior for clients but eliminating unbounded stack growth. Also scope the LockGuard in STATE_SEND_OTHER_NODEINFOS' done-branch to its own block so it is released before re-entering the switch (a strict no-op given non-recursive concurrency::Lock semantics, but cleaner and documents intent), and add an early skip in STATE_REPLAY_POSITIONS to jump directly to STATE_SEND_FILEMANIFEST when gradient sync is off, avoiding three needless loop iterations on the legacy-client path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
cb711de to
923178c
Compare
|
Closing — superseded by #10459 (thebentern), which removed the recursive The stack-overflow root cause (4 recursive Thanks @caveman99 for the review. |
Summary
PR #10413 (2.8 NodeDB shrink) introduced four new
STATE_REPLAY_*states inPhoneAPI::getFromRadiothat each transition into the next viareturn getFromRadio(buf);. When the client does not opt into gradient sync (the standard meshtastic-python CLI path — i.e.meshtastic --info,meshtastic --set …, etc.), all four replay states are no-ops but are still walked recursively, producing 5–6 nestedgetFromRadio()frames. Each frame locally allocates large protobuf objects (meshtastic_NodeInfoandmeshtastic_MeshPacket, ~250–400 B), so on platforms with a tight task stack the function prologue of the next frame faults during register save → HardFault → watchdog reset.This patch replaces the six recursive
return getFromRadio(buf)calls withgoto retry_state;to a label placed just before the switch. The state machine now iterates within a single stack frame, preserving identical observable behavior for clients but eliminating unbounded stack growth.Reproduction
MESHTASTIC_EXCLUDE_BLUETOOTH(so CLI is the only config path).meshtastic --port COM41 --infoormeshtastic --host <ip> --infofrom any host.[ServerAPI] Done sending N of M nodeinfos millis=…. The first log insideSTATE_REPLAY_POSITIONSnever prints.uxTaskGetStackHighWaterMark()measured 292 words / 1168 bytes free at that point — the next recursive frame overflows.The arduino-pico FreeRTOS port hardcodes the CORE0 task stack to 1024 words / 4 KB (
cores/rp2040/freertos/freertos-main.cpp:149). This is the most exposed platform; targets with larger task stacks (ESP32 typical 8 KB+, nRF52 default 4 KB but with smaller protobuf ABI) are likely to survive but with much less margin than they had pre-#10413.What changed
retry_state:label immediately beforeswitch (state)ingetFromRadio.return getFromRadio(buf);call sites withgoto retry_state;:STATE_SEND_OTHER_NODEINFOSdone-branchSTATE_REPLAY_POSITIONSempty-replay-queue branchSTATE_REPLAY_TELEMETRYempty-replay-queue branchSTATE_REPLAY_ENVIRONMENTempty-replay-queue branchSTATE_REPLAY_STATUSempty-replay-queue branchSTATE_REPLAY_POSITIONSskip-when-no-gradient-sync (added; see below)LockGuardinSTATE_SEND_OTHER_NODEINFOSdone-branch so it is released before the loop iterates back. Today this is a no-op givenconcurrency::Lockis non-recursive and the recursive frame did not actually re-acquire it on the legacy-client path, but the explicit scoping documents intent and prevents future foot-guns.STATE_REPLAY_POSITIONSthat jumps directly toSTATE_SEND_FILEMANIFESTwhenclientWantsGradientSync()is false. Saves three trivial loop iterations on every legacy-client connection.No protobuf, on-disk schema, or wire-format change. Behavior for gradient-sync-aware clients is unchanged — the four replay phases still execute end-to-end, just iteratively.
Test plan
wiznet_5500_evb_pico2_e22p(RP2350 + W5500 Ethernet, BLE excluded).meshtastic --port COM41 --infoandmeshtastic --host 192.168.1.x --infoboth complete normally — full config dump returned, no reset, full owner / preferences / module-config / nodes printed.--setcommands accepted and persisted; reboot retains config.93cc59e(rp2040, rp2350, nRF52840, esp32/s3/c3/c6, stm32, native, docker matrix).Notes
🤖 Generated with Claude Code