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.
This commit is contained in:
Joseph Doherty
2026-07-09 05:51:45 -04:00
parent 5faef6c012
commit 31eec41456
4 changed files with 161 additions and 15 deletions
@@ -704,6 +704,102 @@ public sealed class WorkerPipeSessionTests
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
}
/// <summary>
/// WRK-01 regression: a long in-flight STA command that keeps pumping
/// must NOT self-fault as <c>StaHung</c>, and its reply must still be
/// delivered. The real fix makes <c>StaRuntime.PumpPendingMessages</c>
/// refresh <c>LastActivityUtc</c> on every wait iteration, so a healthy
/// <c>ReadBulk</c> holding the STA far longer than
/// <c>HeartbeatStuckCeiling</c> (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
/// <c>LastStaActivityUtc</c> while the command blocks. Contrast
/// <see cref="RunAsync_WhenStaActivityIsStaleBeyondCeilingWithCommandInFlight_WritesWatchdogFault"/>,
/// where a frozen timestamp beyond the ceiling correctly faults; here
/// the refreshed timestamp must keep the fault suppressed and let the
/// reply through the <c>Ready</c>-state gate.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[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);
}
/// <summary>
/// <c>RunAsync</c> must throw a diagnostic exception if the
/// runtime-session factory returns null, rather than deferring the
@@ -85,6 +85,34 @@ public sealed class StaRuntimeTests
Assert.True(updated);
}
/// <summary>
/// Verifies that <see cref="StaRuntime.PumpPendingMessages"/> refreshes
/// <see cref="StaRuntime.LastActivityUtc"/>. A long synchronous STA
/// command (for example <c>ReadBulk</c> waiting <c>timeout_ms</c> for
/// the first <c>OnDataChange</c>) 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.
/// </summary>
[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.");
}
/// <summary>Verifies that InvokeAsync faults the returned task when a command raises an exception without stopping the runtime.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
@@ -80,14 +80,24 @@ public sealed class StaRuntime : IDisposable
public bool IsRunning => startedEvent.IsSet && !stoppedEvent.IsSet;
/// <summary>
/// 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 <c>HeartbeatStuckCeiling</c>, 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).
/// </summary>
/// <returns>The number of messages pumped.</returns>
public int PumpPendingMessages() => messagePump.PumpPendingMessages();
public int PumpPendingMessages()
{
int pumpedMessages = messagePump.PumpPendingMessages();
MarkActivity();
return pumpedMessages;
}
/// <summary>
/// Starts the STA thread.