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
@@ -0,0 +1,118 @@
using System;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.Win32.SafeHandles;
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
/// <summary>
/// Message-aware blocking wait shared by STA code that must sit idle without
/// starving the Windows message queue. <see cref="StaMessagePump"/> 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.
/// </summary>
/// <remarks>
/// Deliberately separate from <see cref="StaMessagePump"/> 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 <c>user32</c> wait signature in the worker reviewable in one
/// place; the declaration below mirrors the pump's exactly.
/// </remarks>
internal static class StaWaitHelper
{
/// <summary>
/// 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.
/// </summary>
internal const int MaxFallbackTickMilliseconds = 50;
private const uint MsgWaitFailed = 0xFFFFFFFF;
private const uint MwmoInputAvailable = 0x0004;
private const uint QsAllInput = 0x04FF;
/// <summary>
/// Blocks the calling thread until <paramref name="signal"/> is set, a
/// Windows message is available to pump, or the timeout elapses —
/// whichever comes first. Unlike <see cref="Thread.Sleep(int)"/> this
/// never blinds the thread to inbound COM event messages: the wait
/// returns the instant one arrives, so the caller can dispatch it.
/// </summary>
/// <param name="signal">Wait handle set by whoever produces the awaited result.</param>
/// <param name="timeoutMilliseconds">Maximum wait; values at or below zero return immediately.</param>
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);
}
}
/// <summary>
/// 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 <see cref="MaxFallbackTickMilliseconds"/>.
/// </summary>
/// <param name="remaining">Time left before the caller's absolute deadline.</param>
/// <param name="pollIntervalMilliseconds">The caller's requested pump cadence.</param>
/// <returns>Milliseconds to wait; zero or less when the deadline has passed.</returns>
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);
}