fix(discovery): wait out an abandoned port claim instead of reporting no devices - #454
Conversation
… no devices A SerialDeviceFinder pass that ends by timeout or caller cancellation abandons its in-flight probe, but the PortClaims entry survives with Probe.IsCompleted == false. The stale-claim recovery in ProbeSafelyAsync only cleared a COMPLETED claim, so every following pass took the `return null` path and reported the port absent. Bench-measured on a real Nq1 (fw 3.7.2, USB, macOS): | preceding pass | next pass | latency | |-------------------------------|------------|-----------| | completed normally (control) | FOUND | 766-817ms | | timed out at 300ms | no devices | ~1ms | | cancelled at 200ms | no devices | ~1ms | The ~1ms proves the port was never opened. The claim was held 488/490/491ms across three runs before auto-releasing — nowhere near the 30s QuarantineRetryTtlMs, so the abandoned probe was draining, not wedged. Because each skipped attempt costs ~1ms, a caller that retries promptly burns ~18 retries inside the window and concludes no device exists. That is exactly the Daqifi.Mcp shape: DaqifiAgent.DiscoverAsync builds a fresh finder per call. The 2-3s desktop sweep never lands inside the window, which is why only one-shot callers saw it. When a claim is abandoned-but-in-flight and less than AbandonedClaimDrainWaitMs (1s, ~2x the measured drain) has passed, wait for it to clear before giving up, bounded by both that window and the caller's own token/timeout. The wait awaits the EXISTING probe task — it starts no probe and blocks no thread — so the one-blocked-thread-per-wedged-port bound from #294/#295 is untouched: a genuinely wedged port burns the remaining window once, then ages out of it and is skipped in ~1ms as before. Also documents on IDeviceFinder.DiscoverAsync that an empty result means "nothing answered within your budget", not "no device attached". Tests use the existing internal seams (probeOverride, portNameProvider, ResetPortQuarantineForTests, PortProbeHardTimeoutMs) plus a new AbandonedClaimDrainWaitMs knob, with a gated fake probe standing in for the uncancellable native I/O — no hardware required. The two "finds the device" tests were confirmed to fail without the fix. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoFix Serial discovery by waiting for abandoned port claims to drain
AI Description
Diagram
High-Level Assessment
Files changed (3)
|
Code Review by Qodo
1.
|
…open Qodo review on #454, both findings addressed. 1. Drain wait consumed a probe slot. DiscoverAsync took a probeGate slot before calling ProbeSafelyAsync and held it for the whole call, so the new abandoned-claim drain wait — which opens no port and only awaits an already-running task — occupied one of the MaxParallelProbes (4) slots. A pass whose predecessor timed out with every slot busy could park all four slots on drain waits and starve a healthy port for the full window. The gate moves inside ProbeSafelyAsync, wrapping only the Task.Run probe and its hard timeout; claim acquisition and the drain wait now run ungated. The existing DoesNotDelayOtherPortsOnTheSameSweep test used only 2 ports and so passed either way. Added MoreDrainingPortsThanProbeSlots, which drains 5 ports against the cap of 4 with the healthy port last in the list — confirmed to fail with the slot held across the whole call. 2. Bounded every discovery await in the new tests via a shared 30s SweepGuard, so a regression that stops DiscoverAsync from settling fails the test instead of hanging CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 1 addressed in 55fdd6b — both findings were real and are fixed; replies on each inline thread.
Full suite green: 2710 passed / 2 skipped / 0 failed on net9.0 and net10.0. /agentic_review |
The test hard-coded five draining ports against a cap of four. Raising the cap would have left it under-subscribed — passing while no longer contending for a probe slot, which is the entire point of the test. MaxParallelProbes becomes internal so the port list is derived from it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Round 2 came back clean (Bugs 0, no inline findings) — thanks. One commit landed after that review ran: 07b162c, a test-only robustness fix. Requesting one more pass to cover that commit. /agentic_review |
The problem
A
SerialDeviceFinderpass that ends by timeout or caller cancellation abandons its in-flight probe, but thePortClaimsentry survives withProbe.IsCompleted == false. The stale-claim recovery inProbeSafelyAsynconly clears a completed claim, so every subsequent pass takes thereturn nullpath and silently reports the port as absent.Bench-measured on a real Nq1 (fw 3.7.2, USB, macOS) against
origin/main@ 4b8eed2 — full evidence:The ~1 ms proves the port was never opened — a real pass costs ~800 ms. The claim was held 488/490/491 ms across three runs before auto-releasing: nowhere near the 30 s
QuarantineRetryTtlMs, so the abandoned probe was draining, not wedged. Because each skipped attempt costs ~1 ms, a naive immediate-retry loop burns ~18 retries inside the window and concludes no device exists.The 2-3 s desktop sweep never lands inside that window (
ContinuousDeviceFinderwas confirmed unaffected). The gap is the one-shot caller that retries promptly — exactly theDaqifi.Mcpshape, whereDaqifiAgent.DiscoverAsyncbuilds a freshSerialDeviceFinderper call.Option chosen: 3 (bounded wait), with 1's documentation folded in
Daqifi.Mcpand every future one-shot caller would still have to implement backoff, and it adds public API surface to do it.The decisive point for option 3: waiting on an existing claim's probe task starts no new probe and blocks no new thread. The wait awaits the
Taskthat is already running, so the thread-leak bound from #294/#295 that motivated the claim is preserved for free — no tradeoff to make.What changed
When a claim is abandoned-but-in-flight and less than
AbandonedClaimDrainWaitMs(1 s, ~2x the measured drain) has elapsed since abandonment,ProbeSafelyAsyncwaits for it to clear before giving up, then re-attempts the claim. The wait is bounded by both the drain window and the caller's own token/timeout, via a linked CTS.Behaviour for a genuinely wedged port is unchanged in the way that matters: the pass burns the remaining window once, then the claim ages out of the window and is skipped in ~1 ms exactly as before. Nothing is re-probed, and the
QuarantineRetryTtlMsrecovery path is untouched.The claim loop goes from 2 attempts to 3 — the drain wait's worst path is
wait → observe completed claim → clear → claim.Also documented on
IDeviceFinder.DiscoverAsyncthat an empty result means "nothing answered within the budget you gave", not "no device is attached" — so callers give a realistic timeout rather than retrying tightly.Tests
Five new tests in
SerialDeviceFinderTests, using the existing internal seams (probeOverride,portNameProvider,ResetPortQuarantineForTests,PortProbeHardTimeoutMs) plus a new internalAbandonedClaimDrainWaitMsknob. A gated fake probe (DrainProbe) stands in for the uncancellable native I/O, so the state machine is driven deterministically with no hardware:AfterTimedOutPass_WaitsForAbandonedClaimAndFindsDevice— the bench timeout rowAfterCancelledPass_WaitsForAbandonedClaimAndFindsDevice— the bench cancellation rowAfterTimedOutPass_WithoutDrainWait_SilentlyReportsNoDevices— pins the regression itself: port never reopened, bail-out under 500 msWedgedPort_DrainWaitExpiresWithoutReProbing— the thread-leak bound holds; probe started exactly onceDrainWait_IsBoundedByCallerTimeout— a 250 ms caller is not held for a 10 s windowDrainWait_DoesNotDelayOtherPortsOnTheSameSweep— one draining port doesn't stall the rest of the passMoreDrainingPortsThanProbeSlots_StillProbesHealthyPortPromptly(added in review round 1) — 5 draining ports against the cap of 4, healthy port lastEvery discovery await is bounded by a 30 s
SweepGuard, so a regression fails the test rather than hanging CI.Each fix was confirmed against the unfixed code, not just asserted:
MoreDrainingPortsThanProbeSlotsfails with the probe slot held across the whole call.Timing-sensitive cases were run 5x for flakiness — stable.
Review round 1 (Qodo) — both findings accepted
ProbeSafelyAsynccall, so a drain wait (which opens no port) occupied one of 4 slots. A pass that times out with every slot busy abandons all its probes at once, so the next pass can park every slot on drain waits. Gate now wraps only the port open. My existing 2-port test never contended and passed either way — replaced that false confidence with the 5-port test above.SweepGuard.Verification
(An earlier revision of this PR noted 5 failing
FirmwareUpdateServiceTests; those were pre-existing onmainand have since been fixed by #456, now merged in here.)🤖 Generated with Claude Code