test(dcl+communication+storeandforward+kpihistory+host): close register row-40-class sleeps + row-32 provider leak (arch-review 2026-08-19 P8-8 + P8-9)
P8-8: replace the remaining wall-clock sleep-then-assert instances outside Communication.Tests' Medium-wave sweep with the same row-40 house pattern — continuous polling (AssertHoldsFor) for ABSENCE assertions instead of a single end-of-window sample, which cannot distinguish "stayed quiet throughout" from "hadn't happened yet when the one sample landed" and cannot catch a value that flips wrong and back before that sample. - DataConnectionActorBatchTests: QualityCounters_ArePushedOnTransitionOnly_AndCoalesced (:206/:214 class) converted to AssertHoldsFor; ResolutionProbes_BackOff... (:181/:184, a genuine real-time backoff-RATE assertion, not a sync-primitive stand-in) converted to AssertRoundsWithinWindow, which enforces the ceiling continuously (fails fast on a no-backoff regression) while the floor still necessarily waits out the window. - SiteAlarmLiveCacheServiceTests (:452-454, :470-472): converted to the project's own AssertHoldsFor (mirrors SiteAlarmAggregatorActorTests', same assembly). - StoreAndForwardServiceTests (:891-892): async AssertHoldsForAsync variant (the class runs on the async test thread). - KpiHistoryRecorderActorTests (:1043-1049): two skip-path FoldCount==0 absence checks converted. Each helper is a private per-file copy, matching the Medium-wave T17 precedent (SiteAlarmAggregatorActorTests.AssertHoldsFor) rather than a shared TestSupport utility. Proof of the mechanism: a standalone (non-repo) harness run 100 trials of a real background thread flipping a boolean true then back to false inside a 300ms window — Thread.Sleep(300)+one sample missed the violation 100/100 times (false pass), continuous 20ms polling caught it 100/100 times. Most of the actual conversions here guard monotonic counters (their single-sample variant would eventually see the final value too), so each converted test carries an inline honesty note on what specifically the conversion buys for that assertion (fail-fast vs. true flip-back detection). P8-9: ScadaBridgeTelemetryTests.EmitHelpers_DoNotThrow called SetQueueDepthProvider(() => 5) against the process-global row-32 provider slot with no clear and no [Collection] — a landmine for any future same-process reader of scadabridge.store_and_forward.queue.depth. Fixed with compare-and-clear in a finally via the existing (production, untouched) ClearQueueDepthProvider. Searched this assembly and the whole test tree for a second same-process reader that asserts on the value: none exists today (MetricsEndpointTests scrapes /metrics but only asserts on the response shape, and the only value-asserting test, QueueDepthGaugeTests, lives in StoreAndForward.Tests — a separate test-host process the leak cannot reach), so no [Collection] was added; the test's doc comment records the search and flags it for re-check if that changes. Test-only change, zero production edits. Full suites green: DCL.Tests 342/342, Communication.Tests 729/729, StoreAndForward.Tests 135/135, KpiHistory.Tests 54/54, Host.Tests 522/522. Each converted test additionally looped 20x in isolation with zero flakes.
This commit is contained in:
@@ -786,6 +786,29 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
|
||||
while (Interlocked.CompareExchange(ref target, value, current) != current);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register row-40 house pattern for an ABSENCE assertion (mirrors the Medium-wave
|
||||
/// <c>SiteAlarmAggregatorActorTests.AssertHoldsFor</c> treatment, ported here async since
|
||||
/// this class's facts run on the async test thread): proves <paramref name="holds"/> keeps
|
||||
/// being true for the whole <paramref name="window"/> by polling, rather than a single
|
||||
/// blind <c>await Task.Delay(window)</c> followed by one end-of-window sample. A bare
|
||||
/// delay-then-assert cannot distinguish "the mechanism correctly stayed quiet" from "the
|
||||
/// mechanism just hadn't run yet when the one sample was taken", and it can't catch a
|
||||
/// value that flips wrong and then flips back before that single sample. This fails
|
||||
/// immediately the first time <paramref name="holds"/> goes false, and only returns once
|
||||
/// the full window has elapsed with it still true throughout.
|
||||
/// </summary>
|
||||
private static async Task AssertHoldsForAsync(TimeSpan window, Func<bool> holds, string because)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + window;
|
||||
while (true)
|
||||
{
|
||||
Assert.True(holds(), because);
|
||||
if (DateTime.UtcNow >= deadline) return;
|
||||
await Task.Delay(20);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Task 12: deferToSweep enqueue mode ──
|
||||
|
||||
/// <summary>
|
||||
@@ -888,8 +911,13 @@ public class StoreAndForwardServiceTests : IAsyncLifetime, IDisposable
|
||||
service.TriggerSweep(); // the clobbering kick
|
||||
|
||||
var stop = service.StopAsync();
|
||||
await Task.Delay(300);
|
||||
Assert.False(stop.IsCompleted); // pre-fix: StopAsync already returned (awaited the no-op)
|
||||
// The real sweep is blocked on `release` behind `entered`, so `stop` is genuinely
|
||||
// unable to complete during this window (not merely "hasn't yet") — proving it stays
|
||||
// incomplete throughout, not just at one end-of-delay sample, is what actually
|
||||
// distinguishes "waited for the real sweep" from "pre-fix: awaited the no-op and
|
||||
// returned instantly, then this single sample raced whether that had happened yet".
|
||||
await AssertHoldsForAsync(TimeSpan.FromMilliseconds(300), () => !stop.IsCompleted,
|
||||
"pre-fix: StopAsync already returned (awaited the no-op)");
|
||||
|
||||
release.TrySetResult();
|
||||
await stop.WaitAsync(TimeSpan.FromSeconds(5)); // drains the real sweep promptly once released
|
||||
|
||||
Reference in New Issue
Block a user