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:
Joseph Doherty
2026-08-19 16:33:24 -04:00
parent b34ec10f8b
commit 428b8db024
5 changed files with 196 additions and 19 deletions
@@ -177,11 +177,73 @@ public class DataConnectionActorBatchTests : TestKit
ExpectMsg<TagValueUpdate>(u => u.Quality == QualityCode.Bad, TimeSpan.FromSeconds(5));
ExpectMsg<SubscribeTagsResponse>(TimeSpan.FromSeconds(5));
// This is a genuine real-time rate assertion (how many retry rounds a backoff
// schedule produces inside a fixed wall-clock window), not a synchronization
// primitive standing in for "wait until X happens" — proving the floor (>= 2
// rounds) is unavoidably tied to actually waiting out the window. What the old
// blind Thread.Sleep(2000) + single end-of-window sample could NOT catch is a
// regression to full-rate (no-backoff) retrying: it would still land somewhere
// in [2, 8] by luck if the window elapsed while under load, or it would blow
// past 8 well before the 2s mark and nobody would know until the final sample.
// AssertRoundsWithinWindow polls throughout and fails immediately the first time
// the ceiling is breached, so a no-backoff regression is caught fast instead of
// being masked by (or racing) the fixed window.
var initialCalls = adapter.SubscribeBatches.Count;
Thread.Sleep(2000);
var rounds = adapter.SubscribeBatches.Count - initialCalls;
AssertRoundsWithinWindow(TimeSpan.FromSeconds(2),
() => adapter.SubscribeBatches.Count - initialCalls,
minInclusive: 2, maxInclusive: 8,
context: "dead-device probe backoff (floor 150ms, ceiling 600ms)");
}
Assert.InRange(rounds, 2, 8);
/// <summary>
/// Row-40 house pattern adapted for a RATE assertion: proves a monotonically-increasing
/// count stays within <c>[minInclusive, maxInclusive]</c> across the whole
/// <paramref name="window"/>, rather than a single blind sleep-then-sample at the end.
/// The upper bound is enforced continuously (fails fast the instant it is exceeded,
/// well before the window elapses); the lower bound can only be confirmed once the
/// full window has elapsed, since proving "at least N happened by wall-clock time T"
/// requires actually waiting until T.
/// </summary>
private static void AssertRoundsWithinWindow(
TimeSpan window, Func<int> currentRounds, int minInclusive, int maxInclusive, string context)
{
var deadline = DateTime.UtcNow + window;
while (true)
{
var rounds = currentRounds();
Assert.True(rounds <= maxInclusive,
$"{context}: {rounds} rounds already fired, exceeding the ceiling of {maxInclusive} " +
"before the window even elapsed — backoff is not slowing retries down.");
if (DateTime.UtcNow >= deadline)
{
Assert.True(rounds >= minInclusive,
$"{context}: only {rounds} rounds fired in the window (need >= {minInclusive}).");
return;
}
Thread.Sleep(20);
}
}
/// <summary>
/// Row-40 house pattern for an ABSENCE assertion (mirrors the Medium-wave
/// <c>SiteAlarmAggregatorActorTests.AssertHoldsFor</c> treatment): proves
/// <paramref name="holds"/> keeps being true for the whole <paramref name="window"/> by
/// polling, rather than a single blind <c>Thread.Sleep(window)</c> followed by one
/// end-of-window sample. A bare sleep-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.
/// </summary>
private static void AssertHoldsFor(TimeSpan window, Func<bool> holds, string because)
{
var deadline = DateTime.UtcNow + window;
while (true)
{
Assert.True(holds(), because);
if (DateTime.UtcNow >= deadline) return;
Thread.Sleep(20);
}
}
[Fact]
@@ -203,16 +265,18 @@ public class DataConnectionActorBatchTests : TestKit
for (var i = 0; i < 20; i++)
adapter.ValueCallback!("tag1", new TagValue(i, QualityCode.Good, DateTimeOffset.UtcNow));
Thread.Sleep(400);
Assert.DoesNotContain(_health.ReceivedCalls(), c => c.GetMethodInfo().Name == "UpdateTagQuality");
AssertHoldsFor(TimeSpan.FromMilliseconds(400),
() => !_health.ReceivedCalls().Any(c => c.GetMethodInfo().Name == "UpdateTagQuality"),
"20 unchanged-quality repeats must never push UpdateTagQuality");
// One genuine transition → exactly one coalesced push.
adapter.ValueCallback!("tag1", new TagValue(1, QualityCode.Bad, DateTimeOffset.UtcNow));
AwaitCondition(
() => _health.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "UpdateTagQuality") == 1,
TimeSpan.FromSeconds(3));
Thread.Sleep(300);
Assert.Single(_health.ReceivedCalls(), c => c.GetMethodInfo().Name == "UpdateTagQuality");
AssertHoldsFor(TimeSpan.FromMilliseconds(300),
() => _health.ReceivedCalls().Count(c => c.GetMethodInfo().Name == "UpdateTagQuality") == 1,
"the coalesced push must not repeat");
}
[Fact]