From 215cd84bce589a35c49a4bb539bbbd39b5551f87 Mon Sep 17 00:00:00 2001 From: Chris Lange Date: Mon, 11 May 2026 22:51:21 -0600 Subject: [PATCH 1/3] feat(firmware): retry LAN chip-info probe during startup transients (closes #144) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Right after a PIC32 firmware update the application is up while the WiFi subsystem is still finishing startup, so the first GetLanChipInfo query can transiently fail. Without retry, the WiFi version decision short-circuits to ChipInfoUnavailable and the desktop layer ends up running an unnecessary multi-minute reflash of already-current firmware. Desktop's workaround was a bounded retry loop in the app — this PR moves that policy into Core where the WiFi-update decision itself lives. Adds two FirmwareUpdateServiceOptions properties: - LanChipInfoMaxAttempts (default 3) — total tries before giving up - LanChipInfoRetryDelay (default 2s) — wait between attempts Worst-case wait = (MaxAttempts - 1) * RetryDelay = 4s by default, which fits the observed startup window without slowing steady state. The retry loop observes cancellation between attempts. Wired into CheckWifiFirmwareStatusCoreAsync so both the legacy IsWifiFirmwareUpToDateAsync hit-path AND the new (PR #198) CheckWifiFirmwareStatusAsync planning method get the retry behavior for free. Test plan: 3 new tests cover (a) transient failure recovery within budget, (b) exhausted budget falls through to ChipInfoUnavailable, (c) steady-state success doesn't trigger retry overhead. Extended FakeLanChipInfoStreamingDevice with transientFailuresBeforeSuccess + GetLanChipInfoCallCount instrumentation. 898/900 pass (2 skipped require live hardware). Stacks on PR #198 — needs to land first. --- .../Firmware/FirmwareUpdateServiceTests.cs | 130 +++++++++++++++++- .../Firmware/FirmwareUpdateService.cs | 75 ++++++++-- .../Firmware/FirmwareUpdateServiceOptions.cs | 28 ++++ 3 files changed, 217 insertions(+), 16 deletions(-) diff --git a/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs b/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs index 549a52e..e595a8c 100644 --- a/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs +++ b/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs @@ -730,6 +730,124 @@ public async Task CheckWifiFirmwareStatusAsync_ReentrantCallFromUpdateProgressCa Assert.Equal(FirmwareUpdateState.Complete, service.CurrentState); } + [Fact] + public async Task CheckWifiFirmwareStatusAsync_WhenLanChipInfoFailsTransiently_RetriesUntilSuccess() + { + // Closes #144: post-PIC32-reboot the WiFi subsystem can lag the + // application by a few seconds, so the first chip-info query + // transiently fails. Without retry, the WiFi version decision + // would short-circuit and trigger an unnecessary multi-minute + // reflash. The retry budget covers the startup window. + var wifiRelease = new FirmwareReleaseInfo + { + Version = new FirmwareVersion(19, 5, 4, null, 0), + TagName = "19.5.4", + IsPreRelease = false + }; + var device = new FakeLanChipInfoStreamingDevice( + "COM14", + chipInfo: new LanChipInfo + { + ChipId = 1234, + FwVersion = "19.5.4", + BuildDate = "Jan 8 2019" + }, + transientFailuresBeforeSuccess: 2); + + var options = CreateFastOptions(); + options.LanChipInfoMaxAttempts = 3; + options.LanChipInfoRetryDelay = TimeSpan.FromMilliseconds(5); // Keep test fast + + var service = new FirmwareUpdateService( + new FakeHidTransport(), + new FakeFirmwareDownloadService { LatestWifiRelease = wifiRelease }, + new FakeExternalProcessRunner(), + NullLogger.Instance, + new FakeBootloaderProtocol([[0x10]]), + new FakeHidDeviceEnumerator([]), + options); + + var status = await service.CheckWifiFirmwareStatusAsync(device); + + Assert.Equal(3, device.GetLanChipInfoCallCount); + Assert.True(status.IsUpToDate); + Assert.Equal(WifiFirmwareStatusReason.UpToDate, status.Reason); + } + + [Fact] + public async Task CheckWifiFirmwareStatusAsync_WhenLanChipInfoFailsAllAttempts_ReturnsChipInfoUnavailable() + { + // After exhausting LanChipInfoMaxAttempts the planning method must + // fall through to ChipInfoUnavailable, NOT hang or surface the + // raw exception. This preserves the "couldn't check, default to + // running update" semantics for genuinely-broken devices. + var device = new FakeLanChipInfoStreamingDevice( + "COM15", + chipInfo: new LanChipInfo + { + ChipId = 1234, + FwVersion = "19.5.4", + BuildDate = "Jan 8 2019" + }, + transientFailuresBeforeSuccess: 99); + + var options = CreateFastOptions(); + options.LanChipInfoMaxAttempts = 3; + options.LanChipInfoRetryDelay = TimeSpan.FromMilliseconds(5); + + var service = new FirmwareUpdateService( + new FakeHidTransport(), + new FakeFirmwareDownloadService(), + new FakeExternalProcessRunner(), + NullLogger.Instance, + new FakeBootloaderProtocol([[0x10]]), + new FakeHidDeviceEnumerator([]), + options); + + var status = await service.CheckWifiFirmwareStatusAsync(device); + + Assert.Equal(3, device.GetLanChipInfoCallCount); + Assert.False(status.IsUpToDate); + Assert.Equal(WifiFirmwareStatusReason.ChipInfoUnavailable, status.Reason); + } + + [Fact] + public async Task CheckWifiFirmwareStatusAsync_FirstAttemptSucceeds_DoesNotRetry() + { + // Steady-state path: no retry overhead when the first call works. + var wifiRelease = new FirmwareReleaseInfo + { + Version = new FirmwareVersion(19, 5, 4, null, 0), + TagName = "19.5.4", + IsPreRelease = false + }; + var device = new FakeLanChipInfoStreamingDevice( + "COM16", + chipInfo: new LanChipInfo + { + ChipId = 1234, + FwVersion = "19.5.4", + BuildDate = "Jan 8 2019" + }); + + var options = CreateFastOptions(); + options.LanChipInfoMaxAttempts = 3; + options.LanChipInfoRetryDelay = TimeSpan.FromMilliseconds(5); + + var service = new FirmwareUpdateService( + new FakeHidTransport(), + new FakeFirmwareDownloadService { LatestWifiRelease = wifiRelease }, + new FakeExternalProcessRunner(), + NullLogger.Instance, + new FakeBootloaderProtocol([[0x10]]), + new FakeHidDeviceEnumerator([]), + options); + + await service.CheckWifiFirmwareStatusAsync(device); + + Assert.Equal(1, device.GetLanChipInfoCallCount); + } + private sealed class SyncProgress : IProgress { private readonly Action _handler; @@ -848,14 +966,18 @@ private sealed class FakeLanChipInfoStreamingDevice : IStreamingDevice, ILanChip { private readonly LanChipInfo? _chipInfo; private ConnectionStatus _status = ConnectionStatus.Connected; + private int _remainingTransientFailures; - public FakeLanChipInfoStreamingDevice(string name, LanChipInfo? chipInfo) + public FakeLanChipInfoStreamingDevice(string name, LanChipInfo? chipInfo, int transientFailuresBeforeSuccess = 0) { Name = name; _chipInfo = chipInfo; + _remainingTransientFailures = transientFailuresBeforeSuccess; IsConnected = true; } + public int GetLanChipInfoCallCount { get; private set; } + public string Name { get; } public IPAddress? IpAddress => null; public bool IsConnected { get; private set; } @@ -899,6 +1021,12 @@ public void Send(IOutboundMessage message) public Task GetLanChipInfoAsync(CancellationToken cancellationToken = default) { + GetLanChipInfoCallCount++; + if (_remainingTransientFailures > 0) + { + _remainingTransientFailures--; + throw new InvalidOperationException("Simulated transient post-reboot failure."); + } return Task.FromResult(_chipInfo); } } diff --git a/src/Daqifi.Core/Firmware/FirmwareUpdateService.cs b/src/Daqifi.Core/Firmware/FirmwareUpdateService.cs index a248d36..9dad995 100644 --- a/src/Daqifi.Core/Firmware/FirmwareUpdateService.cs +++ b/src/Daqifi.Core/Firmware/FirmwareUpdateService.cs @@ -536,21 +536,15 @@ private async Task CheckWifiFirmwareStatusCoreAsync( }; } - LanChipInfo? chipInfo; - try - { - chipInfo = await lanChipInfoProvider.GetLanChipInfoAsync(cancellationToken).ConfigureAwait(false); - } - catch (Exception ex) when (ex is not OperationCanceledException) - { - _logger.LogDebug(ex, "Failed to query LAN chip info; reporting status as ChipInfoUnavailable."); - return new WifiFirmwareStatus - { - IsUpToDate = false, - Reason = WifiFirmwareStatusReason.ChipInfoUnavailable, - }; - } - + // Bounded retry for the LAN chip-info probe (closes #144). Right + // after a PIC32 firmware update the application is up while WiFi + // is still finishing startup, so the first chip-info query can + // transiently fail; without retry, the WiFi version decision + // would short-circuit to ChipInfoUnavailable and flow on to a + // multi-minute reflash of already-current WiFi firmware. The + // retry budget is bounded (LanChipInfoMaxAttempts × RetryDelay) + // and observes cancellation between attempts. + var chipInfo = await TryGetLanChipInfoWithRetryAsync(lanChipInfoProvider, cancellationToken).ConfigureAwait(false); if (chipInfo == null) { return new WifiFirmwareStatus @@ -613,6 +607,57 @@ private async Task CheckWifiFirmwareStatusCoreAsync( }; } + private async Task TryGetLanChipInfoWithRetryAsync( + ILanChipInfoProvider lanChipInfoProvider, + CancellationToken cancellationToken) + { + var maxAttempts = Math.Max(1, _options.LanChipInfoMaxAttempts); + var retryDelay = _options.LanChipInfoRetryDelay; + + for (var attempt = 1; attempt <= maxAttempts; attempt++) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var chipInfo = await lanChipInfoProvider.GetLanChipInfoAsync(cancellationToken).ConfigureAwait(false); + if (chipInfo != null) + { + if (attempt > 1) + { + _logger.LogDebug( + "LAN chip-info query succeeded on attempt {Attempt}/{Max}.", + attempt, + maxAttempts); + } + return chipInfo; + } + _logger.LogDebug( + "LAN chip-info query returned null on attempt {Attempt}/{Max}.", + attempt, + maxAttempts); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogDebug( + ex, + "LAN chip-info query failed on attempt {Attempt}/{Max}.", + attempt, + maxAttempts); + } + + if (attempt < maxAttempts) + { + await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false); + } + } + + _logger.LogDebug( + "LAN chip-info query exhausted {Max} attempts; reporting status as ChipInfoUnavailable.", + maxAttempts); + return null; + } + private ExternalProcessRequest BuildWifiProcessRequest( IStreamingDevice device, string firmwarePath, diff --git a/src/Daqifi.Core/Firmware/FirmwareUpdateServiceOptions.cs b/src/Daqifi.Core/Firmware/FirmwareUpdateServiceOptions.cs index 4b75984..ef5ee86 100644 --- a/src/Daqifi.Core/Firmware/FirmwareUpdateServiceOptions.cs +++ b/src/Daqifi.Core/Firmware/FirmwareUpdateServiceOptions.cs @@ -119,6 +119,24 @@ public sealed class FirmwareUpdateServiceOptions /// public string? WifiPortOverride { get; set; } + /// + /// Total attempts (initial + retries) for LAN chip-info queries before + /// the WiFi version decision falls through to "couldn't check, proceed + /// with flash". Right after a PIC32 firmware update the application is + /// typically up while the WiFi subsystem is still finishing startup, so + /// the first chip-info query can transiently fail; bounded retry covers + /// that window so callers don't unnecessarily reflash up-to-date WiFi + /// firmware (closes #144). Default 3 attempts × 2s delay = up to 4s + /// wait in the worst case, which fits the observed startup window. + /// + public int LanChipInfoMaxAttempts { get; set; } = 3; + + /// + /// Delay between LAN chip-info retry attempts (cancellation-aware). + /// Total worst-case wait = (LanChipInfoMaxAttempts - 1) * LanChipInfoRetryDelay. + /// + public TimeSpan LanChipInfoRetryDelay { get; set; } = TimeSpan.FromSeconds(2); + /// /// Gets the configured timeout for a given firmware update state. /// @@ -176,6 +194,16 @@ public void Validate() "Flash write retry count must be at least 1."); } + if (LanChipInfoMaxAttempts < 1) + { + throw new ArgumentOutOfRangeException( + nameof(LanChipInfoMaxAttempts), + LanChipInfoMaxAttempts, + "LAN chip-info max attempts must be at least 1."); + } + + ValidatePositive(LanChipInfoRetryDelay, nameof(LanChipInfoRetryDelay)); + if (BootloaderVendorId < 0 || BootloaderVendorId > 0xFFFF) { throw new ArgumentOutOfRangeException( From 34c23d6c7c6fca102d944414028b004f0b64e82e Mon Sep 17 00:00:00 2001 From: Chris Lange Date: Mon, 11 May 2026 22:59:56 -0600 Subject: [PATCH 2/3] fix: Apply Qodo /agentic_review pass 1 on PR #199: cap retry total wall-clock time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Qodo correctly flagged that per-attempt query timeouts compound with retry delays, so the actual worst-case blocking can be ~10s with the default DaqifiStreamingDevice 2s response timeout (3 attempts × 2s + 2 × 2s delays), not the 4s the docs claimed. Bad enough by itself, worse because the retry loop holds _operationLock the whole time. Added LanChipInfoTotalTimeout option (default 8s) — a hard wall-clock cap enforced by a linked CancellationTokenSource around the entire retry loop. Caller's cancellation token is honored as before; the new timeout just adds a deadline. When hit, the loop short-circuits to ChipInfoUnavailable instead of letting the per-attempt timeouts keep accumulating. Updated LanChipInfoMaxAttempts docstring to reflect the actual worst-case math (sum of attempt durations + delays) and point at LanChipInfoTotalTimeout for hard bounds. Test: SlowFakeLanChipInfoStreamingDevice with 200ms attempt latency + 100ms TotalTimeout asserts the probe bails in <1500ms instead of the 3s a naive impl would take with 10 max attempts. --- .../Firmware/FirmwareUpdateServiceTests.cs | 73 +++++++++++++++++++ .../Firmware/FirmwareUpdateService.cs | 51 ++++++++++++- .../Firmware/FirmwareUpdateServiceOptions.cs | 26 ++++++- 3 files changed, 144 insertions(+), 6 deletions(-) diff --git a/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs b/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs index e595a8c..865cc50 100644 --- a/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs +++ b/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs @@ -811,6 +811,79 @@ public async Task CheckWifiFirmwareStatusAsync_WhenLanChipInfoFailsAllAttempts_R Assert.Equal(WifiFirmwareStatusReason.ChipInfoUnavailable, status.Reason); } + [Fact] + public async Task CheckWifiFirmwareStatusAsync_TotalTimeoutHit_ShortCircuitsToChipInfoUnavailable() + { + // Closes a Qodo follow-up on PR #199: per-attempt query timeouts + // compound with retry delays, so a high MaxAttempts × non-trivial + // per-attempt latency could block far beyond the configured retry + // budget while holding _operationLock. The total-timeout cap caps + // wall-clock time independent of attempt counts. + // + // The fake's per-attempt latency is the Task.Delay below; with + // 200ms latency × 10 max attempts × 100ms retry delay, a naive + // implementation would block ~2.9s. The 100ms total timeout + // forces an early ChipInfoUnavailable return after the first + // attempt's latency exceeds the budget. + var device = new SlowFakeLanChipInfoStreamingDevice( + "COM17", + attemptLatency: TimeSpan.FromMilliseconds(200)); + + var options = CreateFastOptions(); + options.LanChipInfoMaxAttempts = 10; + options.LanChipInfoRetryDelay = TimeSpan.FromMilliseconds(100); + options.LanChipInfoTotalTimeout = TimeSpan.FromMilliseconds(100); + + var service = new FirmwareUpdateService( + new FakeHidTransport(), + new FakeFirmwareDownloadService(), + new FakeExternalProcessRunner(), + NullLogger.Instance, + new FakeBootloaderProtocol([[0x10]]), + new FakeHidDeviceEnumerator([]), + options); + + var stopwatch = System.Diagnostics.Stopwatch.StartNew(); + var status = await service.CheckWifiFirmwareStatusAsync(device); + stopwatch.Stop(); + + Assert.False(status.IsUpToDate); + Assert.Equal(WifiFirmwareStatusReason.ChipInfoUnavailable, status.Reason); + // Should bail well before attempting all 10 × (200ms + 100ms) = 3s. + // Allowing 1500ms for CI variance / xunit overhead. + Assert.True(stopwatch.Elapsed < TimeSpan.FromMilliseconds(1500), + $"Probe took {stopwatch.ElapsedMilliseconds}ms, expected <1500ms with TotalTimeout=100ms."); + } + + private sealed class SlowFakeLanChipInfoStreamingDevice : IStreamingDevice, ILanChipInfoProvider + { + private readonly TimeSpan _attemptLatency; + public SlowFakeLanChipInfoStreamingDevice(string name, TimeSpan attemptLatency) + { + Name = name; + _attemptLatency = attemptLatency; + IsConnected = true; + } + public string Name { get; } + public IPAddress? IpAddress => null; + public bool IsConnected { get; private set; } + public ConnectionStatus Status => ConnectionStatus.Connected; + public int StreamingFrequency { get; set; } + public bool IsStreaming { get; private set; } + public event EventHandler? StatusChanged { add { } remove { } } + public event EventHandler? MessageReceived { add { } remove { } } + public void Connect() => IsConnected = true; + public void Disconnect() => IsConnected = false; + public void Send(IOutboundMessage message) { } + public void StartStreaming() => IsStreaming = true; + public void StopStreaming() => IsStreaming = false; + public async Task GetLanChipInfoAsync(CancellationToken cancellationToken = default) + { + await Task.Delay(_attemptLatency, cancellationToken).ConfigureAwait(false); + return null; + } + } + [Fact] public async Task CheckWifiFirmwareStatusAsync_FirstAttemptSucceeds_DoesNotRetry() { diff --git a/src/Daqifi.Core/Firmware/FirmwareUpdateService.cs b/src/Daqifi.Core/Firmware/FirmwareUpdateService.cs index 9dad995..41c2ff3 100644 --- a/src/Daqifi.Core/Firmware/FirmwareUpdateService.cs +++ b/src/Daqifi.Core/Firmware/FirmwareUpdateService.cs @@ -613,14 +613,38 @@ private async Task CheckWifiFirmwareStatusCoreAsync( { var maxAttempts = Math.Max(1, _options.LanChipInfoMaxAttempts); var retryDelay = _options.LanChipInfoRetryDelay; + var totalTimeout = _options.LanChipInfoTotalTimeout; + + // Wall-clock budget guards against the pathological case where + // attempt-count × per-attempt-timeout + retry-delay sum vastly + // exceeds the configured retry budget (e.g., 3 × 2s device timeout + // + 2 × 2s delay = ~10s while _operationLock is held). Linking + // the caller's CT preserves cancellation semantics; the timeout + // CTS just adds a deadline. + using var timeoutCts = new CancellationTokenSource(totalTimeout); + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, timeoutCts.Token); + var linkedToken = linkedCts.Token; for (var attempt = 1; attempt <= maxAttempts; attempt++) { - cancellationToken.ThrowIfCancellationRequested(); + try + { + linkedToken.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + _logger.LogDebug( + "LAN chip-info probe hit total timeout ({Timeout}) before attempt {Attempt}/{Max}.", + totalTimeout, + attempt, + maxAttempts); + return null; + } try { - var chipInfo = await lanChipInfoProvider.GetLanChipInfoAsync(cancellationToken).ConfigureAwait(false); + var chipInfo = await lanChipInfoProvider.GetLanChipInfoAsync(linkedToken).ConfigureAwait(false); if (chipInfo != null) { if (attempt > 1) @@ -637,6 +661,15 @@ private async Task CheckWifiFirmwareStatusCoreAsync( attempt, maxAttempts); } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + _logger.LogDebug( + "LAN chip-info probe hit total timeout ({Timeout}) during attempt {Attempt}/{Max}.", + totalTimeout, + attempt, + maxAttempts); + return null; + } catch (Exception ex) when (ex is not OperationCanceledException) { _logger.LogDebug( @@ -648,7 +681,19 @@ private async Task CheckWifiFirmwareStatusCoreAsync( if (attempt < maxAttempts) { - await Task.Delay(retryDelay, cancellationToken).ConfigureAwait(false); + try + { + await Task.Delay(retryDelay, linkedToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + _logger.LogDebug( + "LAN chip-info probe hit total timeout ({Timeout}) during retry delay after attempt {Attempt}/{Max}.", + totalTimeout, + attempt, + maxAttempts); + return null; + } } } diff --git a/src/Daqifi.Core/Firmware/FirmwareUpdateServiceOptions.cs b/src/Daqifi.Core/Firmware/FirmwareUpdateServiceOptions.cs index ef5ee86..8d920b4 100644 --- a/src/Daqifi.Core/Firmware/FirmwareUpdateServiceOptions.cs +++ b/src/Daqifi.Core/Firmware/FirmwareUpdateServiceOptions.cs @@ -126,17 +126,36 @@ public sealed class FirmwareUpdateServiceOptions /// typically up while the WiFi subsystem is still finishing startup, so /// the first chip-info query can transiently fail; bounded retry covers /// that window so callers don't unnecessarily reflash up-to-date WiFi - /// firmware (closes #144). Default 3 attempts × 2s delay = up to 4s - /// wait in the worst case, which fits the observed startup window. + /// firmware (closes #144). /// + /// + /// Each attempt also incurs the per-attempt timeout from the device + /// implementation (e.g., DaqifiStreamingDevice.GetLanChipInfoAsync + /// uses 2s). Total wall-clock budget is therefore + /// sum(attempt durations) + (MaxAttempts-1) * RetryDelay; with the + /// 2s device default, 3 attempts and 2s delay sum to ~10s in the worst + /// case. The retry loop holds _operationLock, so use + /// to cap the actual wall-clock + /// time independent of attempt counts. + /// public int LanChipInfoMaxAttempts { get; set; } = 3; /// /// Delay between LAN chip-info retry attempts (cancellation-aware). - /// Total worst-case wait = (LanChipInfoMaxAttempts - 1) * LanChipInfoRetryDelay. /// public TimeSpan LanChipInfoRetryDelay { get; set; } = TimeSpan.FromSeconds(2); + /// + /// Hard upper bound on wall-clock time spent in the LAN chip-info + /// probe (including per-attempt query timeouts and retry delays). + /// When exceeded, the loop short-circuits to ChipInfoUnavailable + /// regardless of remaining attempts. Prevents pathological multi- + /// attempt cases (e.g., 3 attempts × 2s device timeout + 2 × 2s delays + /// = ~10s) from stalling firmware flows or UI status probes that hold + /// the operation lock. + /// + public TimeSpan LanChipInfoTotalTimeout { get; set; } = TimeSpan.FromSeconds(8); + /// /// Gets the configured timeout for a given firmware update state. /// @@ -203,6 +222,7 @@ public void Validate() } ValidatePositive(LanChipInfoRetryDelay, nameof(LanChipInfoRetryDelay)); + ValidatePositive(LanChipInfoTotalTimeout, nameof(LanChipInfoTotalTimeout)); if (BootloaderVendorId < 0 || BootloaderVendorId > 0xFFFF) { From 01fd0114df521e4e7e8487a62acc963b30e518ef Mon Sep 17 00:00:00 2001 From: Chris Lange Date: Mon, 11 May 2026 23:02:43 -0600 Subject: [PATCH 3/3] fix: Apply Qodo /improve pass 2 on PR #199: faulted task instead of sync throw Test fake's GetLanChipInfoAsync now returns Task.FromException for the simulated transient failure case, matching how a real async method surfaces errors. Behavior of the production retry loop is identical (both forms get caught by the same try/await), but the test now exercises the more honest async exception path. --- .../Firmware/FirmwareUpdateServiceTests.cs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs b/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs index 865cc50..3eb0948 100644 --- a/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs +++ b/src/Daqifi.Core.Tests/Firmware/FirmwareUpdateServiceTests.cs @@ -1098,7 +1098,11 @@ public void Send(IOutboundMessage message) if (_remainingTransientFailures > 0) { _remainingTransientFailures--; - throw new InvalidOperationException("Simulated transient post-reboot failure."); + // Faulted task (not sync throw) more accurately simulates how + // a real async method surfaces failure — caller's await sees a + // genuinely-async exception path. + return Task.FromException( + new InvalidOperationException("Simulated transient post-reboot failure.")); } return Task.FromResult(_chipInfo); }