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
@@ -100,6 +100,20 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
this.variantConverter = variantConverter ?? throw new ArgumentNullException(nameof(variantConverter));
this.statusProxyConverter = new MxStatusProxyConverter();
this.alarmCommandHandler = alarmCommandHandler;
// The no-op fallback is a TEST affordance only — never pass null on a
// real STA. The cache wait loops this pump step feeds
// (MxAccessValueCache.TryWaitForUpdate,
// MxAccessWriteCompletionCache.TryWaitForCompletion) block in
// StaWaitHelper.WaitForSignalOrMessages, whose contract is that the pump
// step drains the message queue to empty on every call: the wait wakes on
// input that is merely present, so a pump step that removes nothing makes
// every wait return instantly and turns the loop into a busy spin at 100%
// CPU until its deadline (deadline and reply shape still hold — it is a
// CPU fault, not a correctness one). It is harmless here only because the
// fakes that pass null pre-populate the caches and run on threads with no
// windows and no pending input, so there is never a message left behind
// for the wait to keep waking on.
this.pumpStep = pumpStep ?? (static () => { });
this.writeCompletionTimeout = writeCompletionTimeout ?? DefaultWriteCompletionTimeout;
}
@@ -32,11 +32,26 @@ public sealed class MxAccessValueCache
// the value is recorded from inside the waiter's own pumpStep (COM
// dispatches OnDataChange on the STA), so the message wake is what does the
// work there; this handle covers the cross-thread producers — the tests, and
// any future off-STA writer. Not IDisposable on purpose: the cache is
// co-owned by MxAccessBaseEventSink and MxAccessSession with no single
// teardown owner, and a late COM callback must never Set a disposed handle.
// One kernel event per session (one session per worker process), released by
// the handle's finalizer.
// any future off-STA writer.
//
// Single-waiter invariant: an AutoResetEvent releases exactly one waiter per
// Set. Today the sole waiter is the STA (one worker per session, and ReadBulk
// holds the apartment while it waits), so no wake is ever lost. A second
// concurrent waiter would not hang — the loser still re-checks the entry
// every fallback tick and still honors its deadline — it would just fall
// back to 50 ms latency for that iteration.
//
// NOT IDisposable, and that is one invariant with the wait's use of
// DangerousGetHandle: the cache is co-owned by MxAccessBaseEventSink and
// MxAccessSession with no single teardown owner, so a late COM callback
// could otherwise Set a disposed handle. StaWaitHelper deliberately does not
// DangerousAddRef the handle; GC.KeepAlive there closes the *finalization*
// race but not a dispose race — if this field were ever disposed while the
// STA sat inside the wait, the OS could recycle that handle value and the
// wait would silently attach to an unrelated object. Making these caches
// IDisposable is therefore not a free refactor: it must add DangerousAddRef/
// DangerousRelease around the wait in the same change. One kernel event per
// session (one session per worker process), released by the finalizer.
private readonly AutoResetEvent updateSignal = new(false);
/// <summary>Records a fresh OnDataChange payload for the given handle pair.</summary>
@@ -31,11 +31,26 @@ public sealed class MxAccessWriteCompletionCache
// worker the completion is recorded from inside the waiter's own pumpStep
// (COM dispatches OnWriteComplete on the STA), so the message wake is what
// does the work there; this handle covers the cross-thread producers — the
// tests, and any future off-STA recorder. Not IDisposable on purpose: the
// cache is co-owned by MxAccessBaseEventSink and MxAccessSession with no
// single teardown owner, and a late COM callback must never Set a disposed
// handle. One kernel event per session (one session per worker process),
// released by the handle's finalizer.
// tests, and any future off-STA recorder.
//
// Single-waiter invariant: an AutoResetEvent releases exactly one waiter per
// Set. Today the sole waiter is the STA (one worker per session, and the
// write path holds the apartment while it waits), so no wake is ever lost. A
// second concurrent waiter would not hang — the loser still re-checks the
// entry every fallback tick and still honors its deadline — it would just
// fall back to 50 ms latency for that iteration.
//
// NOT IDisposable, and that is one invariant with the wait's use of
// DangerousGetHandle: the cache is co-owned by MxAccessBaseEventSink and
// MxAccessSession with no single teardown owner, so a late COM callback
// could otherwise Set a disposed handle. StaWaitHelper deliberately does not
// DangerousAddRef the handle; GC.KeepAlive there closes the *finalization*
// race but not a dispose race — if this field were ever disposed while the
// STA sat inside the wait, the OS could recycle that handle value and the
// wait would silently attach to an unrelated object. Making these caches
// IDisposable is therefore not a free refactor: it must add DangerousAddRef/
// DangerousRelease around the wait in the same change. One kernel event per
// session (one session per worker process), released by the finalizer.
private readonly AutoResetEvent completionSignal = new(false);
/// <summary>Records the status rows of a fresh OnWriteComplete callback for the given handle pair.</summary>