diff --git a/docs/MxAccessWorkerInstanceDesign.md b/docs/MxAccessWorkerInstanceDesign.md
index 29211b5..4372eba 100644
--- a/docs/MxAccessWorkerInstanceDesign.md
+++ b/docs/MxAccessWorkerInstanceDesign.md
@@ -261,6 +261,54 @@ is still responsive. Shutdown marks the runtime as closing, wakes the pump,
rejects new commands, cancels queued work, uninitializes COM on the STA, and
waits for the thread to exit.
+### Inner Completion Waits
+
+Two commands hold the STA while waiting for a COM event they just provoked: the
+unary write path waits for its `OnWriteComplete`
+(`MxAccessWriteCompletionCache.TryWaitForCompletion`), and `ReadBulk` waits per
+tag for the first `OnDataChange` (`MxAccessValueCache.TryWaitForUpdate`). Both
+run the same loop shape as the outer pump, for the same reason — the event they
+are waiting for *is* a Windows message, so the thread must keep dispatching to
+receive it:
+
+```text
+loop:
+ pumpStep() # PeekMessage / TranslateMessage / DispatchMessage
+ if cache entry newer than baseline: return it
+ if now >= deadline: return the timed-out shape
+
+ MsgWaitForMultipleObjectsEx(
+ cache_update_event,
+ min(remaining, 50 ms),
+ QS_ALLINPUT,
+ MWMO_INPUTAVAILABLE)
+```
+
+The idle slice is a Win32 wait (`StaWaitHelper.WaitForSignalOrMessages`), never
+`Thread.Sleep`. A sleeping STA pumps no messages, so a sleep-polled loop could
+only dispatch the awaited COM event at poll-tick granularity while stalling
+*every other* event for the same tick — up to 1.5 s for a write completion and
+up to `timeout_ms` per tag for `ReadBulk`. The Win32 wait returns the instant a
+message needs pumping, so the apartment dispatches continuously for the whole
+wait. Each cache also sets an `AutoResetEvent` from its update path (outside the
+cache lock) so a cross-thread producer wakes the waiter immediately; in the live
+worker the update arrives on the STA from inside `pumpStep` itself, and the
+message wake is what carries it.
+
+The wait slice is capped at 50 ms so `pumpStep` runs periodically even when
+nothing wakes the wait — a process with no STA message queue (unit tests drive
+these caches from ordinary threads, standing in for the STA by updating the
+cache from a fake `pumpStep`) must not block for a full poll interval. Timeouts,
+deadline math, and return values are unchanged by the wait mechanism: an expired
+write wait still yields the empty-`statuses` unconfirmed reply, and an expired
+per-tag `ReadBulk` wait still reports its own timeout.
+
+The write wait's budget is `MxGateway:Worker:WriteCompletionWaitMilliseconds`
+(default 1500). It is a bounded hold on the STA per unary write, so deployments
+whose write workload is effectively fire-and-forget — no consumer reads the
+reply's `statuses` — can lower it, or set `0` to skip the wait entirely and
+reply on acceptance alone.
+
## COM Creation
The MXAccess analysis source at `C:\Users\dohertj2\Desktop\mxaccess` identifies
@@ -378,16 +426,30 @@ If event conversion throws, catch it inside the event handler, record a
structured `WorkerFault`, and keep the worker alive only if the fault policy
allows it.
-The event drain loop streams queued events as `WorkerEvent` frames. A single
-event whose envelope exceeds the negotiated frame maximum is **undeliverable end
-to end** — the pipe maximum sits only the envelope-overhead reserve above the
-public gRPC cap, so a frame the pipe rejects would also be rejected on the
-client-facing stream. The session therefore faults on it rather than dropping it
-(a silent drop makes the event stream unfaithful, and a synthesized placeholder
-is barred by the no-synthesized-events rule), but the death is structured: the
-worker logs the event's identity — family, handles, worker sequence, and sizes,
-never the value — writes a `WorkerFault` with category `ProtocolViolation` and
-command method `EventDrain` carrying the same identity, and only then exits.
+The event drain loop streams queued events as `WorkerEvent` frames. It is
+**signal-driven, not polled**: `MxAccessEventQueue` carries a wake signal that
+`Enqueue` and `RecordFault` release (outside the queue lock, so the STA's enqueue
+stays a lock acquire plus a non-blocking release), and a drain that comes back
+empty waits on that signal rather than sleeping. The signal is capped at one
+pending wake, so a burst coalesces into a single wake and the waiter re-drains
+everything that arrived — the loop must therefore re-check `DrainFault()` and
+re-drain after every wait, never treat a wake as "exactly one event". The 25 ms
+`EventDrainInterval` survives as the **fallback ceiling** on an unsignalled wait,
+not as a latency floor: an event arriving at an idle worker is framed at signal
+latency instead of waiting out a tick, an idle worker parks instead of waking 40
+times a second, and the interval only bounds how long the loop may sleep if some
+future path mutates the queue without signalling.
+
+A single event whose envelope exceeds the negotiated frame maximum is
+**undeliverable end to end** — the pipe maximum sits only the envelope-overhead
+reserve above the public gRPC cap, so a frame the pipe rejects would also be
+rejected on the client-facing stream. The session therefore faults on it rather
+than dropping it (a silent drop makes the event stream unfaithful, and a
+synthesized placeholder is barred by the no-synthesized-events rule), but the
+death is structured: the worker logs the event's identity — family, handles,
+worker sequence, and sizes, never the value — writes a `WorkerFault` with
+category `ProtocolViolation` and command method `EventDrain` carrying the same
+identity, and only then exits.
Operator remediation is configuration: raise `MxGateway:Worker:MaxMessageBytes`
for that workload. Other per-frame rejection codes keep their previous behavior
because they indicate worker bugs, not workload size.
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessValueCacheTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessValueCacheTests.cs
index fded4a2..6b2ef48 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessValueCacheTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessValueCacheTests.cs
@@ -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);
}
+ ///
+ /// 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.
+ ///
+ [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.");
+ }
+
+ ///
+ /// 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.
+ ///
+ [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,
diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs
index 670bc5f..fd817f1 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessWriteCompletionCacheTests.cs
@@ -1,4 +1,7 @@
using System;
+using System.Diagnostics;
+using System.Threading;
+using System.Threading.Tasks;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
@@ -109,6 +112,48 @@ public sealed class MxAccessWriteCompletionCacheTests
Assert.Equal(55, Assert.Single(statuses).Detail);
}
+ ///
+ /// 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
+ /// Windows messages dispatched — and then reported a timeout.
+ ///
+ [Fact]
+ public void TryWaitForCompletion_WakesOnCrossThreadRecord_DespiteLongPollInterval()
+ {
+ MxAccessWriteCompletionCache cache = new();
+ using ManualResetEventSlim waitEntered = new(false);
+
+ Task recorder = Task.Run(() =>
+ {
+ // 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);
+ cache.Record(7, 21, BuildStatuses(detail: 8080));
+ });
+
+ Stopwatch elapsed = Stopwatch.StartNew();
+ bool found = cache.TryWaitForCompletion(
+ 7,
+ 21,
+ sinceVersion: 0UL,
+ deadlineUtc: DateTime.UtcNow.AddSeconds(3),
+ pumpStep: () => waitEntered.Set(),
+ out RepeatedField statuses,
+ pollIntervalMs: 10_000);
+ elapsed.Stop();
+ recorder.Wait(TimeSpan.FromSeconds(5));
+
+ 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.");
+ }
+
/// Verifies that Record stores an independent clone of the caller's rows.
[Fact]
public void Record_ClonesStatuses()
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessValueCache.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessValueCache.cs
index f82429b..abe133f 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessValueCache.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessValueCache.cs
@@ -4,6 +4,7 @@ using System.Threading;
using Google.Protobuf.Collections;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.MxGateway.Worker.Sta;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
@@ -26,6 +27,18 @@ public sealed class MxAccessValueCache
private readonly Dictionary entries = new();
private readonly object syncRoot = new();
+ // Set by Set() so a waiter blocked in TryWaitForUpdate wakes the moment a
+ // value lands instead of after the next blind poll tick. In the live worker
+ // 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.
+ private readonly AutoResetEvent updateSignal = new(false);
+
/// Records a fresh OnDataChange payload for the given handle pair.
/// MXAccess server handle.
/// MXAccess item handle.
@@ -68,6 +81,11 @@ public sealed class MxAccessValueCache
cachedTimestamp,
mxEvent.Statuses.Clone());
}
+
+ // Signaled outside the lock: the waiter re-takes syncRoot (through
+ // TryGet) the instant it wakes, so signaling while holding it would hand
+ // it guaranteed contention.
+ updateSignal.Set();
}
/// Tries to read the most recent cached value for the handle pair.
@@ -107,16 +125,28 @@ public sealed class MxAccessValueCache
///
/// Waits until the cache entry's version exceeds
- /// or the deadline elapses, calling on every poll
+ /// or the deadline elapses, calling on every
/// iteration so the worker's STA can dispatch the inbound MXAccess message.
///
+ ///
+ /// The idle part of each iteration blocks in
+ /// rather than
+ /// Thread.Sleep: a sleeping STA pumps no Windows messages, so a
+ /// ReadBulk waiting on the first OnDataChange would only dispatch it at
+ /// poll-tick granularity — and it holds the apartment for up to
+ /// timeout_ms per tag, stalling every other COM event with it. The
+ /// Win32 wait returns the instant a message needs pumping (or the value
+ /// is recorded from another thread), so the apartment keeps dispatching
+ /// continuously for the whole wait. The per-tag timeout, the deadline
+ /// math, and both return shapes are unchanged.
+ ///
/// MXAccess server handle.
/// MXAccess item handle.
/// Version snapshot captured before the wait.
/// Absolute UTC deadline.
/// Action that pumps any pending Windows messages.
/// The cached value if the update was received before the deadline.
- /// How long to sleep between pump cycles. Default 5 ms.
+ /// Upper bound on one idle wait slice — the cadence at which runs when nothing wakes the wait. Default 5 ms, itself capped at .
/// if an update newer than arrived before the deadline; otherwise .
public bool TryWaitForUpdate(
int serverHandle,
@@ -134,6 +164,12 @@ public sealed class MxAccessValueCache
while (true)
{
+ // Pumped unconditionally at the top of every iteration — including
+ // the one that immediately follows a signaled wait, and the very
+ // first before any waiting. Dispatching the message is what fills
+ // the cache in the live worker, and unit tests stand in for the STA
+ // by driving the cache from their fake pumpStep, so this call must
+ // never be conditional on a real message queue.
pumpStep();
if (TryGet(serverHandle, itemHandle, out value) && value.Version > sinceVersion)
@@ -141,12 +177,18 @@ public sealed class MxAccessValueCache
return true;
}
- if (DateTime.UtcNow >= deadlineUtc)
+ // Same expiry test as the sleep-polled version: remaining <= zero is
+ // exactly DateTime.UtcNow >= deadlineUtc. `value` keeps whatever the
+ // TryGet above left in it on this path, as before.
+ TimeSpan remaining = deadlineUtc - DateTime.UtcNow;
+ if (remaining <= TimeSpan.Zero)
{
return false;
}
- Thread.Sleep(pollIntervalMs);
+ StaWaitHelper.WaitForSignalOrMessages(
+ updateSignal,
+ StaWaitHelper.ClampWaitMilliseconds(remaining, pollIntervalMs));
}
}
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs
index 1bb5e35..bfbfeaa 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessWriteCompletionCache.cs
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading;
using Google.Protobuf.Collections;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
+using ZB.MOM.WW.MxGateway.Worker.Sta;
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
@@ -25,6 +26,18 @@ public sealed class MxAccessWriteCompletionCache
private readonly Dictionary entries = new();
private readonly object syncRoot = new();
+ // Set by Record so a waiter blocked in TryWaitForCompletion wakes the moment
+ // a completion lands instead of after the next blind poll tick. In the live
+ // 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.
+ private readonly AutoResetEvent completionSignal = new(false);
+
/// Records the status rows of a fresh OnWriteComplete callback for the given handle pair.
/// MXAccess server handle.
/// MXAccess item handle.
@@ -47,6 +60,10 @@ public sealed class MxAccessWriteCompletionCache
: 1UL;
entries[key] = new CompletionEntry(version, statuses.Clone());
}
+
+ // Signaled outside the lock: the waiter re-takes syncRoot the instant it
+ // wakes, so signaling while holding it would hand it guaranteed contention.
+ completionSignal.Set();
}
/// Returns the current completion version for a handle pair, or 0 if none was recorded.
@@ -66,19 +83,29 @@ public sealed class MxAccessWriteCompletionCache
}
///
- /// Polls for a completion newer than until it
+ /// Waits for a completion newer than until it
/// arrives or the deadline elapses, calling on every
- /// poll iteration so the worker's STA can dispatch the inbound MXAccess
+ /// iteration so the worker's STA can dispatch the inbound MXAccess
/// OnWriteComplete message. Same loop shape as
/// .
///
+ ///
+ /// The idle part of each iteration blocks in
+ /// rather than
+ /// Thread.Sleep: a sleeping STA pumps no Windows messages, so the
+ /// inbound OnWriteComplete could only ever be dispatched at poll-tick
+ /// granularity. The Win32 wait returns the instant a message needs
+ /// pumping (or the completion is recorded from another thread), so the
+ /// apartment keeps dispatching COM events continuously for the whole
+ /// wait. Timeouts, deadline math, and both return shapes are unchanged.
+ ///
/// MXAccess server handle.
/// MXAccess item handle.
/// Version snapshot captured before the write COM call.
/// Absolute UTC deadline.
/// Action that pumps any pending Windows messages.
/// The recorded status rows if a completion arrived before the deadline; empty otherwise.
- /// How long to sleep between pump cycles. Default 5 ms.
+ /// Upper bound on one idle wait slice — the cadence at which runs when nothing wakes the wait. Default 5 ms, itself capped at .
/// if a completion newer than arrived before the deadline; otherwise .
public bool TryWaitForCompletion(
int serverHandle,
@@ -96,6 +123,12 @@ public sealed class MxAccessWriteCompletionCache
while (true)
{
+ // Pumped unconditionally at the top of every iteration — including
+ // the one that immediately follows a signaled wait, and the very
+ // first before any waiting. Dispatching the message is what fills
+ // the cache in the live worker, and unit tests stand in for the STA
+ // by recording the completion from inside their fake pumpStep, so
+ // this call must never be conditional on a real message queue.
pumpStep();
lock (syncRoot)
@@ -108,13 +141,18 @@ public sealed class MxAccessWriteCompletionCache
}
}
- if (DateTime.UtcNow >= deadlineUtc)
+ // Same expiry test as the sleep-polled version: remaining <= zero is
+ // exactly DateTime.UtcNow >= deadlineUtc.
+ TimeSpan remaining = deadlineUtc - DateTime.UtcNow;
+ if (remaining <= TimeSpan.Zero)
{
statuses = new RepeatedField();
return false;
}
- Thread.Sleep(pollIntervalMs);
+ StaWaitHelper.WaitForSignalOrMessages(
+ completionSignal,
+ StaWaitHelper.ClampWaitMilliseconds(remaining, pollIntervalMs));
}
}
diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaWaitHelper.cs b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaWaitHelper.cs
new file mode 100644
index 0000000..7549d29
--- /dev/null
+++ b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaWaitHelper.cs
@@ -0,0 +1,118 @@
+using System;
+using System.Runtime.InteropServices;
+using System.Threading;
+using Microsoft.Win32.SafeHandles;
+
+namespace ZB.MOM.WW.MxGateway.Worker.Sta;
+
+///
+/// Message-aware blocking wait shared by STA code that must sit idle without
+/// starving the Windows message queue. owns the
+/// runtime's outer idle wait; this helper serves the inner waits performed
+/// by commands that already hold the STA — the write-completion wait and the
+/// ReadBulk first-value wait — which need the same "wake on a signal or on
+/// an inbound message" behavior without owning a pump instance.
+///
+///
+/// Deliberately separate from rather than a new
+/// method on it: the two caches that use this are plain per-session objects
+/// with no pump dependency (they take the pump step as an injected delegate
+/// so tests can drive them off the STA). Keeping the helper in this folder
+/// keeps every user32 wait signature in the worker reviewable in one
+/// place; the declaration below mirrors the pump's exactly.
+///
+internal static class StaWaitHelper
+{
+ ///
+ /// Ceiling on the blind fallback tick. A caller's poll interval is
+ /// clamped to this so no wait can go longer than 50 ms without calling
+ /// its pump step, even when the process has no message queue to wake it
+ /// (unit-test threads) and the wait handle is never signaled.
+ ///
+ internal const int MaxFallbackTickMilliseconds = 50;
+
+ private const uint MsgWaitFailed = 0xFFFFFFFF;
+ private const uint MwmoInputAvailable = 0x0004;
+ private const uint QsAllInput = 0x04FF;
+
+ ///
+ /// Blocks the calling thread until is set, a
+ /// Windows message is available to pump, or the timeout elapses —
+ /// whichever comes first. Unlike this
+ /// never blinds the thread to inbound COM event messages: the wait
+ /// returns the instant one arrives, so the caller can dispatch it.
+ ///
+ /// Wait handle set by whoever produces the awaited result.
+ /// Maximum wait; values at or below zero return immediately.
+ internal static void WaitForSignalOrMessages(
+ WaitHandle signal,
+ int timeoutMilliseconds)
+ {
+ if (signal is null)
+ {
+ throw new ArgumentNullException(nameof(signal));
+ }
+
+ if (timeoutMilliseconds <= 0)
+ {
+ return;
+ }
+
+ SafeWaitHandle safeHandle = signal.SafeWaitHandle;
+ IntPtr[] handles = [safeHandle.DangerousGetHandle()];
+ uint result = MsgWaitForMultipleObjectsEx(
+ (uint)handles.Length,
+ handles,
+ (uint)timeoutMilliseconds,
+ QsAllInput,
+ MwmoInputAvailable);
+
+ // The handle is a field of a live caller object, so it cannot be
+ // collected mid-call; KeepAlive states that dependency explicitly
+ // rather than relying on it.
+ GC.KeepAlive(signal);
+
+ if (result == MsgWaitFailed)
+ {
+ // Degrade to the pre-existing blind sleep instead of throwing or
+ // hot-spinning: the caller is a bounded, deadline-driven poll loop,
+ // and a failed wait must not turn a timed-out write into a fault.
+ Thread.Sleep(timeoutMilliseconds);
+ }
+ }
+
+ ///
+ /// Computes the wait slice for one iteration of a deadline-driven poll
+ /// loop: never past the deadline, never longer than the caller's poll
+ /// interval, and never longer than .
+ ///
+ /// Time left before the caller's absolute deadline.
+ /// The caller's requested pump cadence.
+ /// Milliseconds to wait; zero or less when the deadline has passed.
+ internal static int ClampWaitMilliseconds(
+ TimeSpan remaining,
+ int pollIntervalMilliseconds)
+ {
+ int tick = pollIntervalMilliseconds < 1
+ ? 1
+ : Math.Min(pollIntervalMilliseconds, MaxFallbackTickMilliseconds);
+
+ double remainingMilliseconds = remaining.TotalMilliseconds;
+ if (remainingMilliseconds <= 0d)
+ {
+ return 0;
+ }
+
+ return remainingMilliseconds < tick
+ ? (int)Math.Ceiling(remainingMilliseconds)
+ : tick;
+ }
+
+ [DllImport("user32.dll", SetLastError = true)]
+ private static extern uint MsgWaitForMultipleObjectsEx(
+ uint count,
+ IntPtr[] handles,
+ uint milliseconds,
+ uint wakeMask,
+ uint flags);
+}