fix(sdcard): bound the SD download path so a wedged card cannot hang or ignore cancellation (closes #399) - #401
Conversation
…forever (closes #399) An SD file download against a device whose SD subsystem is wedged hung indefinitely and ignored cancellation: nothing on the parked path observed the token, so a consumer-side stall watchdog could see the hang but never end it. Three bounds, from the outside in: - SerialStreamTransport: WriteTimeout was set to the connect timeout and, unlike ReadTimeout, never lowered after open — so it kept that value for the life of the port. SerialPort.Write takes no CancellationToken, so a device that stops draining its receive buffer parks a write with nothing able to interrupt it. Both directions are now bounded after open. - DownloadSdCardFileAsync enforces its own overall deadline instead of trusting the parked call to notice a token: the transfer runs on a worker task raced against the deadline AND the caller's token, so both now end the call even when the transfer cannot observe either. On expiry the transfer is abandoned rather than awaited (documented on the method). Retries draw from the remaining budget rather than getting a fresh 30 minutes each. - SdCardFileReceiver checks its token every iteration. A serial read returns 0 bytes on its own timeout without ever having looked at the token, and the loop reported that as "transport stream closed" — a timeout — even for a transfer the caller had already cancelled. Tests cover the deadline, the caller-cancellation rescue, a synchronous park before the first await, and a slow-but-healthy transfer that must not be falsely aborted; each was verified to fail without the corresponding fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…w deadline contract The pre-fix WriteTimeout was not unbounded — it inherited the caller's ConnectionRetryOptions.ConnectionTimeout (3-10s by preset, settable to anything). Say that accurately rather than implying an infinite block, and surface the TimeoutException/abandonment contract on ISdCardOperations where consumers actually read it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoBound SD card downloads with hard deadline, cancellation wins, and bounded serial writes
AI Description
Diagram
High-Level Assessment
Files changed (7)
|
Code Review by Qodo
1.
|
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit f880a49 |
…an't stack Qodo review of #401: because a timed-out download is abandoned rather than stopped, nothing stopped a caller from starting another one. Against a device that stays wedged — an "import all" loop over 30 files — each attempt would leave another permanently blocked thread, and worse, put a second reader on a transport stream the abandoned transfer still owns. That is exactly the framing corruption RestartMessageConsumerAfterSwap refuses to risk. A per-device gate now admits one download at a time and is released only when the worker genuinely finishes, however long after the caller gave up. A retry while a transfer is still parked fails fast with InvalidOperationException; once the abandoned worker unwinds, downloads are accepted again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 1acbf57 |
… the fast-fail test Qodo review round 2 of #401. Cancellation vs gate: a caller who cancelled was told "a previous download is still in flight" — an answer about someone else's transfer, and a contract violation, since the method documents OperationCanceledException. The token is now checked before the gate is taken and again before the refusal is thrown, matching the precedence the abandon path already applies. The regression test cancels from inside the pre-flight StopStreaming send, which lands in the exact window between the entry guard and the gate check. Fast-fail test: dropped the 250ms wall-clock assertion, which could fail on a contended runner even when correct. The exception TYPE is the real proof — waiting out another deadline yields TimeoutException, never InvalidOperationException — so the timing check is now only a loose hang guard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit b2a8da5 |
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>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit fab3944 |
… Dispose Last round's unbounded wait rested on a premise that is false: "every possible holder is itself a bounded lifecycle operation". SerialStreamTransport calls _serialPort.Open() synchronously with no timeout — ConnectionTimeout only sets Read/WriteTimeout, which govern an already-open port — and it is reached while holding the lifecycle lock. This codebase already knows that call can wedge in uncancellable native I/O: SerialDeviceFinder carries a process-wide port quarantine built for precisely that (its comment cites PR #295). So a holder can hang forever, and an unbounded wait inherits the hang, turning Disconnect and therefore Dispose into a permanent block. Teardown now waits TeardownLockTimeout (30s, far more generous than the 10s connect side, because a teardown that gives up early is a teardown that did not happen) and then ABANDONS rather than either racing or hanging — the house answer to uncancellable native I/O here, matching #295's quarantine and #401's bounded SD path. On abandonment the transport is deliberately left to the stuck holder, which is guaranteed to release it: _callerWantsDisconnected is set before the wait, so AbandonIfSuperseded tears down whatever the connect eventually builds. What the abandoned path does do is record the caller's intent in this class's own fields (Status, State, _isInitialized) — safe because they are not the transport, and necessary because otherwise the device keeps reporting itself connected after the caller asked it not to. Dispose still reaches _transport.Dispose() outside the lock, so the handle is released either way. XML docs on Disconnect and the helper corrected: they asserted the bounded-holder claim that this commit disproves. Regression test verified against the pre-fix behaviour, where it fails with "Disconnect blocked for 5037ms behind a wedged connect" — and that 5s was only the test harness's own cap; a real Open() hang has none. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Summary
An SD download against a device whose SD subsystem is wedged hung indefinitely and ignored cancellation, and no consumer-side watchdog could rescue it. This bounds the host side of that path from the outside in, so the caller is released no matter which inner call is parked.
1.
SerialStreamTransport— bound writes, not just reads. OnlyReadTimeoutwas lowered after open;WriteTimeoutkept the caller'sConnectionRetryOptions.ConnectionTimeoutfor the life of the port. Both are now set to operational values after open (read 500 ms unchanged, write 2000 ms) viaApplyOperationalTimeouts.Correction to the issue's framing: the write was not unbounded before — it inherited the connect timeout, which is 3 s / 5 s / 10 s for the Fast / default / Resilient presets. So a parked write alone cannot explain an 11-minute hang unless a consumer configured a very large
ConnectionTimeout. Treat this as hardening (decoupling the write bound from retry configuration), not as the fix.2.
DownloadSdCardFileAsync— a deadline it enforces itself. The transfer now runs on aLongRunningworker task raced against both the deadline and the caller's token, so neither depends on the parked code observing anything. This is the actual fix. Details:ExecuteRawCaptureAsync's synchronous prefix (consumer stop-and-join,PrepareSdInterface's writes) otherwise runs on the calling thread before the first await — outside any deadline, and on a UI thread it freezes the window. There's a test for exactly that.WifiBridgeActivator.RunWithHardTimeoutAsync(fix: SerialDeviceFinder.DiscoverAsync blocks the calling thread before its first await (sync SerialPort.Open, no per-port timeout) #294/fix(discovery): make serial probing immune to hung ports (closes #294) #295/fix(firmware): isolate WifiBridgeActivator against uncancellable SerialPort.Open() hangs #326).3.
SdCardFileReceiver— honor the token that is accepted. The loop now checks its token every iteration.SerialStreamignores the token once a read is in flight and, on a silent device, just returns 0 bytes when itsReadTimeoutelapses — which the loop reported as "transport stream closed before receiving the EOF marker", i.e. a timeout for a transfer the caller had already cancelled. That is the case where a consumer's stall watchdog most wants its own cancellation back.4. One SD download at a time (added during review). Because a timed-out transfer is abandoned rather than stopped, nothing stopped a caller from starting another one — and an abandoned transfer still owns the transport stream. A retry against a device that stays wedged (an "import all" loop over 30 files) would put a second concurrent reader on that stream — the framing corruption
RestartMessageConsumerAfterSwapalready refuses to risk — and stack another blocked thread each time. A per-device gate now admits one download at a time and is released only when the worker genuinely finishes, however long after the caller gave up; a retry before then fails fast withInvalidOperationException, and cancellation outranks that refusal.What I fixed vs. what remains inferred
Fixed (verified): writes bounded independently of connect configuration; the download bounded by its own deadline including its synchronous prefix; caller cancellation able to end the call regardless of what the transfer is parked in; cancellation no longer misreported as a timeout on the zero-byte read path.
Still inferred: which call actually parked for 11 minutes in the original report. I could not confirm it, and two of the issue's own candidates got weaker on inspection:
Send()on the SD path is queued —DaqifiDevice.Sendhands string messages toMessageProducer, whose background thread does the blocking write. A wedged write therefore parks the producer thread, not the download; the download then waits for a reply to aSD:GETthat never left the queue. That fits the reported evidence (0 bytes, no progress callbacks) better than a parked caller. NoteMessageProducerlogs and swallows write failures, so a timed-outSD:GETwrite is silent — the download is now bounded either way, but the command is quietly lost.Because the park point is unconfirmed, the bounding is deliberately at the outside of the path rather than at any one suspect.
Bench verification (Nq1 on
/dev/cu.usbmodem1101, non-destructive)The board turned out to be in the wedged state, which was useful and also limiting:
InitializeAsync: 32 channels populated. SD LIST: 31 files, run before and after the download tests — the device was still healthy afterwards. This exercises the new 2 sWriteTimeouton all normal command traffic.SD:GETon two different files produced a 0-byte destination and zero progress callbacks — matching the issue's report (device-side firmware SD:GET silently returns no data after any non-SD stream — SD circular buffer collapses 32 KB→512 B daqifi-nyquist-firmware#703/#567).CancellationTokenSourcepassed intoDownloadSdCardFileAsync, the call returnedOperationCanceledExceptionat exactly 10.0 s / 8.0 s.origin/main: I rebuilt the same harness against pre-fix core and ran it on the same wedged file — it also cancelled at 10.0 s. So on macOS the token was already being observed; the inert-cancellation symptom is specific to the reporter's Windows serial stack (SerialStream.Windowsdiffers from the Unix implementation here). The bench proves no regression, not a cure. The parked-path behavior this PR fixes is covered by unit tests only.SD:GETdata at all right now, so there was no healthy transfer to run. Unit test covers it.Tests
dotnet testgreen on net9.0 and net10.0 (1941 passed each), 0 warnings. New tests, each verified to fail with its corresponding fix disabled:Known consequences (documented on the method and
ISdCardOperations)destinationStreamafter the method throws, so callers must not reuse that stream.SD:GETwith the protobuf consumer stopped; reconnect (or power-cycle a genuinely wedged card) before retrying.StopStreamingsend stays on the caller's thread — it is a queued write, so it cannot block.Review
Qodo raised three findings across four rounds, all fixed and confirmed resolved: abandoned downloads could stack (→ the gate above), cancellation could be masked by that gate, and a wall-clock assertion in one test was flake-prone. Details in the thread replies.
Merge reconciliation with
main(2026-07-30)The branch had gone
CONFLICTINGafter #400, #402, #403, #404, #405 and #406 landed. Mergedmainin and resolved; no behavior added or removed beyond reconciling the two sides, and the bench findings above are unchanged (no new hardware evidence — the caveat there still stands as written).SdCardFileReceiver— the semantic one. fix(api): retire four downstream workarounds — SD stall typing, empty-transfer guard, ResetAll semantics, Verifying overload (closes #398) #405 retyped this file's throws toSdCardTransferStalledException/SdCardEmptyTransferException, and both branches had independently added the same timeout-vs-cancellationcatchguard. Kept main's typed version rather than duplicating it, and re-applied this PR's one unique line — the per-iterationtoken.ThrowIfCancellationRequested(). ZeroTimeoutExceptionremains in the file.OperationCanceledException, not as a stall. With the token check commented out,ReceiveAsync_WhenCancelledAndStreamIgnoresTheToken_ReportsCancellationNotTimeoutfails with "exception type was not compatible" — it getsSdCardTransferStalledException. The distinction holds against the new type, not just the oldTimeoutException.DaqifiStreamingDevice— this PR's deadline,LongRunningworker and download gate now carry fix(api): retire four downstream workarounds — SD stall typing, empty-transfer guard, ResetAll semantics, Verifying overload (closes #398) #405's listed-size plumbing (TryGetListedFileSize→ReceiveAsync) alongside the remaining-budget retries. fix(sdcard): run the SD bus switch as a prepare phase inside the exchange lock #406's prepare-phase change did not touch this method's raw-capture shape.SerialStreamTransport— kept both theWriteTimeoutbounding and fix(transport): detect a physically dropped serial/TCP connection so ConnectionStatus.Lost is reachable #403's watchdog /PortPresenceProbeseam.ISdCardOperations— declarations were byte-identical on both sides; merged both doc sets, dropped neither.SdCardTestResponsesterminator helper, listed-size cases) and re-applied this branch's parked/slow/gate tests on top; this branch's changes to that file were purely additive.Re-confirmed the fixes are still load-bearing after the merge, not just green: disabling the
LongRunningworker fails onlyDownloadSdCardFileAsync_WhenTransferParksSynchronously_StillTimesOut, and removing the hard-deadline race makes the parked-transfer test hang indefinitely — the original #399 symptom.Build clean, 0 warnings. Full suite green on net9.0 and net10.0 (2179 passed, 2 skipped each).
Sibling PRs touching adjacent code
SdCardFileReceiver(stall / empty-transfer classification). I stayed on hang/timeout/cancellation bounding and did not retype anything; textual conflicts in that file are expected.SerialStreamTransport(disconnect detection). My change there is limited to timeout bounding after open.closes #399
Not merging — opened for review.
🤖 Generated with Claude Code