fix(sdcard): run the SD→LAN restore inside the exchange lock, not after it (closes #407) - #417
Conversation
…xchange lock The SD operations switched the shared SPI bus to the card inside the text-exchange lock (the prepare phase added in #406) but restored it to LAN from each method's own finally, after the lock had been released. The switch was serialized; the matching restore was not, so a competing exchange could run between an SD command and its restore, or observe the bus mid-restore. ExecuteTextCommandAsync's Action overload gains a symmetric finalizeAsync phase. It runs under the same lock acquisition as the prepare phase, after the protobuf consumer has been restarted, and the exchange owns a try/finally around it so it runs however the exchange ended. If the exchange failed and the finalize fails too, the finalize failure is logged and the exchange's original failure is what the caller sees. If the exchange succeeded, the finalize failure is the only failure and it propagates - but only after the lock has been released, so a failed restore cannot also wedge the device. GetSdCardFilesAsync, DeleteSdCardFileAsync and GetSdCardStorageAsync now pass the restore as that phase; the storage query's switch also moves from its setup action into the prepare phase, matching its siblings and dropping a blocking Thread.Sleep. DownloadSdCardFileAsync runs on the raw-capture path, not the exchange. There the restore is now skipped when the transfer was abandoned on its deadline: the abandoned worker is still alive and still owns the transport, so the restore would write onto a link it is still reading (#399/#401). Closes #407. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoSerialize SD→LAN restore by adding a finalize phase to text exchanges
AI Description
Diagram
High-Level Assessment
Files changed (10)
|
Code Review by Qodo🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0)
Great, no issues found!Qodo reviewed your code and found no material issues that require reviewTo customize comments, go to the Qodo configuration screen, or learn more in the docs. |
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).
Resolves against #415 (background-error surface, connection-loss escalation) and #417 (SD->LAN restore inside the exchange lock). Three conflicts, all where #415 edited the same connect/disconnect bodies this branch factored into shared sync/async step sets: - IDevice.cs: #415's ErrorOccurred event landed immediately before Connect(), whose doc comment this branch rewrote. Kept both. - DaqifiDevice.Connect(): #415's consumer ErrorOccurred subscription moved into the shared CompleteConnect(), so the async path wires it too. - DaqifiDevice.Disconnect(): #415's ErrorOccurred unsubscribe moved into the shared StopMessagePumps(), reached by both Disconnect() and DisconnectAsync(). _errorThrottle.Reset() moved from Connect() into the shared BeginConnect(). Leaving it on the sync path alone would have quietly dropped #415's per-session reset from the primary connect path, since the factory now connects through ConnectAsync. Nothing in #415's suite covers that reset, so it would have survived a fully green build. Added two regression tests for that seam — every #415 test drives Connect(), because ConnectAsync() did not exist when they were written. Both verified to fail when the connect-side wiring is dropped. OnTransportStatusChanged, the _isDisconnecting guard and #417's finalizeAsync plumbing are byte-identical to main. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Why
The SD card operations turn a switch on the way in and turn it back off on the way out. The DAQiFi hardware shares one SPI bus between the SD card and the network interface, so before Core can talk to the card it has to hand the bus over, and afterwards it has to hand it back.
#406 made the hand-over safe: it now happens while the operation holds the device's text-command lock, so nothing else can run in the middle of it. The hand-back was left where it was — in each method's own cleanup block, which runs after the lock has already been let go. So half the pair was protected and half was not, and another command could slip in between an SD operation and its own hand-back, or run while it was happening.
What
The text exchange gained a matching "finalize" step, so the hand-back now runs under the same lock as the hand-over. The listing, the delete, and the storage-space query all use it. Nothing about the commands sent to the device changes on the normal path.
Two decisions worth stating plainly:
How
ExecuteTextCommandAsynctakes an optional finalize step alongside the prepare step added in #406. The exchange wraps the caller's work in a try/finally around it, so the finalize runs whether the work succeeded or blew up, and always before the lock is released. Anything the finalize throws is held until after the lock is released and only then reported, so a bad restore can't also wedge the device — that one bit me while writing this, and there's a test for it.Subclasses that override
ExecuteTextCommandAsynchave to widen their signature by one parameter, exactly as in #406, and for the same reason: a compile error beats an override that quietly stops seeing SD traffic.Side effects worth knowing about:
Tests
Full suite green on net9.0 and net10.0 (2203 passed, 2 skipped each), plus the MCP project (23), build clean with 0 warnings.
New coverage, each checked to fail with its fix disabled:
GetSdCardFilesAsync;Bench
DAQiFi Nyquist 1, firmware 3.7.2, example CLI built against this branch. Nothing destructive — no format, delete, download, flash, reboot or WiFi reconfiguration.
--sd-list: 32 files.--sd-storage: 7.44 GiB total, parsed fine — that is the query whose prepare step moved.192.168.1.30immediately afterwards, and a final USB listing returned all 32 files again.Refs #406, #396, #399. Narrower than #342 by design — this is one asymmetry inside a path that already has a lock, and does not settle whether Core should serialize all mutating operations per device.
closes #407
Not merging — for review.
🤖 Generated with Claude Code