feat(firmware): share the LAN chip-info retry probe as public API (part of #269) - #443
Conversation
…rt of #269) A single `GetLanChipInfoAsync` is not a reliable answer to "what WiFi firmware is on this device". Right after a PIC32 update the application is up while WiFi is still starting (#144), and a module whose state machine has not reached INITIALIZED answers SCPI -200 instead of JSON (#203). Both clear on their own within seconds, so treating the first failure as the answer is what sends a caller into a needless multi-minute reflash of already-current firmware. Core already had the bounded retry that handles this, but it was private to `WifiModuleUpdater`, so every consumer hand-rolled its own — daqifi-desktop wraps the call in a 3x/2s loop of its own (issue #269, item 5). Move that loop to `ILanChipInfoProvider.GetLanChipInfoWithRetryAsync`, an additive extension with a `LanChipInfoRetryOptions` budget whose defaults are the ones Core itself uses. `WifiModuleUpdater` now projects its options onto it and calls the same code, so there is one implementation rather than two. Behavior is unchanged: attempt count clamped to at least one, wall-clock budget that ends the probe with an "unavailable" result rather than throwing, the LAN:APPLY kick sent at most once and only to a connected device, the cancellation check before that state-changing send, and a not-initialized flag that describes the terminal failure rather than any earlier one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoExpose shared LAN chip-info retry probe as public firmware API
AI Description
Diagram
High-Level Assessment
Files changed (3)
|
Code Review by Qodo
1.
|
…ng (part of #269) `GetLanChipInfoWithRetryAsync` is public API taking a settable options record, but it handed `RetryDelay` to `Task.Delay` and `TotalTimeout` to `CancellationTokenSource` unchecked. Three misconfigurations escaped a probe whose documented contract is that failures come back as an unavailable result: - a negative `TotalTimeout` threw `ArgumentOutOfRangeException` from the CTS constructor, even though the option documents non-positive as "no budget, so no attempt"; - a negative `RetryDelay` threw the same from `Task.Delay`, partway through the loop rather than up front; - `RetryDelay = Timeout.InfiniteTimeSpan` with an equally unbounded `TotalTimeout` stalled the caller between attempts with nothing left to release it. Normalize both, in the same clamp-don't-throw spirit as the existing `Math.Max(1, MaxAttempts)`. `Timeout.InfiniteTimeSpan` stays meaningful for `TotalTimeout` — it is how .NET spells "no ceiling" — but not for `RetryDelay`, where an infinite pause inside a bounded retry has no reading other than a hang; "don't retry" is `MaxAttempts = 1`. All other negatives become zero. +4 tests. Each of the three failure modes was reproduced red first, and both subtle branches of the fix were mutation-checked: clamping the infinite budget to zero fails 2 tests, honoring an infinite retry delay fails 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 0d903a7 |
|
Ready for review: Qodo re-reviewed |
|
Bench-validated on the real Nq1 (fw 3.7.2, USB, non-destructive) — 16/16 checks green on This PR shipped with unit tests only, so I put the new public API in front of real firmware. What real hardware confirmed
The part worth flaggingMy first pass ran the negative/infinite So I drove real retries: a decorator that discards the first two answers while still performing all three real device round-trips.
Pre-fix Not covered on hardwareThe bench unit's WINC is initialized, so it answers with JSON rather than SCPI Still not merging — for review. |
|
Heads-up: this PR's failing build is not caused by anything in this PR. It is inherited from main. After #444 and #445 merged back to back, Fix is up as #451 (test-only). Once that lands, a Nothing else changed here: still Qodo-clean with 0 unresolved threads, and |
Part of #269 (item 5: "a
GetLanChipInfoAsync(retries, delay)helper in Core would let every consumer share it").Not merging — opened for your review.
Why
A single
GetLanChipInfoAsyncis not a reliable answer to "what WiFi firmware is on this device":-200instead of JSON (WiFi chip-info probe: kick LAN:APPLY before declaring ChipInfoUnavailable #203).Both clear on their own within seconds. Treating the first failure as the answer is what sends a caller into a needless multi-minute reflash of already-current firmware.
Core already had the bounded retry that handles this — but it was
privatetoWifiModuleUpdater, so every consumer hand-rolled its own.daqifi-desktopwraps the call in a 3×/2sTryGetLanChipInfoAsyncof its own, which is precisely the duplication #269 exists to remove.What changed
Daqifi.Core.Firmware):ILanChipInfoProvider.GetLanChipInfoWithRetryAsync(...)extension, plusLanChipInfoRetryOptions(budget) andLanChipInfoProbeResult(what was read + whether the terminal failure was specifically "WINC not initialized").WifiModuleUpdaternow calls it instead of its own copy:TryGetLanChipInfoWithRetryAsync(~140 lines) is gone, replaced by a smallBuildLanChipInfoRetryOptions()projection ofFirmwareUpdateServiceOptions. One implementation, not two.LanChipInfoRetryOptions' defaults are the ones Core itself uses (3 attempts / 2 s apart / 8 s total / kick enabled), soGetLanChipInfoWithRetryAsync()with no arguments is "what Core does".Behavior is deliberately unchanged, including the parts that are easy to lose in a move:
LAN:APPLYkick is sent at most once per probe and only to a connected device (repeated kicks re-init the WINC and risk an already-associated link);The probe finds the device for the kick by
provider as IStreamingDevice(in practice the provider is the device —DaqifiStreamingDeviceimplements both). A bare provider simply gets no kick, which is covered by a test.Tests
+22 new
LanChipInfoProviderExtensionsTests(18 with the original commit, +4 with the review fix below). Baseline measured on a cleanorigin/mainworktree at4b8eed2(2690) rather than taken from notes; this branch is 2712 — exactly +22, zero losses. Full suite green on net9 + net10 (2712 passed / 2 skipped each, plus 23 Mcp on net9), 0 warnings.The 22 existing
FirmwareUpdateServiceTestsassertions covering this retry (#144 / #203 / kick-once / cancel-race / total-timeout) were left untouched on purpose — they are the evidence that routing the updater through the shared helper changed nothing.Mutation-checked rather than assumed to bite. Eight mutations of the new implementation, each run against the new tests:
Math.Max(1, MaxAttempts)IsConnectedbefore kickingKickLanApplyOnNotInitialized(An eighth — deleting the
!hasSentLanApplyguard outright — doesn't compile underTreatWarningsAsErrors, so it was re-expressed as "kick every attempt".) Implementation restored from a byte-identical backup and diffed before committing.Bench (real Nq1, fw 3.7.2, USB
/dev/cu.usbmodem1101, non-destructive)Scratchpad harness with a ProjectReference straight at this branch's Core, so the new public API is what actually ran against hardware.
chipId=1377184 fwVersion=19.7.7 buildDate=Mar 30 2022— identical to the single-shotGetLanChipInfoAsyncbaseline taken immediately before it (1061 ms), so the wrapper returns the device's own answer unaltered.attempts=3 realDeviceCalls=1, 1563 ms ≈ 2 × 400 ms delay + the device call). Per-attempt debug logging surfaced through the injectedILogger. This decorator is also the not-a-streaming-device path, so the "no kick available" branch ran on hardware too.SYSTem:POWer:STATe 1+ 1 s settle (what Core's own status check does): succeeded in 560 ms.LAN:APPLYwas sent in any run — the kick was left disabled, since the bench module is already initialized and repeated APPLYs are the WiFi-churn hazard the once-only guard exists for.One honest observation, pre-existing and unchanged by this PR: a 900 ms
TotalTimeoutagainst a 20-attempt budget did not cut a single in-flight query short — the run returned successfully at 1059 ms. The device-side text exchange doesn't observe the linked token mid-flight on this path, so the budget bounds the loop between attempts rather than acting as a hard deadline. That is exactly what it was written for (attempts × per-attempt timeout + delays overrunning while a lock is held), and it is the same behavior main has today; the docs now say so explicitly.🤖 Generated with Claude Code
Review fix (
0d903a7) — the retry budget is normalized, not thrownQodo caught a real gap in the new public surface, confirmed red before fixing:
LanChipInfoRetryOptionsis a settable record, butRetryDelaywent straight toTask.DelayandTotalTimeoutstraight toCancellationTokenSource. Three misconfigurations escaped a probe whose stated contract is that failures come back as a result:TotalTimeoutnegativeArgumentOutOfRangeExceptionfrom the CTS ctor — despite the option's own doc promising "no budget, so no attempt"RetryDelaynegativeArgumentOutOfRangeExceptionfromTask.Delay, partway through the loopTimeout.InfiniteTimeSpanNormalized in the same clamp-don't-throw spirit as the existing
Math.Max(1, MaxAttempts), rather than validated — throwing would contradict the "failures are absorbed" contract, andFirmwareUpdateServiceOptions.Validate()throws because it is an up-front config object, not a per-call budget.One deliberate asymmetry, now documented on both properties:
Timeout.InfiniteTimeSpankeeps meaning "no ceiling" forTotalTimeout(that is what .NET andCancellationTokenSourcealready mean by it) but folds into "no pause" forRetryDelay, where an infinite pause inside a bounded retry has no reading other than the hang above. Every other negative becomes zero. Defaults are provably identity, so the shipped path is unchanged.Both subtle branches mutation-checked: clamping the infinite budget to zero fails 2 tests; honoring an infinite
RetryDelayfails 1 (the 10s hang returns).Re-benched on the real Nq1 (fw 3.7.2, USB, non-destructive, kick disabled) — the defaults line matches the pre-fix baseline, the negative budget returns in 0 ms having made no attempt, and the bottom three all previously threw or hung: