fix(sdcard): run the SD bus switch as a prepare phase inside the exchange lock - #406
Conversation
…ange lock Hoisting PrepareSdInterface() above ExecuteTextCommandAsync in a82c3af closed a stale-terminator window but opened an interface-interleaving one: the SPI switch had been running inside _textExchangeLock, and outside it a competing text exchange can restore the LAN interface between the switch and the LIST, leaving the listing to run against the wrong interface. Both properties are wanted, so split the setup rather than choose. A new ExecuteTextCommandWithPrepareAsync seam runs a prepare phase inside the lock and before the consumer swap, so: - no other exchange can interleave between the switch and the sends, and - the settle wait sits ahead of the stale-line boundary, so the setup action is still gap-free. Both listing and delete call sites share PrepareSdInterfaceAndSettleAsync. Bench (Nyquist 1, fw 3.7.2, USB): 31 files on repeated listings, SD storage and a 10 Hz two-channel stream unaffected. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoFix SD card SPI switch by adding prepare phase inside text exchange lock
AI Description
Diagram
High-Level Assessment
Files changed (4)
|
Code Review by Qodo
1.
|
The parallel ExecuteTextCommandWithPrepareAsync bypassed subclass overrides. It called the core directly, so a subclass overriding ExecuteTextCommandAsync silently stopped intercepting SD LIST and DELETE -- no compile error, no runtime signal, just an instrumented device or test double quietly missing SD traffic. The three new overrides the test fakes needed were the tell. Prepare is now an optional parameter on the existing virtual, so overrides catch every SD operation again. Placed after cancellationToken with CA1068 suppressed, matching IFirmwareUpdateService. Overriders must widen their signature -- a compile error, which is the point: loud beats silent, and it is the same defect class this series has been retiring. The lock ordering is unchanged: prepare still runs inside _textExchangeLock and ahead of the stale-line boundary, both of which live in the core. Adds a test that fails if the seam ever splits in two again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit a002460 |
#406 added an optional prepareAsync parameter to the virtual ExecuteTextCommandAsync so a subclass cannot silently stop intercepting SD operations. C# requires an exact parameter-list match to override, so this fake failed with CS0115 once main landed — the compile error the seam is designed to produce. Widen the override and honor the prepare phase the way the real device does (it runs first, before anything the exchange sends), matching the fakes updated in #406. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The merge of main left TestableRetryDownloadDevice seeding ListingLines into the wrong exchange overload, so all four size-plumbing tests read an empty, unterminated listing and failed with SdCardListIncompleteException. - #406 moved the SD bus switch into the exchange's prepareAsync phase, so GetSdCardFilesAsync now drives the listing through the Action overload, not the async-setup one. The listing is served from there now. - #400 terminates the listing with SYSTem:ERRor?. Both overloads answer it via the shared SdCardTestResponses.AnswerErrorQuery helper, matching TestableSdCardStreamingDevice, and the fake gains UnterminatedAttempts. Also pins the semantics this interacts with, rather than only greening: - GetSdCardFilesAsync_ListTerminator_IsNotParsedAsAFileEntry. The stripping in TrySplitAtSdListTerminator is load-bearing for gap 2: IsErrorResponseLine matches only **ERROR/ERROR, so 0,"No error" is NOT filtered by the parser and would split into a phantom file with a null size, which would then be handed to the receiver as a legitimate empty download. - GetSdCardFilesAsync_UnterminatedFirstAttempt_RetriesThenKeepsSizesIntact and DownloadSdCardFileAsync_AfterRetriedListing_StillDownloadsZeroByteFile WithoutRetrying. The two retry loops are on different operations (#400's around the LIST exchange, gap 2's around the transfer) and do not compound: a retried listing still yields size 0 and the download completes on its first GET. Production code unchanged. Full suite green net9.0 + net10.0 (2170 passed). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reconciles #399's bounding work with #400/#402/#403/#404/#405/#406. - SdCardFileReceiver: keep main's typed SdCardTransferStalledException / SdCardEmptyTransferException throws (#405) and add this branch's unique per-iteration token.ThrowIfCancellationRequested(). Both branches had added the same timeout-vs-cancellation catch guard; main's typed version is kept rather than duplicated. A cancelled transfer still surfaces as OperationCanceledException rather than a stall. - DaqifiStreamingDevice: this branch's hard deadline, LongRunning worker and one-download-at-a-time gate now carry main's listed-size plumbing (TryGetListedFileSize -> receiver) alongside the remaining-budget retries. - SerialStreamTransport: keep both the operational WriteTimeout bounding and main's watchdog/PortPresenceProbe seam (#403). - ISdCardOperations: keep both doc sets — typed exceptions and the deadline/abandonment contract. - Tests: take main's SD test files (shared SdCardTestResponses terminator helper, listed-size cases) and re-apply this branch's parked/slow/gate tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Resolves against #415 (connection-loss detection and the device ErrorOccurred surface) and #417 (SD->LAN restore inside the exchange lock, which added a finalizeAsync phase to ExecuteTextCommandAsync). One real conflict, in DaqifiDeviceInitializeTests: #417 wrapped the testable device's ExecuteTextCommandAsync body in try/finally to honor the new finalize phase and re-indented it, while this branch had inserted a MutateDuringInitialization hook between the prepare phase and setupAction. Kept both — the hook now sits inside the new try block, still after prepare and before setupAction, so the two overlapping-initialization tests still mutate state at the intended point. The two test doubles this branch added (OverlappingInitDevice and CancelDuringCapabilityReadDevice) also had to widen their ExecuteTextCommandAsync overrides for the finalizeAsync parameter, and now honor the finalize phase the way the other doubles do. That compile break is the seam from #406 working as designed. Verified nothing from main was dropped: the only deletions relative to origin/main are this branch's three intended OnDeviceInitializingAsync signature changes. #415's ErrorOccurred wiring, OnConsumerErrorOccurred subscription and the Connected->Lost transition, and #417's finalizeAsync phase are all intact, as are this branch's PreserveActiveStream command skipping and the pre-Ready cancellation guard. Full suite green on net9.0 and net10.0 (2246 Core + 23 MCP).
Follow-up to #400 (merged as c2d5da8). This fix was ready minutes after that merge landed, so it needs its own PR.
The problem this fixes
#400 went through several review rounds on where the SD card's SPI bus switch should happen relative to the text exchange. The last move — hoisting
PrepareSdInterface()and its settle delay aboveExecuteTextCommandAsync— closed a stale-terminator window but opened a different one, and that is what merged.ExecuteTextCommandCoreAsyncruns its setup action while holding the device-wide_textExchangeLock. The bus switch used to run inside that lock, because it lived inside the setup action. Hoisting it moved it outside:The device answers a LIST it cannot service, and the listing fails or retries for no reason. Concurrent text operations are an expected scenario —
_textExchangeLockexists precisely because of #186 — so this is reachable, not theoretical.Why not just move it back
Moving the switch back inside the setup action reinstates the window #400 spent two rounds closing: the settle wait between the switch and the LIST sits after the exchange's stale-line boundary, so a late reply to an earlier command arriving during it is captured as part of this response — and for the listing that means a stale
SYSTem:ERRor?reply can pass for the end-of-listing terminator, letting a silent device read as a healthy empty card. That is the original #396 bug.Both properties are wanted, so this splits the setup instead of choosing between them.
The fix
The existing virtual
ExecuteTextCommandAsyncgains an optionalprepareAsyncphase that runs inside the lock and before the consumer swap:Send()calls, no awaits.GetSdCardFilesAsyncand bothDeleteSdCardFileAsynccall sites share onePrepareSdInterfaceAndSettleAsync.Subclassing impact — this one does break overrides, on purpose
The first version of this PR added a parallel
ExecuteTextCommandWithPrepareAsyncand claimed nothing broke. That was wrong, and Qodo caught it: the parallel method called the core directly, so a subclass overridingExecuteTextCommandAsyncsilently stopped intercepting SD LIST and DELETE — no compile error, no runtime signal. The three extra overrides my own test fakes suddenly needed were the tell; anything outside this repo would just have stopped seeing SD traffic.So there is one seam, and the honest statement of the cost:
prepareAsyncis optional and sits aftercancellationToken(CA1068 suppressed, matching the convention inIFirmwareUpdateService), so existing positional calls still compile.ExecuteTextCommandAsync(Action, int, int, CancellationToken)override fails with CS0115 untilFunc<CancellationToken, Task>? prepareAsync = nullis appended. Ten in this repo; any downstream subclass — desktop, the Avalonia port, consumer test doubles — will hit the same.That break is the point. A compile error that says "add this parameter" is strictly better than an override that quietly stops intercepting, which is the defect class this whole series has been retiring. An override that accepts the parameter and ignores it is still correct for a fake, since there is no real SPI bus to switch.
Only the
Actionoverload takes the parameter. TheFunc<CancellationToken, Task>overload has no caller needing a prepare phase, and adding it there would break another eight overrides for no benefit.Known gap, deliberately not addressed here
The restore side is still unsynchronized:
PrepareLanInterface()runs in each SD method'sfinally, outside the lock, so a competing exchange's restore can still fire during another exchange. That predates #400 — it is onmaintoday and is not something these changes introduced. Fixing it needs a symmetric finalize seam that still runs when the exchange itself throws, which is a larger change than belongs in a follow-up. Flagging it explicitly rather than leaving it implied; happy to file it separately.Tests
Full suite green on net9.0 and net10.0 (1961 passed, 0 failed), build clean with 0 warnings.
Three new tests in
DaqifiDeviceStaleTextLineTests:Every test double now honors the prepare phase rather than ignoring the argument.
Bench
DAQiFi Nyquist 1, firmware 3.7.2,
/dev/cu.usbmodem1101, example CLI built against this branch: listing returns all 31 files across repeated runs;--sd-storageand a 10 Hz two-channel stream unaffected. Nothing destructive — no format, delete, flash, reboot or WiFi change.The delete path is deliberately not bench-verified: confirming it on hardware means deleting a file from the shared bench card. Its prepare phase is the identical helper the listing uses.
Refs #396. Follow-up to #400.
Not merging — opened for review.
🤖 Generated with Claude Code