From 470cf4dca158be93c88ad1dd095890bbedb6b86a Mon Sep 17 00:00:00 2001 From: Chris Wolfgang <210299580+Chris-Wolfgang@users.noreply.github.com> Date: Wed, 22 Jul 2026 13:19:34 -0400 Subject: [PATCH] test: de-flake StopTimer_prevents_further_callbacks (#250) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test asserted an exact callback-count equality after only a fixed 50ms grace following StopTimer(). A tick already dispatched when StopTimer was called (at most one) can land after that grace on a slow/loaded runner, incrementing the count and failing the exact-equality assertion. Replaces the fixed grace with a WaitUntilStable poll that returns once the count has been unchanged for a full window — so the last in-flight tick is absorbed regardless of runner speed — then still asserts the count stays put across several further intervals (a running timer would fire ~4 more). Bounded by an overall deadline so a genuinely-broken StopTimer fails the assertion instead of hanging. Verified: 5/5 green in Release. Closes #250 Co-Authored-By: Claude Opus 4.8 --- .../SystemProgressTimerTests.cs | 48 +++++++++++++++++-- 1 file changed, 43 insertions(+), 5 deletions(-) diff --git a/tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/SystemProgressTimerTests.cs b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/SystemProgressTimerTests.cs index e4f0f591..1a5da513 100644 --- a/tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/SystemProgressTimerTests.cs +++ b/tests/Wolfgang.Etl.Abstractions.Tests.Unit/BaseClassTests/SystemProgressTimerTests.cs @@ -45,11 +45,15 @@ public async Task StopTimer_prevents_further_callbacks() capturedTimer!.StopTimer(); - // Allow any in-flight tick to land, then snapshot - await Task.Delay(50); - var countAfterStop = callbackCount; - - // Wait several more intervals — no new callbacks should fire + // A tick already dispatched when StopTimer was called can still land after it + // (at most one). A fixed grace delay is racy on a slow/loaded runner — the + // in-flight tick can land after it — so instead wait until the count has been + // stable for a full window, which absorbs that last tick regardless of runner + // speed. If StopTimer were broken the count would keep growing and this would + // hit its deadline, and the assertion below would then catch it. + var countAfterStop = await WaitUntilStable(() => callbackCount); + + // Wait several more intervals — a still-running timer would fire ~4 more callbacks. await Task.Delay(200); var countAfterWait = callbackCount; @@ -190,6 +194,40 @@ private static async Task WaitUntil(Func condition, int timeoutMs) } + /// + /// Polls until its value has not changed for + /// , then returns that value. Adapts to runner + /// speed: a late in-flight tick resets the stability window rather than racing a + /// fixed delay. Bounded by so a value that never + /// settles (e.g. a timer that failed to stop) returns instead of hanging, letting + /// the caller's assertion report the failure. + /// + private static async Task WaitUntilStable( + Func read, + int stableForMs = 150, + int pollMs = 15, + int maxWaitMs = 3000) + { + var overallDeadline = DateTime.UtcNow.AddMilliseconds(maxWaitMs); + var last = read(); + var stableSince = DateTime.UtcNow; + + while (DateTime.UtcNow - stableSince < TimeSpan.FromMilliseconds(stableForMs) + && DateTime.UtcNow < overallDeadline) + { + await Task.Delay(pollMs); + var current = read(); + if (current != last) + { + last = current; + stableSince = DateTime.UtcNow; + } + } + + return last; + } + + // ------------------------------------------------------------------ // Test double