fix(sdcard): distinguish a timed-out SD LIST from an empty card (closes #396) - #400
Conversation
#396) GetSdCardFilesAsync returned an empty list both when the SD card was genuinely empty and when the device never answered, so an unreachable device holding data rendered downstream as "SD card OK, 0 files". The firmware emits no end-of-listing marker and writes nothing at all for an empty directory, so the two cases are byte-for-byte identical on the wire and cannot be told apart from the LIST reply alone. Core now appends a SYSTem:ERRor? query to the same text exchange and uses its reply as a terminator: the transport is ordered and the firmware does not process the next command until the listing has been handed to the output, so the reply proves both that the device is answering and that the listing ahead of it arrived in full. A missing terminator raises SdCardListIncompleteException instead of a plausible-looking empty list, which also catches the truncated listing that no downstream corroboration can detect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoFix SD LIST timeout vs empty-card by adding an in-band terminator probe
AI Description
Diagram
High-Level Assessment
Files changed (6)
|
|
/agentic_review |
Code Review by Qodo
1. Stale lines still leak
|
…on window Addresses both Qodo findings on PR #400. 1. Stale terminator false-completes. A late reply to an earlier command can land in the window between the text consumer binding and the setup action sending anything, and the SD listing would accept it as its end-of-listing terminator -- letting a silent device pass as a healthy empty card, the very bug #396 is about. ExecuteTextCommandCoreAsync now marks that boundary and excludes anything received before the exchange sent a thing. It cannot be a response to a command that had not yet gone out, so this is correct for every caller, not just the listing. 2. Terminator missed by the completion window. The listing exchange kept the 250ms default, so a terminator trailing the last listing line by more than that would read as missing and fail a listing that was about to complete. The exchange now uses a dedicated 1s window. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 918c32c |
… exchange Addresses the round-2 Qodo finding: the stale-line boundary is taken before the setup action runs, so a gap INSIDE that action still left a window where a late reply to an earlier command could be mistaken for this listing's terminator. For the listing that gap was the 100ms SPI settle delay. Doing the switch and its settle before the exchange opens leaves the exchange with no internal gap at all -- its first act is now the LIST query itself, so the boundary collapses to thread scheduling rather than a timed wait. From the device's side the delay is unchanged, if anything longer, since the consumer swap now follows it. Bench: listing returns all 31 files across three consecutive runs; SD storage and a 10 Hz two-channel stream unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit a82c3af |
…elays The settle delay kept ConfigureAwait(false) while it lived inside the setup action lambda and lost it when moved out in a82c3af. Restored, and applied to the retry-gap delay in the same loop for consistency: neither wait has any reason to resume on a captured synchronization context, and doing so makes a sync-over-async caller more deadlock-prone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 2d7952f |
The delete path still had the exact pattern removed from the listing in a82c3af: PrepareSdInterface() plus an awaited settle delay INSIDE the setup action, at both the first-attempt and retry call sites. That gap is a window in which a late reply to an earlier command is captured as part of this response. The consequence here is milder than the listing's -- delete keys off ContainsScpiError, so a stale error line causes a spurious delete-and-relist retry rather than an incomplete listing read as complete -- but it is the same defect, so it gets the same fix. These were the last two async setup actions in the file; every call site is now a synchronous lambda with no internal gap. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 0c2f9c0 |
|
Post-merge note, for anyone reading the two findings that Qodo left unmarked in the accumulated review body. Both have a verified position; neither is an open bug at "Stale lines still leak" — not a defect at the merged commit. The finding's quoted evidence is that the listing setup action "sends preparatory commands and then "Unsynchronized SD interface switch" — real, and NOT fixed by this merge. Qodo rated it Weak, but the mechanism holds and I now think it was underrated. Closing the stale-terminator window opened an interface-interleaving one. Reachable, since concurrent text operations are exactly what Fixed in #406, which splits the setup rather than choosing between the two: a prepare phase that runs inside the lock and ahead of the stale-line boundary, so the switch stays serialized and the setup action stays gap-free. Bench-verified on the Nyquist 1, full suite green on net9.0 and net10.0. #406 also flags one thing neither PR addresses: the restore side ( |
Problem
GetSdCardFilesAsyncreturned an empty list both when the SD card was genuinely empty and when the device never answered, so an unreachable device holding data rendered downstream as "SD card OK · 0 files" (daqifi-avalonia#2).Which option, and why none of the three worked as written
The issue offered three options. Checking them against the firmware source (
daqifi-nyquist-firmware,sd_card_manager.c/SCPIStorageSD.c) changed the answer:Options 1 and 2 as stated are wrong — they would make a genuinely empty card throw.
ListFilesInDirectoryChunkedonly callssendChunkwhenstrBuffIndex > 0, so an empty directory produces zero bytes, andSCPI_StorageSDListDirwrites no SCPI result of its own. A phase-1 timeout with zero lines collected is therefore exactly what a healthy empty card looks like. "The device never answered" and "the card is empty" are byte-for-byte identical on the wire, and no amount of timeout bookkeeping insideExecuteTextCommandCoreAsynccan separate them.Option 3 as stated is also out — the firmware emits no end-of-listing marker. Verified on the bench (raw SCPI probe below): the listing simply stops.
What shipped: option 3, with Core supplying the terminator the firmware lacks.
GetSdCardFilesAsyncappends aSYSTem:ERRor?query to the same text exchange, right afterSYSTem:STORage:SD:LIST?, and treats its reply as the end-of-listing marker. The transport is ordered andSCPI_StorageSDListDirblocks insd_card_manager_WaitForCompletionuntil every chunk has been handed to the output, so the reply arriving proves both that the device is answering and that the listing ahead of it arrived in full. Its absence raises the newSdCardListIncompleteExceptioninstead of a plausible-looking empty list.That also fixes the truncation case the issue calls out as unreachable by any downstream mitigation: corroborating with a later
GetSdCardStorageAsyncproves the device answered a subsequent request, never that this listing was complete. A missing terminator does.The terminator's error code is used only as a liveness marker, never for classification — the queue it pops can hold entries from earlier commands. Documented side effect: each listing now consumes one SCPI error-queue entry.
Caller audit of the text path
The blast radius is deliberately narrow. In particular the empty-reply contract of
ExecuteTextCommandCoreAsyncis untouched, which matters because that is a genuinely unsafe thing to change globally — several callers depend on an empty reply being the success case:DaqifiDevice.InitializeAsync(echo off / stop / power on / protobuf format)DaqifiStreamingDevice.OnDeviceInitializingAsync(SetStreamInterface)DaqifiDevice.DrainErrorQueueAsyncGetSdCardStorageAsync,GetLanChipInfoAsync, and the other query helpersDeleteSdCardFileAsync(delete + LIST)_sdCardFilesGetSdCardFilesAsyncsrc/Daqifi.Mcpdoes not callGetSdCardFilesAsync, and no doc depends on empty-on-timeout.Review round 1 did add one shared change to
ExecuteTextCommandCoreAsync— dropping lines that predate the exchange's own commands (see below). That is additive for every caller in the table: none of them can want a reply to a command they had not yet sent, and all of the above were re-benched afterwards.Deliberate scope boundary:
DeleteSdCardFileAsyncre-lists to refresh the cache and has the same latent ambiguity, but resolving it means deciding what a lost reply says about whether the delete succeeded — a different question — and it cannot be bench-verified non-destructively on the shared board. Left exactly as it is onmain; no regression either way.Bench evidence — DAQiFi Nyquist 1, firmware 3.7.2,
/dev/cu.usbmodem1101Example CLI built against this worktree's
Daqifi.Core.Real listing still returns in full and does not throw — 31 files, and the terminator is not parsed as a phantom entry:
Raw SCPI probe (read-only; SD enable → list → LAN restore) confirming both halves of the design:
The reply is the bare
<code>,"<message>"form the matcher expects, and it lands after every listing line. Also re-ran--sd-storage(unchanged) and a 10 Hz / 2-channel stream (5 messages, clean stop), then--sd-listagain — still 31 files. Nothing destructive: no format, delete, flash, reboot or WiFi change.The silent-device and truncated-listing paths are covered by unit tests rather than the bench — provoking them on real hardware needs an unplug mid-transfer.
Tests
src/Daqifi.Core.Tests, full suite green on net9.0 and net10.0 (1958 passed, 0 failed), build clean with 0 warnings.SdCardListIncompleteException, and the previously-cached listing is not overwrittenRawDeviceResponseScpiResponseClassifier.IsSystemErrorReplyLinematch/non-match table, including<path> <size>listing entries and**ERROR:-prefixed linesDaqifiDeviceStaleTextLineTests)The SD test doubles now model the device rather than replaying canned lines verbatim: they answer
SYSTem:ERRor?when the exchange asked for it, with anUnterminatedAttemptsknob to simulate a device that went silent.Siblings
Companion to #394 / #395 / #398, worked in parallel. No semantic overlap: #395 owns the typed connectivity guards at the top of
ExecuteTextCommandCoreAsync, #398 owns the GET-side empty-transfer ambiguity inSdCardFileReceiver. This PR touches neither: the only change insideExecuteTextCommandCoreAsyncis to the timeout/return semantics of its body (the stale-line boundary from review round 1), not to the connectivity guards at its top. Textual conflicts inDaqifiStreamingDevice.csare possible.Review round 1 (Qodo)
Two bugs found, both valid, both fixed in
918c32c.Stale terminator could false-complete. Content alone cannot separate a stale terminator-shaped line from a fresh one — that is the same ambiguity this PR is about, one level up. What separates them is position relative to our own commands, so
ExecuteTextCommandCoreAsyncnow marks the boundary: it snapshots the collected-line count immediately before the setup action runs and drops those lines from the result. A line captured before the exchange sent anything cannot be a response to a command that had not yet gone out, so this is right for every caller, not just the listing — and it needs no signature change. Covered byDaqifiDeviceStaleTextLineTests, which releases the stale line at the exact moment the exchange binds its text consumer (keyed off theStreamproperty access, so no timing dependence); verified to fail without the fix.This is the one place the change reaches outside
GetSdCardFilesAsync, and it is the timeout/return semantics of the exchange body rather than the connectivity guards at its top — still disjoint from #395.Terminator could be missed by the completion window. Requiring a reply while keeping the 250ms inactivity default was the wrong trade — the firmware walks the directory tree between chunks. The listing exchange now uses a dedicated
SD_LIST_COMPLETION_TIMEOUT_MS = 1000; other text queries keep the default, having no terminator to wait for.Re-benched after both fixes: listing still returns all 31 files, and
--sd-storage,--lan-chip-infoand a 10 Hz two-channel stream all still pass — the last three specifically to prove the shared-exchange change did not regress the non-SD text paths. Full suite green on net9.0 and net10.0 (1958 passed), 0 warnings.Review round 2 (Qodo)
One bug, valid, fixed in
a82c3af.Stale lines could still leak through a gap inside the setup action. The round-1 boundary only rules out lines that predate the setup action, not ones arriving during a gap within it — for the listing, the 100ms SPI settle delay.
PrepareSdInterface()and that delay now run before the exchange opens, so the exchange has no internal gap and its first act is the LIST query; the boundary collapses from a timed wait to thread scheduling. From the device's side the delay is unchanged, if anything longer, since the consumer swap now follows it.I did not take the suggested
Send()-based gate: it flips on the first outbound write, and the listing setup action starts by sending the prep commands, so it would already be true before the settle delay — leaving the exact window it was meant to close. Details on the thread.Re-benched: 31 files on three consecutive listings,
--sd-storageand a 10 Hz two-channel stream unaffected.Review rounds 3-4 (Qodo)
Round 3 — the settle delay lost its
ConfigureAwait(false)when round 2 moved it out of the setup action lambda. Restored in2d7952f, and applied to the retry-gap delay in the same loop.Round 4 — Qodo re-raised the stale-line finding, but its quoted evidence ("the setup action ...
awaits a settle delay before sending the LIST + terminator") no longer matches the code: that has not been true sincea82c3af. Verified and explained on the thread.The general point was still live elsewhere, though. Grepping every
ExecuteTextCommandAsynccall site turned up the identical pattern inDeleteSdCardFileAsync, at both its first-attempt and retry sites — the last twoasync ct =>setup actions in the file. Fixed the same way in0c2f9c0; every call site is now a synchronous lambda with no internal gap. The consequence there was milder than the listing's: delete keys offContainsScpiErrorrather than a terminator, so a stale error line would have caused a spurious delete-and-relist retry rather than an incomplete listing being accepted.This supersedes the scope note above: the delete path's gap is now fixed. What is still deliberately left alone there is the terminator itself, for the reason given — a lost reply's meaning for whether the delete succeeded is a separate question.
The delete path is deliberately not bench-verified: confirming it on hardware means actually deleting a file from the shared bench card. The reorder is byte-identical to the one validated on the listing path against real firmware.
closes #396
Not merging — opened for review.
🤖 Generated with Claude Code