fix(worker): StaWaitHelper regression tests + full-drain contract notes; consolidate wait P/Invoke

This commit is contained in:
Joseph Doherty
2026-08-15 17:17:10 -04:00
parent 896d81e286
commit 7c9add3d73
10 changed files with 466 additions and 66 deletions
@@ -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>
@@ -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>