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); +}