From 31eec4145658be63935d18e331aa41376f37ff0c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 9 Jul 2026 05:51:45 -0400 Subject: [PATCH] fix(WRK-01): STA pump step refreshes activity to prevent false StaHung A long legitimate ReadBulk pumped Windows messages without refreshing LastStaActivityUtc, so the watchdog false-positived StaHung past HeartbeatStuckCeiling and then silently dropped every reply. PumpPendingMessages() now calls MarkActivity() after pumping; a genuinely stuck STA (no pumping) still accrues staleness and faults correctly. No MXAccess parity change. archreview: WRK-01 (P0). Verified on the Windows host (x86): worker builds clean, StaRuntimeTests + WorkerPipeSessionTests 33/33 pass. --- docs/MxAccessWorkerInstanceDesign.md | 30 ++++-- .../Ipc/WorkerPipeSessionTests.cs | 96 +++++++++++++++++++ .../Sta/StaRuntimeTests.cs | 28 ++++++ .../Sta/StaRuntime.cs | 22 +++-- 4 files changed, 161 insertions(+), 15 deletions(-) diff --git a/docs/MxAccessWorkerInstanceDesign.md b/docs/MxAccessWorkerInstanceDesign.md index d490f59..77b6fa7 100644 --- a/docs/MxAccessWorkerInstanceDesign.md +++ b/docs/MxAccessWorkerInstanceDesign.md @@ -656,11 +656,15 @@ the event queue implementation owns those counters. The STA watchdog currently emits a `WorkerFault` with `WorkerFaultCategory.StaHung` when `LastStaActivityUtc` is older than `WorkerPipeSessionOptions.HeartbeatGrace` **and no command is in flight**. -`StaRuntime.ProcessQueuedCommands` calls `MarkActivity()` only immediately -before and after each work item, so a synchronously long-running STA command -(for example a `ReadBulk` waiting `timeout_ms` for the first `OnDataChange`) -legitimately freezes `LastStaActivityUtc` for the duration of the wait while -the worker is healthy. The watchdog is therefore suppressed while the +`StaRuntime.ProcessQueuedCommands` calls `MarkActivity()` immediately before +and after each work item, so a synchronously long-running STA command that +neither completes work items nor pumps would freeze `LastStaActivityUtc` for +the duration of the wait while the worker is healthy. Commands that hold the +STA to wait for COM events (for example a `ReadBulk` waiting `timeout_ms` for +the first `OnDataChange`) avoid this: they pump via +`StaRuntime.PumpPendingMessages()`, which now refreshes `LastStaActivityUtc` +on every iteration (see the `HeartbeatStuckCeiling` discussion below). The +watchdog is additionally suppressed while the heartbeat snapshot's `CurrentCommandCorrelationId` is non-empty: the worker is busy executing a command, not hung, and the heartbeat already surfaces the in-flight correlation id so the gateway can apply its own per-command timeout @@ -684,10 +688,18 @@ session and only the gateway's per-command timeout would catch the hang — losing the worker-originated diagnostic (`StaHung` fault category, the stale-by interval) from the gateway audit trail. Once `LastStaActivityUtc` has been stale for longer than `HeartbeatStuckCeiling`, the watchdog fires -`StaHung` regardless of whether a command is in flight, on the assumption -that no legitimate STA command should run that long without periodically -refreshing activity. Deployments that legitimately run very long bulk -operations should raise the ceiling rather than disable it. +`StaHung` regardless of whether a command is in flight. This is now safe for +healthy long-running commands: `StaRuntime.PumpPendingMessages()` refreshes +`LastStaActivityUtc` (via `MarkActivity()`) every time it runs, and long-hold +STA commands invoke it on every wait iteration (`ReadBulk` routes its +per-tag wait through the `pumpStep` wired from `StaRuntime.PumpPendingMessages`). +A command that keeps pumping therefore keeps its activity timestamp fresh and +never reaches the ceiling, while a genuinely stuck STA — one that has stopped +pumping — accrues staleness and faults correctly. The ceiling is thus the +backstop for a command that both holds the thread and stops pumping, not a +guillotine for slow-but-healthy work. Deployments that legitimately run very +long bulk operations should still be able to raise the ceiling rather than +disable it. ## Shutdown diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs index 0c13b3a..117770a 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Ipc/WorkerPipeSessionTests.cs @@ -704,6 +704,102 @@ public sealed class WorkerPipeSessionTests await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token); } + /// + /// WRK-01 regression: a long in-flight STA command that keeps pumping + /// must NOT self-fault as StaHung, and its reply must still be + /// delivered. The real fix makes StaRuntime.PumpPendingMessages + /// refresh LastActivityUtc on every wait iteration, so a healthy + /// ReadBulk holding the STA far longer than + /// HeartbeatStuckCeiling (75 s in production) keeps its activity + /// timestamp fresh. This test compresses the clock — a 100 ms ceiling + /// with a command in flight across a window many multiples longer — and + /// models the pump refresh by continuously advancing the snapshot's + /// LastStaActivityUtc while the command blocks. Contrast + /// , + /// where a frozen timestamp beyond the ceiling correctly faults; here + /// the refreshed timestamp must keep the fault suppressed and let the + /// reply through the Ready-state gate. + /// + /// A task that represents the asynchronous operation. + [Fact] + public async Task RunAsync_LongInFlightCommandThatKeepsPumping_DoesNotFaultAndDeliversReply() + { + using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(20)); + using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token); + FakeRuntimeSession runtime = new() + { + BlockDispatch = true, + }; + WorkerPipeSession session = CreatePipeSession( + pipePair.WorkerStream, + runtime, + new WorkerPipeSessionOptions + { + HeartbeatInterval = TimeSpan.FromMilliseconds(20), + HeartbeatGrace = TimeSpan.FromMilliseconds(50), + HeartbeatStuckCeiling = TimeSpan.FromMilliseconds(100), + }); + Task runTask = session.RunAsync(cancellation.Token); + await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token); + + // Kick off the long command; it blocks in DispatchAsync until released, + // so its correlation id stays in flight in the heartbeat snapshot. + await pipePair.GatewayWriter + .WriteAsync(CreateCommandEnvelope("long-bulk-read"), cancellation.Token); + Assert.True( + runtime.DispatchStarted.Wait(TimeSpan.FromSeconds(5)), + "The long command must reach the runtime and begin dispatch."); + + // Model the pump refreshing STA activity on each wait iteration: keep + // the snapshot's LastStaActivityUtc current while the command is in + // flight. + using CancellationTokenSource pumpRefresh = new(); + Task refreshLoop = Task.Run( + async () => + { + while (!pumpRefresh.IsCancellationRequested) + { + runtime.SetSnapshot(new WorkerRuntimeHeartbeatSnapshot( + DateTimeOffset.UtcNow, + pendingCommandCount: 1, + outboundEventQueueDepth: 0, + lastEventSequence: 0, + currentCommandCorrelationId: "long-bulk-read")); + await Task.Delay(TimeSpan.FromMilliseconds(20)).ConfigureAwait(false); + } + }); + + // Inspect a bounded number of frames over a window many multiples of the + // 100 ms ceiling (at least 30 heartbeats at 20 ms ~ 600 ms). None may be + // a WorkerFault while activity is continuously refreshed. + const int framesToInspect = 30; + for (int index = 0; index < framesToInspect; index++) + { + WorkerEnvelope envelope = await pipePair.GatewayReader + .ReadAsync(cancellation.Token); + Assert.NotEqual( + WorkerEnvelope.BodyOneofCase.WorkerFault, + envelope.BodyCase); + } + + // Stop refreshing and release the command; its reply must be delivered + // because the session never faulted (state stayed Ready). + pumpRefresh.Cancel(); + await refreshLoop; + runtime.ReleaseDispatch(); + + WorkerEnvelope reply = await ReadUntilAsync( + pipePair.GatewayReader, + WorkerEnvelope.BodyOneofCase.WorkerCommandReply, + envelope => envelope.CorrelationId == "long-bulk-read", + cancellation.Token); + Assert.Equal( + ProtocolStatusCode.Ok, + reply.WorkerCommandReply.Reply.ProtocolStatus.Code); + + await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token); + } + /// /// RunAsync must throw a diagnostic exception if the /// runtime-session factory returns null, rather than deferring the diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaRuntimeTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaRuntimeTests.cs index 3afaa85..f143324 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaRuntimeTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/Sta/StaRuntimeTests.cs @@ -85,6 +85,34 @@ public sealed class StaRuntimeTests Assert.True(updated); } + /// + /// Verifies that refreshes + /// . A long synchronous STA + /// command (for example ReadBulk waiting timeout_ms for + /// the first OnDataChange) invokes the pump step on every wait + /// iteration while it legitimately holds the STA thread; refreshing + /// activity here keeps the watchdog from mistaking a busy STA for a hung + /// one (WRK-01). The runtime is deliberately left unstarted so the only + /// source of activity is the pump call under test, not the idle loop. + /// + [Fact] + public void PumpPendingMessages_RefreshesLastActivity() + { + RecordingComApartmentInitializer initializer = new(); + using StaRuntime runtime = CreateRuntime(initializer); + DateTimeOffset before = runtime.LastActivityUtc; + + bool refreshed = SpinWait.SpinUntil( + () => + { + runtime.PumpPendingMessages(); + return runtime.LastActivityUtc > before; + }, + TimeSpan.FromSeconds(2)); + + Assert.True(refreshed, "PumpPendingMessages must advance LastActivityUtc."); + } + /// Verifies that InvokeAsync faults the returned task when a command raises an exception without stopping the runtime. /// A task that represents the asynchronous operation. [Fact] diff --git a/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaRuntime.cs b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaRuntime.cs index 2ba2d37..f8a83c1 100644 --- a/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaRuntime.cs +++ b/src/ZB.MOM.WW.MxGateway.Worker/Sta/StaRuntime.cs @@ -80,14 +80,24 @@ public sealed class StaRuntime : IDisposable public bool IsRunning => startedEvent.IsSet && !stoppedEvent.IsSet; /// - /// Pumps any pending Windows messages on the calling thread. Intended - /// for commands that synchronously hold the STA (e.g. ReadBulk) and - /// must allow inbound MXAccess COM events to dispatch while they - /// wait. Callers must already be on the STA; the method is otherwise - /// safe (PeekMessage simply finds no messages). + /// Pumps any pending Windows messages on the calling thread and refreshes + /// the STA activity timestamp. Intended for commands that synchronously + /// hold the STA (e.g. ReadBulk) and must allow inbound MXAccess COM events + /// to dispatch while they wait. Because a long-running command invokes this + /// on every wait iteration, refreshing activity here keeps a busy STA from + /// being mistaken for a hung one: a healthy command that keeps pumping stays + /// fresh past HeartbeatStuckCeiling, while a genuinely stuck STA (no + /// pumping) still accrues staleness and faults correctly. Callers must + /// already be on the STA; the method is otherwise safe (PeekMessage simply + /// finds no messages). /// /// The number of messages pumped. - public int PumpPendingMessages() => messagePump.PumpPendingMessages(); + public int PumpPendingMessages() + { + int pumpedMessages = messagePump.PumpPendingMessages(); + MarkActivity(); + return pumpedMessages; + } /// /// Starts the STA thread.