fix(worker): StaWaitHelper regression tests + full-drain contract notes; consolidate wait P/Invoke
This commit is contained in:
@@ -179,27 +179,35 @@ public sealed class MxAccessValueCacheTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the wait is message/signal-driven rather than sleep-polled:
|
||||
/// with a poll interval far longer than the deadline, a value set from
|
||||
/// another thread still wakes the wait and is returned well inside the
|
||||
/// deadline. The sleep-polled loop this replaced would have sat blind for
|
||||
/// the whole 10 s interval — on the STA that means 10 s with no Windows
|
||||
/// Verifies the wait slice stays bounded no matter what poll interval the
|
||||
/// caller asks for: with a 10 s interval and a 3 s deadline, a value set
|
||||
/// 50 ms in is still returned in time, because every slice is capped at
|
||||
/// the 50 ms fallback tick. The blind <c>Thread.Sleep</c> this replaced
|
||||
/// would have slept the full interval — on the STA, 10 s with no Windows
|
||||
/// messages dispatched, so the OnDataChange being waited for could not
|
||||
/// have arrived at all — and then reported a timeout.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Scope: this proves the cadence bound, not the signal path. The clamp
|
||||
/// alone would satisfy it, so it stays green if the <c>Set</c>-side
|
||||
/// signal is deleted. <c>StaWaitHelperTests</c> owns the signal path,
|
||||
/// where a five-second wait with no clamp in play makes the handle the
|
||||
/// only thing that can end it early.
|
||||
/// </remarks>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public void TryWaitForUpdate_WakesOnCrossThreadSet_DespiteLongPollInterval()
|
||||
public async Task TryWaitForUpdate_CompletesWithinDeadline_WhenPollIntervalExceedsTheFallbackTick()
|
||||
{
|
||||
MxAccessValueCache cache = new();
|
||||
Timestamp sourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow);
|
||||
using ManualResetEventSlim waitEntered = new(false);
|
||||
|
||||
Task setter = Task.Run(() =>
|
||||
Task setter = Task.Run(async () =>
|
||||
{
|
||||
// Handshake so the value cannot land before the wait starts —
|
||||
// otherwise the first check would satisfy it and prove nothing.
|
||||
waitEntered.Wait(TimeSpan.FromSeconds(5));
|
||||
Thread.Sleep(50);
|
||||
await Task.Delay(50, CancellationToken.None);
|
||||
cache.Set(7, 21, BuildEvent(7, 21, intValue: 8080, quality: 192, sourceTimestamp));
|
||||
});
|
||||
|
||||
@@ -213,13 +221,13 @@ public sealed class MxAccessValueCacheTests
|
||||
out MxAccessValueCache.CachedValue value,
|
||||
pollIntervalMs: 10_000);
|
||||
elapsed.Stop();
|
||||
setter.Wait(TimeSpan.FromSeconds(5));
|
||||
await setter;
|
||||
|
||||
Assert.True(found);
|
||||
Assert.Equal(8080, value.Value.Int32Value);
|
||||
Assert.True(
|
||||
elapsed.Elapsed < TimeSpan.FromSeconds(2),
|
||||
$"The wait should have woken on the cached value, not slept out the poll interval; took {elapsed.ElapsedMilliseconds} ms.");
|
||||
$"The wait should have re-checked within the fallback tick, not slept out the poll interval; took {elapsed.ElapsedMilliseconds} ms.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
+18
-10
@@ -113,25 +113,33 @@ public sealed class MxAccessWriteCompletionCacheTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the wait is message/signal-driven rather than sleep-polled:
|
||||
/// with a poll interval far longer than the deadline, a completion
|
||||
/// recorded from another thread still wakes the wait and is returned well
|
||||
/// inside the deadline. The sleep-polled loop this replaced would have sat
|
||||
/// blind for the whole 10 s interval — on the STA that means 10 s with no
|
||||
/// Verifies the wait slice stays bounded no matter what poll interval the
|
||||
/// caller asks for: with a 10 s interval and a 3 s deadline, a completion
|
||||
/// recorded 50 ms in is still returned in time, because every slice is
|
||||
/// capped at the 50 ms fallback tick. The blind <c>Thread.Sleep</c> this
|
||||
/// replaced would have slept the full interval — on the STA, 10 s with no
|
||||
/// Windows messages dispatched — and then reported a timeout.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Scope: this proves the cadence bound, not the signal path. The clamp
|
||||
/// alone would satisfy it, so it stays green if the <c>Record</c>-side
|
||||
/// <c>Set()</c> is deleted. <c>StaWaitHelperTests</c> owns the signal
|
||||
/// path, where a five-second wait with no clamp in play makes the handle
|
||||
/// the only thing that can end it early.
|
||||
/// </remarks>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public void TryWaitForCompletion_WakesOnCrossThreadRecord_DespiteLongPollInterval()
|
||||
public async Task TryWaitForCompletion_CompletesWithinDeadline_WhenPollIntervalExceedsTheFallbackTick()
|
||||
{
|
||||
MxAccessWriteCompletionCache cache = new();
|
||||
using ManualResetEventSlim waitEntered = new(false);
|
||||
|
||||
Task recorder = Task.Run(() =>
|
||||
Task recorder = Task.Run(async () =>
|
||||
{
|
||||
// Handshake so the completion cannot land before the wait starts —
|
||||
// otherwise the first check would satisfy it and prove nothing.
|
||||
waitEntered.Wait(TimeSpan.FromSeconds(5));
|
||||
Thread.Sleep(50);
|
||||
await Task.Delay(50, CancellationToken.None);
|
||||
cache.Record(7, 21, BuildStatuses(detail: 8080));
|
||||
});
|
||||
|
||||
@@ -145,13 +153,13 @@ public sealed class MxAccessWriteCompletionCacheTests
|
||||
out RepeatedField<MxStatusProxy> statuses,
|
||||
pollIntervalMs: 10_000);
|
||||
elapsed.Stop();
|
||||
recorder.Wait(TimeSpan.FromSeconds(5));
|
||||
await recorder;
|
||||
|
||||
Assert.True(found);
|
||||
Assert.Equal(8080, Assert.Single(statuses).Detail);
|
||||
Assert.True(
|
||||
elapsed.Elapsed < TimeSpan.FromSeconds(2),
|
||||
$"The wait should have woken on the recorded completion, not slept out the poll interval; took {elapsed.ElapsedMilliseconds} ms.");
|
||||
$"The wait should have re-checked within the fallback tick, not slept out the poll interval; took {elapsed.ElapsedMilliseconds} ms.");
|
||||
}
|
||||
|
||||
/// <summary>Verifies that Record stores an independent clone of the caller's rows.</summary>
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using ZB.MOM.WW.MxGateway.Worker.Sta;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.Sta;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for <see cref="StaWaitHelper"/>, the message-aware wait behind the
|
||||
/// write-completion and ReadBulk value waits.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These exist because the cache-level tests cannot guard the signal
|
||||
/// path. Every cache wait clamps its slice to
|
||||
/// <see cref="StaWaitHelper.MaxFallbackTickMilliseconds"/>, so a wait
|
||||
/// woken by the handle and a wait that merely timed out and re-checked
|
||||
/// are milliseconds apart — no wall-clock assertion up there can tell
|
||||
/// them apart, and deleting the caches' <c>Set()</c> calls would leave
|
||||
/// them green. Calling the helper directly with a five-second timeout
|
||||
/// removes the clamp from the picture: only the handle can end that wait
|
||||
/// early, and the test asserts the handle was the thing consumed.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Boundary: the <c>WAIT_FAILED</c> branch is not exercised, for the same
|
||||
/// reason <see cref="StaMessagePumpTests"/> gives — forcing
|
||||
/// <c>MsgWaitForMultipleObjectsEx</c> to fail means handing it a
|
||||
/// deliberately invalid native handle, which is unsafe to construct in a
|
||||
/// managed test.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public sealed class StaWaitHelperTests
|
||||
{
|
||||
/// <summary>Verifies that a null wait handle is rejected.</summary>
|
||||
[Fact]
|
||||
public void WaitForSignalOrMessages_NullSignal_ThrowsArgumentNullException()
|
||||
{
|
||||
ArgumentNullException exception = Assert.Throws<ArgumentNullException>(
|
||||
() => StaWaitHelper.WaitForSignalOrMessages(null!, 10));
|
||||
|
||||
Assert.Equal("signal", exception.ParamName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the wait ends on a cross-thread signal rather than on its
|
||||
/// timeout: the handle is set ~50 ms into a five-second wait, and the
|
||||
/// wait must return two orders of magnitude before that timeout. The
|
||||
/// post-condition that the handle was consumed is what makes this a
|
||||
/// discriminator — an auto-reset event that our wait did not take would
|
||||
/// still be signalled afterwards, which would mean something other than
|
||||
/// the signal ended the wait.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task WaitForSignalOrMessages_SignalSetDuringWait_ReturnsLongBeforeTheTimeout()
|
||||
{
|
||||
using AutoResetEvent signal = new(initialState: false);
|
||||
|
||||
// Drain anything already queued for this thread so the wait cannot be
|
||||
// woken by stale input instead of by the signal.
|
||||
new StaMessagePump().PumpPendingMessages();
|
||||
|
||||
Task setter = Task.Run(async () =>
|
||||
{
|
||||
await Task.Delay(50, CancellationToken.None);
|
||||
signal.Set();
|
||||
});
|
||||
|
||||
Stopwatch elapsed = Stopwatch.StartNew();
|
||||
StaWaitHelper.WaitForSignalOrMessages(signal, 5000);
|
||||
elapsed.Stop();
|
||||
await setter;
|
||||
|
||||
Assert.True(
|
||||
elapsed.Elapsed < TimeSpan.FromMilliseconds(500),
|
||||
$"Wait took {elapsed.ElapsedMilliseconds} ms of its 5000 ms timeout; the signal should have ended it.");
|
||||
Assert.False(
|
||||
signal.WaitOne(0),
|
||||
"The wait did not consume the signal, so something other than the signal ended it.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies an already-signalled handle ends the wait immediately, which
|
||||
/// is the case that keeps a completion recorded between the caller's
|
||||
/// check and its wait from being missed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForSignalOrMessages_PreSignalledHandle_ReturnsImmediately()
|
||||
{
|
||||
using AutoResetEvent signal = new(initialState: true);
|
||||
|
||||
Stopwatch elapsed = Stopwatch.StartNew();
|
||||
StaWaitHelper.WaitForSignalOrMessages(signal, 30_000);
|
||||
elapsed.Stop();
|
||||
|
||||
Assert.True(
|
||||
elapsed.Elapsed < TimeSpan.FromSeconds(5),
|
||||
$"Wait took {elapsed.ElapsedMilliseconds} ms; a pre-signalled handle must return at once.");
|
||||
Assert.False(signal.WaitOne(0), "The wait should have consumed the signal.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the wait actually blocks for its timeout when nothing signals
|
||||
/// it. The lower bound is the point: a wait that returned instantly would
|
||||
/// turn every caller's poll loop into a spin, which is exactly the
|
||||
/// failure mode the queue must be drained to avoid.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void WaitForSignalOrMessages_NeverSignalled_BlocksUntilTheTimeout()
|
||||
{
|
||||
using AutoResetEvent signal = new(initialState: false);
|
||||
|
||||
// The wait wakes on input that is merely present, so drain first —
|
||||
// otherwise a stale message would end the wait early and the lower
|
||||
// bound below would be measuring the wrong thing.
|
||||
new StaMessagePump().PumpPendingMessages();
|
||||
|
||||
Stopwatch elapsed = Stopwatch.StartNew();
|
||||
StaWaitHelper.WaitForSignalOrMessages(signal, 200);
|
||||
elapsed.Stop();
|
||||
|
||||
// Loose lower bound: the OS may return slightly early on a coarse timer
|
||||
// tick, so this proves "it blocked", not "it blocked for exactly 200 ms".
|
||||
Assert.True(
|
||||
elapsed.Elapsed >= TimeSpan.FromMilliseconds(100),
|
||||
$"Wait returned after {elapsed.ElapsedMilliseconds} ms; a 200 ms wait must not return instantly.");
|
||||
Assert.True(
|
||||
elapsed.Elapsed < TimeSpan.FromSeconds(5),
|
||||
$"Wait took {elapsed.ElapsedMilliseconds} ms; a 200 ms timeout must end it.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a non-positive timeout returns without entering the wait at
|
||||
/// all — proven by the signal still being set afterwards, since a wait
|
||||
/// that ran would have consumed it.
|
||||
/// </summary>
|
||||
/// <param name="timeoutMilliseconds">The non-positive timeout under test.</param>
|
||||
[Theory]
|
||||
[InlineData(0)]
|
||||
[InlineData(-1)]
|
||||
public void WaitForSignalOrMessages_NonPositiveTimeout_DoesNotEnterTheWait(int timeoutMilliseconds)
|
||||
{
|
||||
using AutoResetEvent signal = new(initialState: true);
|
||||
|
||||
Stopwatch elapsed = Stopwatch.StartNew();
|
||||
StaWaitHelper.WaitForSignalOrMessages(signal, timeoutMilliseconds);
|
||||
elapsed.Stop();
|
||||
|
||||
Assert.True(
|
||||
elapsed.Elapsed < TimeSpan.FromSeconds(2),
|
||||
$"Wait took {elapsed.ElapsedMilliseconds} ms; a non-positive timeout must not block.");
|
||||
Assert.True(signal.WaitOne(0), "The signal must be untouched when the wait is skipped.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Boundary table for the slice math. Every caller's wait length comes
|
||||
/// from here, so each clause is pinned: a poll interval below one clamps
|
||||
/// up to 1 ms, an expired deadline yields 0 (the caller returns without
|
||||
/// waiting), a deadline nearer than the tick rounds up so the wait never
|
||||
/// ends before the deadline, and any interval above the fallback ceiling
|
||||
/// clamps down to it.
|
||||
/// </summary>
|
||||
/// <param name="remainingMilliseconds">Time left before the caller's deadline.</param>
|
||||
/// <param name="pollIntervalMilliseconds">The caller's requested pump cadence.</param>
|
||||
/// <param name="expected">The expected wait slice in milliseconds.</param>
|
||||
[Theory]
|
||||
// pollInterval < 1 clamps up to 1 ms — never a zero-length spin.
|
||||
[InlineData(1000d, 0, 1)]
|
||||
[InlineData(1000d, -5, 1)]
|
||||
// remaining <= 0 yields 0; the caller has already returned on its deadline.
|
||||
[InlineData(0d, 5, 0)]
|
||||
[InlineData(-10d, 5, 0)]
|
||||
// remaining < tick shortens the wait to the deadline.
|
||||
[InlineData(3d, 5, 3)]
|
||||
[InlineData(1d, 50, 1)]
|
||||
// remaining == tick is not "less than", so the tick stands.
|
||||
[InlineData(5d, 5, 5)]
|
||||
// The caller's interval wins while it is under the ceiling.
|
||||
[InlineData(1000d, 5, 5)]
|
||||
[InlineData(1000d, 49, 49)]
|
||||
// Anything at or above the ceiling clamps to it — this is what bounds the
|
||||
// unconditional pumpStep cadence regardless of what a caller asks for.
|
||||
[InlineData(1000d, 50, 50)]
|
||||
[InlineData(1000d, 10_000, 50)]
|
||||
[InlineData(50d, 10_000, 50)]
|
||||
public void ClampWaitMilliseconds_BoundaryTable(
|
||||
double remainingMilliseconds,
|
||||
int pollIntervalMilliseconds,
|
||||
int expected)
|
||||
{
|
||||
int actual = StaWaitHelper.ClampWaitMilliseconds(
|
||||
TimeSpan.FromMilliseconds(remainingMilliseconds),
|
||||
pollIntervalMilliseconds);
|
||||
|
||||
Assert.Equal(expected, actual);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies a sub-millisecond remainder rounds up rather than down: a
|
||||
/// zero-length wait would spin, and truncating would end the wait before
|
||||
/// the caller's deadline. Built from ticks because
|
||||
/// <see cref="TimeSpan.FromMilliseconds(double)"/> rounds its argument to
|
||||
/// whole milliseconds and so cannot express these values at all.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ClampWaitMilliseconds_SubMillisecondRemaining_RoundsUp()
|
||||
{
|
||||
// 10_000 ticks == 1 ms.
|
||||
Assert.Equal(1, StaWaitHelper.ClampWaitMilliseconds(TimeSpan.FromTicks(4_000), 5));
|
||||
Assert.Equal(3, StaWaitHelper.ClampWaitMilliseconds(TimeSpan.FromTicks(25_000), 5));
|
||||
Assert.Equal(1, StaWaitHelper.ClampWaitMilliseconds(TimeSpan.FromTicks(1), 5));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the clamp never exceeds the time actually left, which is what
|
||||
/// keeps a wait from overrunning its caller's deadline.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void ClampWaitMilliseconds_NeverExceedsRemainingOrTheCeiling()
|
||||
{
|
||||
for (int remaining = 1; remaining <= 200; remaining++)
|
||||
{
|
||||
int slice = StaWaitHelper.ClampWaitMilliseconds(
|
||||
TimeSpan.FromMilliseconds(remaining),
|
||||
pollIntervalMilliseconds: 10_000);
|
||||
|
||||
Assert.True(slice >= 1, $"Slice for {remaining} ms remaining was {slice}; a positive deadline must wait.");
|
||||
Assert.True(slice <= remaining, $"Slice {slice} overran the {remaining} ms left before the deadline.");
|
||||
Assert.True(
|
||||
slice <= StaWaitHelper.MaxFallbackTickMilliseconds,
|
||||
$"Slice {slice} exceeded the {StaWaitHelper.MaxFallbackTickMilliseconds} ms fallback ceiling.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user