test: stop the wall-clock-tight unit tests racing a stalled CI runner - #458
Conversation
…ll clock The two success-path readiness-probe tests configured a 1s budget for a probe that only needs ~30ms, then asserted behaviour: that the probe is polled until it returns true and that Complete is held back until then. On a loaded CI runner that margin is not enough. #452's run got only 2 of the 3 probes away inside the second and failed with "did not become application-ready within 00:00:01 (probes executed: 2)" on net9.0 while net10.0 passed the same test in the same run — the signature of a starved runner, not a regression. Raise both tests' readiness budget to 30s and their JumpingToApplicationTimeout to 60s, preserving the Validate() invariant that the readiness budget stays strictly below the outer state timeout. The probe still succeeds after two 10ms delays, so the happy path is unchanged and neither test gets slower; the headroom only ever matters when the runner stalls. The timeout-path tests keep their short budgets: those assert that the budget expires, so a stall pushes them toward the outcome they already expect. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PR Summary by QodoDeflake post-reconnect readiness-probe tests by increasing timeout headroom
AI Description
Diagram
High-Level Assessment
Files changed (1)
|
Code Review by Qodo
1.
|
DiscoverAsync_HungPort_TimesOutAndStillReturnsHealthyDevices failed on #453 with Assert.Single() finding an empty collection — on net9.0 while net10.0 passed the same test in the same run. #453 touches the live-stream device tests and nothing in discovery, so this is a pre-existing flake, not a regression. Every probe, healthy ones included, is dispatched through Task.Run and so must be handed a thread-pool thread before it can complete. That handoff races Task.Delay(PortProbeHardTimeoutMs), and SerialDeviceFinder abandons a probe still waiting for a thread when the ceiling expires — which is exactly an empty result for COM_OK. Under a saturated pool, thread injection is throttled to roughly one new thread per second, so a 300ms ceiling can expire before the delegate starts. The file already half-knew this: the cross-sweep tests were relaxed to hungProbeCalls <= 1 because "under thread-pool contention the hung probe may not have STARTED", but the healthy-device assertions were left racing the same clock. Raise the ceiling to 2000ms on the four sites that assert a healthy device IS reported, and document the constraint on CreateFinderWithProbes so the next test picks a value deliberately. DiscoverAsync_QuarantineTtl_AllowsPeriodicRetry keeps its 100ms: it only counts probes of a wedged port and depends on the short window. Also tighten the readiness budgets from the previous commit, 60s/30s to 15s/10s, per review: those also bound how long a real JumpingToApp regression takes to surface, and 10s is already an order of magnitude past the stall that broke the 1s budget. Not reproduced locally — a 12-core dev box does not starve the pool the way a 2-core runner running 2700 parallel tests does. The diagnosis rests on the failure signature, the abandon-on-timeout path, and the net9.0/net10.0 split. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two unrelated tests were failing PRs that had nothing to do with either of them. Both are pre-existing flakes on
main— both fail on net9.0 while net10.0 passes the same test in the same run, which is the signature of a starved runner rather than a regression.1. Post-reconnect readiness probe (hit #452)
UpdateFirmwareAsync_PostReconnectReadinessProbe_AwaitedBeforeCompleteandUpdateFirmwareAsync_PostReconnectProbeThrowsOwnOCE_RetriesAndCompletesconfigure a probe that reports not-ready twice then ready, and assertprobeCallCount == 3withCurrentState == Complete. Neither assertion is about how long readiness takes — the budget only exists so a hung probe can't hang the suite. A 1s budget for a ~30ms need was not enough margin: the runner got 2 of the 3 probes away inside the second and the timeout fired one probe short.Budgets raised to 10s readiness / 15s
JumpingToApplicationTimeout(both must move together —Validaterequires readiness to stay strictly below the outer state timeout whenever a probe is set).2. Hung-port discovery sweeps (hit #453)
#453touches the live-stream device tests and nothing in discovery.Every probe — healthy ones included — is dispatched through
Task.Run, so it must be handed a thread-pool thread before it can complete. That handoff racesTask.Delay(PortProbeHardTimeoutMs), andSerialDeviceFinderabandons a probe still waiting for a thread when the ceiling expires. That is precisely an empty result forCOM_OK. Under a saturated pool, thread injection is throttled to roughly one new thread per second, so a 300ms ceiling can expire before the delegate ever starts.The file already half-knew this. The cross-sweep tests were relaxed to
hungProbeCalls <= 1with the comment "under thread-pool contention the hung probe may not have STARTED before the first sweep's hard timeout fires" — but the healthy-device assertions were left racing the same clock.Ceiling raised to 2000ms on the four sites that assert a healthy device is reported, matching what
DiscoverAsync_HungPort_HealthyDeviceEventFiresBeforeSweepSettlesalready used.DiscoverAsync_QuarantineTtl_AllowsPeriodicRetrykeeps its 100ms — it only counts probes of a wedged port and depends on the short window.CreateFinderWithProbesnow documents the constraint so the next test picks a value deliberately rather than picking the smallest number that passed locally.Scope
Tests only — no production code changed. The abandon-on-timeout behaviour in
SerialDeviceFinderis correct and deliberate (#294); the tests were just holding it to a stopwatch.Verification
Not reproduced locally. A 12-core dev box does not starve the .NET thread pool the way a 2-core runner running 2700 parallel tests does; the unfixed discovery test still passes here under 6x CPU oversubscription. The diagnosis rests on the failure signature (an empty collection means the probe never completed), the abandon-on-timeout code path, and the net9.0/net10.0 split — not on a local repro.
🤖 Generated with Claude Code