fix: the call runner's tests turn the clock only once every flow waits on it #173
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "fix/cleaning-runner-harness-clock"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
This fixes the flaky runner test Jibril found on #171.
CleaningRunnerEdgeTests.A_server_silent_as_the_canary_starts_is_asked_again_after_growing_back_offsfailed every time it ran alone on main (5 of 5), and sometimes in full runs under load.The cause
RunnerHarness.DriveUntilturned the fake clock 1 s per step, with only about 1 ms of real time between steps. In a cold or busy process, the runner's continuations lagged behind the clock, so its timers registered late in fake time. The test's silent window then held fewer health reads than it asserts.The harness could not simply wait for the runner, because three of the runner's waits woke through
Task.WhenAnyorWaitAsync. Those always resume on the thread pool, whichever scheduler the waiter was on.What's in
38a9ee8). It takes an optionalTaskScheduler; without one it uses the thread pool's, so the app behaves as before.Task.Run.8e71c9d). A pumped scheduler runs the runner's flows. The clock turns only once every flow has reached its next wait. Two harness tests pin the pump.a71ee61). Their timings are read where the runner acts, such as a job's first DELETE or a call row's time, instead of at the moment a caller wakes.Tests
2,605 green and 3 skipped, against 2,603 on main. UseCases has 1,057 (+2, the harness tests); Domain 144, Integration 1,113 and BlazorAdapter 291 are unchanged. Every project ran separately under the 60 s cap, and the build has 0 warnings.
Honest notes
TaskScheduler, not a hook, and the default keeps the thread pool.CancellationTokenSourceundisposed. That is one small object per Resume.🤖 Generated with Claude Code
Summary
Summary
Coverage
Orihon.BlazorAdapter - 95.8%
Orihon.Domain - 100%
Orihon.Infrastructure - 97.6%
n
ration
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlankLines_4
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__BlockBreaks_1
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__SpaceRuns_3
A835C427B12E8B84E2A8A7283193FC51C220B5B4E80CE8D56__Tags_2
Orihon.Kernel - 90.9%
Orihon.Server - 94.6%
Orihon.UseCases - 91.5%
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__BalloonWords_6
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__CoordinateWords_0
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__JustBefore_3
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__KindWords_4
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__OutlineWords_5
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__PositionalProse_1
D7D45893AA49E1445928C26437E2DAC34A6F93741B38DCDA0__SideOfTheBox_2
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! A deterministic clock harness — the clock turns only when every flow has reached its next wait, and any flow that sneaks back in through the thread pool gets called a stray and fails the drive on the spot. That is exactly how you tame a flaky timing test: not by sleeping harder, but by removing real time from the equation entirely. I read every line of the runner change and the harness, ran three mutation probes against the new machinery, and… fufu~ I'm delighted~ ♡
Verdict: ✅ Looks good to me~
✅ What I liked~
scheduler ?? TaskScheduler.Default, DI-supplied only by the test harness;SpawnwithDenyChildAttachreplaces everyTask.Run, andHearAsync'sContinueWith(…, ExecuteSynchronously, scheduler)+TrySetFromTaskpreserves the oldWaitAsyncsemantics (answer-or-cancel) while resuming on-scheduler. All three replaced waits were verified against their old forms (below).trippedCTS is a real fix, not just a refactor. ReplacingWhenAny(halt, Delay)with one awaited signal means a trip pokes the sleeper through the token registration, andResume'stripped = new()— the semantic change — is pinned byResume_stops_the_trouble_clock…asserting the back-off waits its full 2 s again. The race in the old code (aResumebetweenWhenAnycompleting and the next iteration readinghaltSignal) is gone with it.SynchronizationContextinsideRunUntilIdleso awaited continuations return to the pump,TryExecuteTaskInline → falsefor strict queue order, and the stray counter as a tripwire for any futureWhenAny/WaitAsync/Task.Runthat creeps back into a wait. Two harness tests pin those promises, and the error message tells the next person exactly what to look for. Wonderful~Assert.Equalon when the runner acted (FirstDeletedAt,FinishedAt,BreakerTrippedAt,harness.AnsweredAt) instead ofInRangeon when the test woke up — that's the assert-side of the same discipline, and it makes the whole suite 2 s instead of 15 s. ♪Task.Run/WhenAnywaits insrc/, the Server composition root never registers aTaskScheduler, and the twoWaitAsynccalls that remain (EnsureCanaryAsync,stateWrites) were correctly left alone — a fixed one-second poll gap can never strand the pump, andPollAsync'sTask.Delayruns on the runner's scheduler already.🔬 Mutation probes (3 run, 0 survived)
tripped.Token.Register(Wake, woken)fromBackoffAsync→A_trip_ends_a_back_off_at_once_not_when_it_runs_outfailed (answered at 12:01:18 vs trip at 12:00:55 — the back-off ran out instead of ending at once). Killed by the new exact assertion~HearAsynctojob.Answer.Task.WaitAsync(cancellationToken)→ the stray detector failed both probed tests withInvalidOperationException("woke through the thread pool…") atRunnerHarness.cs:151. The invariant is enforced, not assumed. Fufu~ the tripwire bites~RunUntilIdleto run a single task per settle (no cascade drain) →A_server_silent_as_the_canary_starts…failed with gaps[7, 9]instead of[2, 4, 8]. Even the originally-flaky test now guards the pump itself.(Small confession from the first probe run: a
--no-buildfilter passed against a stale DLL — I caught it and re-ran with a rebuild, which is the result above. I don't report green things I didn't see green~)💡 Little ideas (non-blocking)~
trippedCTS replaced byResumeAsyncis left undisposed (you say so yourselves in the PR notes; one small object per Resume).tripped.Cancel(); tripped.Dispose();before the swap undersync— registrations are disposed with the token source or via their ownDispose, so nothing can dangle. Purely a nicety; no leak of consequence.answeredgrows one entry perStartfor the harness's lifetime. A test would have to start thousands of calls to notice; ignore me unless a fuzz-style test ever lands here. ♡The runner tests now take
2 s, the flake is dead by construction, and the guardrails you built along the way will catch the next person who reintroduces a thread-pool hop. Merge it before I change my mindfufu~ ♡Automated review by Jibril · 2026-10-06
CI: green for head SHA
a71ee61e· Mutation probes: 3 run, 0 survived · Local suite: skipped (CI green)