perf(worker): message-driven completion waits — the STA pumps continuously while waiting

This commit is contained in:
Joseph Doherty
2026-08-15 16:58:54 -04:00
parent afec56d03b
commit dc9424d3bd
6 changed files with 396 additions and 19 deletions
@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Google.Protobuf.WellKnownTypes;
@@ -177,6 +178,77 @@ public sealed class MxAccessValueCacheTests
Assert.Equal(1UL, value.Version);
}
/// <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
/// messages dispatched, so the OnDataChange being waited for could not
/// have arrived at all — and then reported a timeout.
/// </summary>
[Fact]
public void TryWaitForUpdate_WakesOnCrossThreadSet_DespiteLongPollInterval()
{
MxAccessValueCache cache = new();
Timestamp sourceTimestamp = Timestamp.FromDateTime(DateTime.UtcNow);
using ManualResetEventSlim waitEntered = new(false);
Task setter = Task.Run(() =>
{
// 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);
cache.Set(7, 21, BuildEvent(7, 21, intValue: 8080, quality: 192, sourceTimestamp));
});
Stopwatch elapsed = Stopwatch.StartNew();
bool found = cache.TryWaitForUpdate(
serverHandle: 7,
itemHandle: 21,
sinceVersion: 0,
deadlineUtc: DateTime.UtcNow.AddSeconds(3),
pumpStep: () => waitEntered.Set(),
out MxAccessValueCache.CachedValue value,
pollIntervalMs: 10_000);
elapsed.Stop();
setter.Wait(TimeSpan.FromSeconds(5));
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.");
}
/// <summary>
/// Verifies the pump step keeps running throughout a wait that nothing
/// ever signals: the caller's poll interval is capped at the 50 ms
/// fallback tick, so a timing-out wait still pumps repeatedly instead of
/// once. This is what keeps the STA dispatching COM events in a process
/// whose message queue never wakes the wait, and it is the safety net
/// behind the ReadBulk per-tag timeout.
/// </summary>
[Fact]
public void TryWaitForUpdate_KeepsPumping_WhenPollIntervalExceedsTheFallbackTick()
{
MxAccessValueCache cache = new();
int pumpCalls = 0;
bool found = cache.TryWaitForUpdate(
serverHandle: 7,
itemHandle: 21,
sinceVersion: 0,
deadlineUtc: DateTime.UtcNow.AddMilliseconds(400),
pumpStep: () => Interlocked.Increment(ref pumpCalls),
out _,
pollIntervalMs: 10_000);
Assert.False(found);
Assert.True(pumpCalls >= 3, $"Expected repeated pumping across the 400 ms wait, saw {pumpCalls} calls.");
}
private static MxEvent BuildEvent(
int serverHandle,
int itemHandle,