fix(api): retire four downstream workarounds — SD stall typing, empty-transfer guard, ResetAll semantics, Verifying overload (closes #398) - #405
Conversation
Four independent v1.3.0 gaps that daqifi-desktop is working around in shipped code. Each forced a consumer to match on a Core-authored string or to compensate for a silent fallback. 1. SD download stalls are typed. The three bare TimeoutException throw sites in SdCardFileReceiver now throw SdCardTransferStalledException (in the existing SdCardOperationException hierarchy) carrying a Reason: NoDataReceived, TransportClosed, or TransferTimeout. Over USB serial a zero-byte read is the ORDINARY stall signal — SerialStream .ReadAsync returns 0 on a read timeout rather than throwing — so "the transport stream closed" was factually wrong there; that case is now distinguished from an actually-unreadable stream. 2. SdCardEmptyTransferException can honor its stated precondition. The listing's reported size is plumbed into the receiver, so a marker-only transfer throws only when the listing said the file was non-empty; a listed 0-byte file returns 0 bytes as a legitimate empty download instead of telling the user to power-cycle. SdCardFileInfo gains SizeInBytes and the list parser retains the size token firmware already emits. With no listing available the conservative #264 behavior is unchanged. 3. TimestampProcessor.ResetAll no longer discards device clock configuration. Frequencies are static device configuration, not session state, and dropping them was silent: GetTickPeriod fell back to the 50MHz default while firmware clocks at 42MHz, scaling every timestamp by ~1.19. Reset(deviceId) always preserved them; ResetAll now matches. The fallback is also observable — HasTimestampFrequency up front, TimestampResult.UsedFallbackTickPeriod per message. 4. FirmwareUpdateState.Verifying no longer covers two opposite-severity conditions. The post-WINC-flash serial reconnect gets its own state, ReconnectingAfterFlash, and BuildRecoveryGuidance keys on it, so a fully successful WiFi flash stops telling the user their flash CRC mismatched. Verifying now means only the PIC32 flash CRC check. Behavior change: the SD download path throws SdCardTransferStalledException where it previously threw TimeoutException, and the WiFi flow reports ReconnectingAfterFlash where it previously reported Verifying. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Self-review follow-ups on #398: - An SD listing keeps only leaf names, so the same name can appear twice from different directories. Trusting the first match's size would wave through the wedged-subsystem failure the empty-transfer guard exists to catch, so an ambiguous name now reports "size unknown" and falls back to the conservative behavior. - SdCardTransferStallReason.NoDataReceived no longer asserts the transport is open. A zero-length read means a read timeout on serial but can mean a closed peer on a stream-oriented network transport, and the stream gives no way to tell; the wording and enum docs now say what was actually observed. The consumer action (retry) is the same either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoFix: type SD stalls, honor listed sizes, preserve timestamp freq, split WiFi reconnect state
AI Description
Diagram
High-Level Assessment
Files changed (19)
|
Code Review by Qodo
1.
|
Qodo review, PR #405: inserting listedFileSizeBytes *before* CancellationToken source-breaks any external caller that passed the token positionally as the 5th argument to SdCardFileReceiver.ReceiveAsync. Follows the house pattern set by UpdateWifiModuleAsync's skipVersionCheck (#143 / PR #198): new optional parameters go after CancellationToken, with CA1068 suppressed and the reason stated inline. Additivity wins over strict style on a public API. Adds a deliberately-positional regression test pinning the legacy call shape so this cannot be silently re-broken. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 2b906c5 |
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>
Retires all four gaps from the tracking issue in one PR (they are small and unrelated except in provenance). All line numbers in the issue were verified at
v1.3.0; everything here was re-located on currentmain.closes #398Gap 1 — SD download stalls are typed
The three bare
TimeoutExceptionthrow sites inSdCardFileReceivernow throwSdCardTransferStalledException, a new member of the existingSdCardOperationExceptionhierarchy, carryingFileName,BytesReceived,Timeout, and aReason:NoDataReceivedTransportClosedTransferTimeoutThe old message asserted "Transport stream closed before receiving the EOF marker" for any zero-byte read, which is wrong on the transport it most often fires on. That claim is gone.
One honest caveat, established on the bench (see below): on macOS,
SerialStream.ReadAsyncdoes not return 0 onReadTimeout— it blocks straight past it. SoTransferTimeoutis the reason that fires on this platform, andNoDataReceivedcovers the transports where a zero-length read really is what you get. The enum doc says so rather than over-claiming, because a zero-length read on a stream-oriented network transport can also mean the peer closed the connection and the stream gives no way to tell. In every case the consumer action is the same — the download did not complete — which is the point of typing it.Gap 2 —
SdCardEmptyTransferExceptioncan honor its own preconditionIts XML doc always claimed it is "never a valid download for a file the directory listing reports as non-empty", but the receiver had no access to the listed size, so a genuinely 0-byte file (routinely left on a FAT card by an interrupted logging session) raised the identical exception as a wedged SD subsystem — and told the user to power-cycle.
The listed size is now plumbed through:
"<path> <size>"per listing entry; the parser was discarding the size.SdCardFileInfogainsSizeInBytes(long?) andSdCardFileListParserretains it. Anything that is not a plain non-negative integer parses asnull("unknown") rather than a guess.SdCardFileReceiver.ReceiveAsyncgains an optionallistedFileSizeBytes. A marker-only transfer throws only when the listing said the file was non-empty; a listed 0-byte file returns 0 bytes as a legitimate empty download.DownloadSdCardFileAsynclooks the size up from the lastGetSdCardFilesAsynclisting. With no listing available the conservative DownloadSdCardFileAsync silently returns a 0-byte success on an empty (marker-only) transfer #264 behavior is unchanged (throw, so the existing GET retry still covers a wedged subsystem). An ambiguous name — the listing keeps only leaf names, so the same name can appear twice from different directories — is also treated as unknown, because an over-confident size would wave through the very failure the guard exists to catch.ListedSizeInBytesso the distinction is visible even in the throwing case.Gap 3 —
ResetAll()no longer discards device clock configurationResetAll()cleared_deviceTickPeriodsalongside session baselines, and the loss was silent:GetTickPeriodfalls back to the 50 MHz default while this firmware clocks at 42 MHz, scaling every reconstructed timestamp by ~1.19 with no exception, log, or warning.Reset(deviceId)has always preserved frequencies;ResetAll()now matches — frequency is static device configuration, not session state. It still clears every session baseline, which is its actual job.SetTimestampFrequency(deviceId, 0)remains the explicit way to drop one.That alone retires the consumer-side "have I applied it yet" gate flag that daqifi-desktop and daqifi-avalonia independently invented. The fallback is also made observable, so the failure stops being invisible:
ITimestampProcessor.HasTimestampFrequency(deviceId)— check up front. (GetTickPeriodalone cannot tell an unconfigured device from one that genuinely reports the fallback frequency.)TimestampResult.UsedFallbackTickPeriod— reported per message, read from the same lookup that chose the tick period so the two cannot disagree.Per the settled convention on #80, local time and the omitted rollover
+1are unchanged and deliberately untouched.Gap 4 —
FirmwareUpdateState.Verifyingno longer covers two opposite severitiesThe post-WINC-flash serial reconnect gets its own state,
FirmwareUpdateState.ReconnectingAfterFlash, soVerifyingnow means only the PIC32 flash CRC check.BuildRecoveryGuidancekeys on the same discriminator, so a WiFi flash that succeeded completely stops telling the user:and instead says the firmware flashed and verified fine and only the reconnect timed out. Consumers discriminate on
FailedState— a structural value — instead of onOperation, which was only ever the human-readable UI progress string. The step keeps its previous timeout budget (VerifyingTimeout), so a host that tuned that for a slow re-enumeration keeps the tuning.Behavior changes (deliberate — please weigh these)
SdCardTransferStalledExceptionwhere it previously threwTimeoutException. These are unrelated types; a consumer catchingTimeoutExceptionon this path stops matching. The issue asked for the new type to live in theSdCardOperationExceptionhierarchy, so this is unavoidable.ReconnectingAfterFlashwhere it previously reportedVerifyingin bothStateChangedand progress events. A UI mapping states to labels needs the new case.ResetAll()no longer clears device frequencies. Anyone relying on it as a full wipe should callSetTimestampFrequency(id, 0).ITimestampProcessor(breaking for any external implementer of the interface), a new optional parameter onSdCardFileInfo/TimestampResult/SdCardEmptyTransferExceptionconstructors and onReceiveAsync, and a newFirmwareUpdateStateenum member.docs/and the MCP server (src/Daqifi.Mcp) were checked — neither references the changed exception types, reset semantics, or firmware states, so no updates were needed.Tests
23 new xUnit tests across the four gaps. Full suite green on net9.0 and net10.0: 1963 passed, 2 skipped (the two pre-existing real-hardware transport tests), 0 warnings.
Bench verification
DAQiFi Nyquist 1, firmware 3.7.2,
/dev/cu.usbmodem1101. Non-destructive only — no flash, noSD:FORmat/delete, no reboot, no WiFi reconfiguration. Example CLI and a throwaway probe both built against this branch's Core.SD:LIS?returned 31 files, 31/31 with a parsed size, including exactly one genuine zero-byte file (log_20260728_190448.bin). That is precisely the file that used to raise "the device's SD subsystem may not be ready; retry or power-cycle the device". The device also reportedtimestamp_freq = 42000000 Hz, the value gap 3 exists to protect.SerialStream. PointingSdCardFileReceiverat a liveSerialPort.BaseStreamthat receives nothing producesSdCardTransferStalledException,Reason=TransferTimeout,is SdCardOperationException=True,is TimeoutException=False. This run is also what established the macOSReadAsyncbehavior noted above: it blocked past the 500 msReadTimeoutand only ended at the 3 s transfer deadline.SYSTem:POWer:STATe 1first. A device-state condition; the documented fix is a power-cycle/reboot, which is prohibited here. Unit tests cover the reset semantics and the fallback signal.GETnever returned data on this board for any file, including the 0-byte one and a 186-byte one — the read blocked until my own 25 s probe leash. This is the known low-heap SD condition, not a regression from this PR (the read loop is unchanged apart from the exception type, and the stock CLI on this branch fails identically). But it does show Core's hardcoded 30-minute deadline is the only bound on that path in practice, which is exactly what that ticket owns.Siblings
Deliberately stayed in lane; textual conflicts with these are expected, semantic overlap should not exist:
InvalidOperationExceptionconnectivity guards. Gap 1 is a distinct site insideSdCardFileReceiverthat api: connectivity guards throw untyped InvalidOperationException, forcing clients to message-match #395 does not cover.LISTtimeout-vs-empty ambiguity. Gap 2 is theGETsibling of it; this PR keeps to theGETpath, though it does add a size field to the shared listing parser.Retiring these lets the linked desktop workarounds be simplified or deleted: daqifi-desktop#793 (gaps 1–2), #794 (gap 3), #790 (gap 4).
Not merging — opened for review.
🤖 Generated with Claude Code