fix(sdcard): bound the SD download path so a wedged card cannot hang or ignore cancellation (closes #399) - #401
fix(sdcard): bound the SD download path so a wedged card cannot hang or ignore cancellation (closes #399)#401tylerkron wants to merge 5 commits into
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 |
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.
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