Summary
The ComCcsds downlink stack has a boot-time race that permanently kills all downlink (telemetry, events, command acks) with zero diagnostics. The one-shot arming comStatus=SUCCESS that moves Svc::ComQueue out of its initial WAITING state can be silently dropped if ComQueue's IPC message queue is full at the moment it arrives — and at boot it frequently is, because every component's startup events land on the async comPacketQueueIn before the comQueue thread first drains. Once the arming message is lost there is no retry path in the protocol: ComQueue sits in WAITING forever, and the board looks like it "ignores all commands" (uplink actually works; the acks/events just never come back).
This was found while bench-validating PR #472 merged with feat/ota-bench: adding CONFIG_FILE_SYSTEM_LITTLEFS=y (the /keys key-store mount) shifted boot timing just enough that the boot event burst reliably won the race. littlefs is not at fault — any change to startup workload (new components, more startup events, slower init) can flip a passing build into a permanently wedged one.
Mechanism (GDB-traced on bench hardware)
- During
setupTopology, startup events flood ComQueue.comPacketQueueIn (async, drop semantics), filling the 20-deep IPC queue before the comQueue thread runs.
ComAggregator::preamble() emits its single arming comStatus=SUCCESS (the ComStub::drvConnected SUCCESS is separately absorbed by the aggregator state machine, so the preamble is the only arming source that reaches ComQueue).
- The generated
comStatusIn_handlerBase does a NONBLOCKING queue send, gets FULL, and hits FW_ASSERT(qStatus == OP_OK). On our deployment the assert hook (Fw::defaultReportAssert) reports to a console that is disabled (CONFIG_LOG=n) and execution continues — the message is simply gone, invisibly.
ComQueue::m_state stays WAITING forever. Captured trace of the fatal sequence: [ENQ comStatus] → [PriorityQueue FULL, size=7] → [assert hook] → permanent WAITING.
Both ComCcsdsUart.comQueue and ComCcsdsLora.comQueue wedge identically.
Reproducibility
Deterministic per-binary, hardware-agnostic — verified on two bench V5e units:
| Build |
Board 1 |
Board 2 |
feat/ota-bench-pr472 pre-fix (littlefs present) |
FAIL every boot (m_state=WAITING) |
FAIL every boot (identical signature) |
| Same + fix below |
PASS 3× (incl. cold reset) |
PASS 2× |
Fix (implemented, on branch feat/ota-bench-pr472, pin bump pending fork push)
Declare comStatusIn with block queue-full semantics in Svc/ComQueue/ComQueue.fpp (fprime fork commit de5b46601, branch fix/comqueue-blocking-comstatus; pinned by c3e21d2eb9b):
async input port comStatusIn: Fw.SuccessCondition block
The status sender (aggregator thread, low-rate) blocks until a slot frees — delivery guaranteed, zero RAM cost, no deadlock exposure (the comQueue thread never blocks back toward the aggregator; it sends at most one in-flight frame per status).
Rejected alternative — deepening the queue: QueueSizes.comQueue at 48/64/100 all exhausted the malloc arena at boot (Svc::BufferManager needs a ~27KB contiguous allocation late in configComponents and faults on the NULL; heap slack on this build is <8KB). Documented in ComCcsdsConfig.fpp. Depth also only re-wins the race probabilistically; block removes it categorically.
Follow-ups
Bench evidence: probe = headless GDS + GET_SEQ_NUM round-trip; broken builds show 0 events and ~12 stray downlink bytes; fixed builds show full SequenceNumberGet/OpCodeCompleted round-trips and flowing telemetry.
🤖 Generated with Claude Code
https://claude.ai/code/session_01WpBURCutAx8281i59nj6fo
Summary
The ComCcsds downlink stack has a boot-time race that permanently kills all downlink (telemetry, events, command acks) with zero diagnostics. The one-shot arming
comStatus=SUCCESSthat movesSvc::ComQueueout of its initialWAITINGstate can be silently dropped if ComQueue's IPC message queue is full at the moment it arrives — and at boot it frequently is, because every component's startup events land on the asynccomPacketQueueInbefore the comQueue thread first drains. Once the arming message is lost there is no retry path in the protocol: ComQueue sits inWAITINGforever, and the board looks like it "ignores all commands" (uplink actually works; the acks/events just never come back).This was found while bench-validating PR #472 merged with
feat/ota-bench: addingCONFIG_FILE_SYSTEM_LITTLEFS=y(the/keyskey-store mount) shifted boot timing just enough that the boot event burst reliably won the race. littlefs is not at fault — any change to startup workload (new components, more startup events, slower init) can flip a passing build into a permanently wedged one.Mechanism (GDB-traced on bench hardware)
setupTopology, startup events floodComQueue.comPacketQueueIn(async,dropsemantics), filling the 20-deep IPC queue before the comQueue thread runs.ComAggregator::preamble()emits its single armingcomStatus=SUCCESS(theComStub::drvConnectedSUCCESS is separately absorbed by the aggregator state machine, so the preamble is the only arming source that reaches ComQueue).comStatusIn_handlerBasedoes a NONBLOCKING queue send, getsFULL, and hitsFW_ASSERT(qStatus == OP_OK). On our deployment the assert hook (Fw::defaultReportAssert) reports to a console that is disabled (CONFIG_LOG=n) and execution continues — the message is simply gone, invisibly.ComQueue::m_statestaysWAITINGforever. Captured trace of the fatal sequence:[ENQ comStatus]→[PriorityQueue FULL, size=7]→[assert hook]→ permanentWAITING.Both
ComCcsdsUart.comQueueandComCcsdsLora.comQueuewedge identically.Reproducibility
Deterministic per-binary, hardware-agnostic — verified on two bench V5e units:
feat/ota-bench-pr472pre-fix (littlefs present)m_state=WAITING)Fix (implemented, on branch
feat/ota-bench-pr472, pin bump pending fork push)Declare
comStatusInwithblockqueue-full semantics inSvc/ComQueue/ComQueue.fpp(fprime fork commitde5b46601, branchfix/comqueue-blocking-comstatus; pinned byc3e21d2eb9b):The status sender (aggregator thread, low-rate) blocks until a slot frees — delivery guaranteed, zero RAM cost, no deadlock exposure (the comQueue thread never blocks back toward the aggregator; it sends at most one in-flight frame per status).
Rejected alternative — deepening the queue:
QueueSizes.comQueueat 48/64/100 all exhausted the malloc arena at boot (Svc::BufferManagerneeds a ~27KB contiguous allocation late inconfigComponentsand faults on the NULL; heap slack on this build is <8KB). Documented inComCcsdsConfig.fpp. Depth also only re-wins the race probabilistically;blockremoves it categorically.Follow-ups
fix/comqueue-blocking-comstatusto the OSSF fprime fork and bump the pin on the mainline branches (currently local-only on the bench).CONFIG_LOG=n,Fw::defaultReportAssertmakes every FW_ASSERT failure invisible while execution continues. An assert-to-FATAL (or assert-to-event) hook would have surfaced this bug immediately.Bench evidence: probe = headless GDS +
GET_SEQ_NUMround-trip; broken builds show 0 events and ~12 stray downlink bytes; fixed builds show fullSequenceNumberGet/OpCodeCompletedround-trips and flowing telemetry.🤖 Generated with Claude Code
https://claude.ai/code/session_01WpBURCutAx8281i59nj6fo