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
@@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
using System.Runtime.InteropServices;
using System.Threading;
using Microsoft.Win32.SafeHandles;
@@ -17,23 +18,22 @@ namespace ZB.MOM.WW.MxGateway.Worker.Sta;
/// 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.
/// so tests can drive them off the STA). The wait itself is not duplicated —
/// both this and the pump call the single declaration in
/// <see cref="StaNativeMethods"/>.
/// </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.
/// Ceiling on the fallback tick — the longest a wait may block when
/// nothing wakes it. A caller's poll interval is clamped to this, so a
/// caller's pump step keeps running at least this often no matter what
/// interval it asked for, even on a thread that never receives a message
/// and whose signal is never set.
/// </summary>
internal const int MaxFallbackTickMilliseconds = 50;
private const uint MsgWaitFailed = 0xFFFFFFFF;
private const uint MwmoInputAvailable = 0x0004;
private const uint QsAllInput = 0x04FF;
private static int waitFailureReported;
/// <summary>
/// Blocks the calling thread until <paramref name="signal"/> is set, a
@@ -42,6 +42,29 @@ internal static class StaWaitHelper
/// never blinds the thread to inbound COM event messages: the wait
/// returns the instant one arrives, so the caller can dispatch it.
/// </summary>
/// <remarks>
/// <para>
/// <em>Caller contract: the pump step must drain the message queue
/// to empty on every call.</em> The wait uses
/// <see cref="StaNativeMethods.MwmoInputAvailable"/>, which reports
/// input that is merely <em>present</em> — including input an earlier
/// <c>PeekMessage</c> has already seen but not removed. A caller
/// whose pump step drains only part of the queue, or does nothing at
/// all, therefore leaves a message that satisfies the wake condition
/// forever: every wait returns immediately, and the caller's poll
/// loop degenerates into a busy loop that spins the thread at 100%
/// until its deadline. That is a CPU fault, not a correctness one —
/// the loop still honors its deadline and return shape — but it must
/// not ship. Drain with <c>PeekMessage(..., PM_REMOVE)</c> in a loop
/// until it returns false, exactly as
/// <see cref="StaMessagePump.PumpPendingMessages"/> does.
/// </para>
/// <para>
/// The wait does not <c>DangerousAddRef</c> the handle. That is
/// sound only because the callers never dispose their wait handles —
/// see the coupled invariant documented on the caches' signal fields.
/// </para>
/// </remarks>
/// <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(
@@ -60,20 +83,28 @@ internal static class StaWaitHelper
SafeWaitHandle safeHandle = signal.SafeWaitHandle;
IntPtr[] handles = [safeHandle.DangerousGetHandle()];
uint result = MsgWaitForMultipleObjectsEx(
uint result = StaNativeMethods.MsgWaitForMultipleObjectsEx(
(uint)handles.Length,
handles,
(uint)timeoutMilliseconds,
QsAllInput,
MwmoInputAvailable);
StaNativeMethods.QsAllInput,
StaNativeMethods.MwmoInputAvailable);
// Read before anything else can run another P/Invoke and overwrite the
// thread's last-error slot.
int lastError = result == StaNativeMethods.MsgWaitFailed
? Marshal.GetLastWin32Error()
: 0;
// 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)
if (result == StaNativeMethods.MsgWaitFailed)
{
ReportWaitFailureOnce(lastError);
// 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.
@@ -108,11 +139,30 @@ internal static class StaWaitHelper
: tick;
}
[DllImport("user32.dll", SetLastError = true)]
private static extern uint MsgWaitForMultipleObjectsEx(
uint count,
IntPtr[] handles,
uint milliseconds,
uint wakeMask,
uint flags);
/// <summary>
/// Traces the first wait failure of the process and stays silent after
/// that.
/// </summary>
/// <remarks>
/// The degrade path is silent by design — a failed wait still honors the
/// caller's deadline — but a <em>permanently</em> failing wait means every
/// completion wait has quietly reverted to the sleep-polled behavior this
/// class exists to replace, and nothing else would ever say so. One trace
/// makes that discoverable; latching it keeps a repeating failure from
/// flooding the trace listener from inside a poll loop.
/// </remarks>
/// <param name="lastError">The Win32 error captured immediately after the failed wait.</param>
private static void ReportWaitFailureOnce(int lastError)
{
if (Interlocked.Exchange(ref waitFailureReported, 1) != 0)
{
return;
}
Trace.TraceWarning(
"MsgWaitForMultipleObjectsEx failed in the worker STA completion wait (Win32 error {0}). "
+ "The wait has degraded to blind sleeping: deadlines are still honored, but inbound "
+ "MXAccess COM events will only dispatch at poll-tick granularity. Reported once per process.",
lastError);
}
}