test(device): give the time-dependent internals an injectable clock so seconds-long deadlines are testable - #702
Conversation
…o seconds-long deadlines are testable Every timeout, deadline and backoff in Daqifi.Core read the ambient system clock, so the only way to test one was to wait it out. The long ones — the text exchange's first-response timeout, the SD download watchdog, the reconnect backoff ladder — were therefore asserted at their short-timeout edges or not at all, and the tests that did touch them had to buy slack against a loaded runner (issue #637). Injects TimeProvider into the internals that read a clock, defaulting to TimeProvider.System everywhere: GetTimestamp() IS Stopwatch.GetTimestamp() and Task.Delay(d, TimeProvider.System, ct) IS Task.Delay(d, ct), so no timeout, delay or ordering changes. The seam is internal — no public API movement, and PublicAPI.Shipped.txt is untouched. Covered by nine new FakeTimeProvider tests over deadlines that were previously unreachable: the shipping reconnect ladder walked in full including its MaxDelay ceiling (91s of device time, ~130ms of real), the SD download watchdog on the shipping 30-minute budget, the outbound drain barrier's bound, and the throttle's five-second interval at its exact boundary. closes #637 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
PR Summary by QodoInject clocks into device internals for deterministic deadline tests
AI Description
Diagram
High-Level Assessment
Files changed (17)
|
Code Review by Qodo
1.
|
…ly when it gives up Qodo round 1. The pump returned normally when its real-time bound expired, leaving each caller to notice that the work it was waiting for had not finished. Today's callers all do notice — the reconnect tests await a task WaitFor has already wrapped in a 15s bound, and the other three assert IsCompleted themselves — so nothing could actually hang. But a helper that hands back a device-time total meaning nothing is a footgun the next caller has to remember to disarm. It now asserts, and takes the caller's own sentence for the failure message so the specific assertions it replaces do not lose what they said. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit 8dccc52 |
#701 landed the text exchange's terminator short-circuit, which logs the exchange's elapsed time through the Stopwatch this branch replaced with the host's TimeProvider. The two merged cleanly and did not compile: routed its log through ElapsedMs() like every other elapsed read in the engine. Caught by CI on the merge ref, not locally — the branch built and tested green on its own base. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
/agentic_review |
|
Code review by qodo was updated up to the latest commit fb88add |
#702 (TimeProvider seam) landed on main and touched eight files this branch had converted to file-scoped namespaces, so every conflict was #702's content against this branch's dedent of the same lines. Resolved by taking origin/main's version of all eight verbatim and re-running `dotnet format style --diagnostics IDE0161` over the result, rather than by hand- merging: this branch's only change to those files was the namespace conversion (verified with `git diff -w` against the merge base - not one substantive line), so re-deriving it mechanically cannot drop any of #702's work. Checked after the merge: `git diff -w` against origin/main shows no changed line outside .editorconfig, Directory.Build.props, CONTRIBUTING.md, DEVICE_INTERFACES.md, IntelHexParser.cs, PublicAPI.Unshipped.txt and the two test files that is anything other than a namespace or brace line.
What was wrong
Every timeout, deadline and retry backoff in
Daqifi.Coreread the ambient system clock directly, so the only way to test one was to actually wait it out. That made the library's longest-running safety nets the least-tested code in it: the text exchange's first-response timeout, the SD download watchdog from #399, and the reconnect backoff ladder are all counted in seconds to minutes, so they were asserted at their short-timeout edges — or, in the ladder's case, not really asserted at all. Every reconnect test in the suite runs a millisecond-scale stand-in policy and checks only that each wait is longer than the last, which a ladder that never honoured its ceiling would satisfy just as well.The same missing seam is what makes the tests that do touch a deadline fragile. They have to leave slack for a loaded runner, and the slack is a guess — the SD watchdog test carries a comment explaining that its budget was raised from 200 ms to 2 s after the old margin lost a race on macOS CI and turned
mainred after every test in the run had passed.How it was fixed
TimeProvideris injected into the internals that read a clock — the text exchange, the operation serializer's drain barrier, the error throttle, the reconnect supervisor, the channel-population wait and the SD card operations — defaulting toTimeProvider.Systemeverywhere.Nothing about production timing changes.
TimeProvider.System.GetTimestamp()isStopwatch.GetTimestamp(), andTask.Delay(d, TimeProvider.System, ct)isTask.Delay(d, ct). No timeout is shortened, no delay is skipped, no ordering moves. The deadlines that were deliberately monotonic stay monotonic — the two that usedDateTime.UtcNow(the drain barrier and the reconnect duration) actually get stronger, since they now use an elapsed-time source a clock correction cannot move under a request in flight.The seam is internal, so there is no public API movement and
PublicAPI.Shipped.txtis untouched. Exposing aTimeProvideronDeviceConnectionOptions/ReconnectOptionsis a real API decision now that the surface is tracked (#636), and #637 explicitly defers it.The thing a reviewer should push on: the pump loops. A test cannot advance a fake clock to one precise instant, because the code under test registers each wait only when it reaches it — so
FakeClockPumpsteps the clock in slices until the work completes. That overshoots, always in the same direction: a jump landing before a wait is registered just moves the clock, and the wait is then created relative to the new now. So overshoot can only grant the code under test more device time, never less, and every assertion built on it is one-sided ("it did not give up before its budget"). The helper's remarks say this out loud; the reasoning is what makes the assertions sound.Verification
Full suite green on both target frameworks: 4143 passed, 3 skipped (
Daqifi.Core.Tests) and 217 passed (Daqifi.Mcp.Tests), on net9.0 and net10.0. CI green on ubuntu, windows and macOS.origin/mainis merged in. #701 landed the text exchange's terminator short-circuit while this was open, and it logs the exchange's elapsed time through theStopwatchthis branch replaced — the two merged cleanly and did not compile. Caught by CI on the merge ref, since the branch built green on its own base; its log now goes throughElapsedMs()like every other elapsed read in the engine.Nine new tests, over deadlines that were previously unreachable:
MaxDelayceilingOutagecovering the whole backoffTwo existing tests were converted rather than added to: the silent-device response-timeout test went from 3 s of real waiting to 77 ms, and now asserts the full 3000 ms window exactly instead of allowing the exchange to give up 500 ms early; the throttle's collapse test dropped its two
Thread.Sleeps and now asserts the interval the device actually ships with rather than a 150 ms stand-in.Two of the issue's success criteria are not met and were not attempted here, since they are the bulk test conversion rather than the seam:
Task.Delay/Thread.Sleepcount inDaqifi.Core.Testsis unchanged at 185 (three real sleeps removed, two yields added inside the shared pump helper)Worth noting for anyone reading #637 later: all five flake tickets it cites (#634, #632, #589, #559, #516) are already closed, and #634's fix was a test seam rather than a widened bound, so it is already immune to scheduler load. The prize this PR actually collects is the third cost the issue names — the long-timeout paths — not the flakes.
No bench run: this is a test-infrastructure change with no wire-format or device-behaviour component, and the production default is byte-for-byte the previous behaviour.
closes #637
🤖 Generated with Claude Code