fix(sdcard): an SD download now owns the device, so a concurrent command can't corrupt the file - #506
Conversation
…493) The raw-capture path an SD download runs on suspended the protobuf consumer and took the transport stream while excluding nothing. A status poll from another thread acquired the text-exchange lock uncontended and started a second reader on that same stream; a plain Send() was not even deferred, so its bytes went out mid-transfer and the device's reply landed inside the downloaded file. ExecuteRawCaptureAsync now runs the same lock protocol ExecuteAsync does: nested re-entry via HoldsOperationLock, validation moved inside the lock so a session torn down during the wait is caught, an outbound drain so a command queued just before the capture cannot be written into it, and release on every exit path — which is also what replays the sends parked while the capture ran. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoFix SD download corruption by taking the device operation lock in raw capture
AI Description
Diagram
High-Level Assessment
Files changed (4)
|
Code Review by Qodo
1.
|
Round 1 review: ExecuteRawCaptureAsync stopped the protobuf consumer and took the stream without setting the AsyncLocal flag that marks "a swap is in progress on this flow". A nested capture — or a text exchange opened from inside a raw action — therefore ran, and its finally restarted the consumer while the outer capture still owned the stream. The flag now covers both swaps, and either one attempted inside the other fails with a clear InvalidOperationException instead. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 394a1da |
Round 2 review: the message said the capture "cannot run inside a text exchange", but the flag it reads is also set by a raw capture — so a nested capture was diagnosed as something it was not. It now names the state (already inside a consumer swap) and both things that establish it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 5dcd8e2 |
|
Qodo-clean, CI green — ready for review. Round 3 on head Three rounds, three findings: the nested consumer swap (High) and the misleading re-entrancy message (Medium) were both valid and taken; the unbounded deferred-send backlog is real but is issue #492, already fixed in PR #505 — it stays un-struck in Qodo's summary because Qodo cannot tell "declined with reasoning" from "unaddressed", but its thread is resolved. See the merge-order note in the description. |
|
Merge-order note is now moot: #505 merged at 17:59Z, while this PR was mid-review. The deferred-send cap ( (#504 also merged, at 17:57Z.) |
What was wrong
If anything else touched the device while an SD card download was running, the downloaded file could come back silently corrupted. A UI status poll, an MCP
get_device_status, an SD listing, or even a single fire-and-forgetdevice.Send(...)from another thread was enough: the download had the transport stream to itself only by convention, not by any lock. A text query would open its own reader on the same stream and start eating the file's bytes; a bareSend()would put SCPI onto the wire mid-transfer and the device's reply would land inside the.bin. Nothing failed loudly — you got a truncated or garbled file, or the end-of-file marker was eaten and the download sat there until its 30-minute deadline expired. The other thread's own response was garbage too.The download did hold a gate, but that gate only serialized downloads against each other.
How it was fixed
The raw-capture path the download runs on now takes the same device operation lock every other operation already takes. While a download is in flight, a text query from another thread waits for it, and a
Send()from another thread is deferred and replayed afterwards — exactly what already happens aroundRunExclusiveAsync. Re-entry is nested, so the SD prepare/restore exchanges that already run under that lock don't deadlock against it. A capture also now counts as a consumer swap for the existing re-entrancy guard, so a second swap started from inside one is rejected instead of restarting the message consumer under a capture that still owns the stream.Things you may want to push back on:
DownloadSdCardFileAsync.TransportNotConnectedException(anInvalidOperationException) to the path, mirroring bug: SerialStreamTransport.Stream leaks raw InvalidOperationException when serial port closes mid-operation #238's fix for the text exchange.ExecuteAsynctakes for investigate: per-device operation serialization for concurrent consumers #342, and costs at most 250 ms per download.maintoday (bug(device): Send() during a long exclusive operation parks unbounded deferred messages, then replays the entire backlog at once #492, fixed in fix(device): cap the backlog of sends parked during a long exclusive operation #505). This PR does not create that, but it does widen the window in which it can fill — so fix(device): cap the backlog of sends parked during a long exclusive operation #505 landing first is the tidier order.Verification
12 new tests in
DaqifiDeviceRawCaptureLockTests, covering all three of the issue's success criteria plus lock hygiene (nested re-entry, release on throw / on validation failure, cancellation while queued, teardown during the wait) and the two nested-swap rejections. Confirmed they catch the bugs rather than just passing: with the lock change reverted, 6 of 10 failed; with the swap guard reverted, 2 of 2 failed — and in both cases the tests that are guards correctly still passed.Full suite green on net9.0 (2914 Core + 43 Mcp) and net10.0 (2914), 0 failures, 0 warnings.
Bench (non-destructive), fw 3.7.2 on
/dev/cu.usbmodem1101, re-run after the review fix: connect →--show-status→ 3 s @ 500 Hz → disconnect cycles returning 1177–1189 samples (this unit's known clock ratio), plus SD--sd-list(45 files) and--sd-storage— the SD prepare/finalize exchange path this change sits next to.snand firmware unchanged, cleanDisconnected. NoSD:GET, no delete/format, no reboot, no firmware, serial only.closes #493
Not merging — for review.