fix(WRK-22,WRK-24,WRK-25,WRK-27,IPC-26): worker write-seam hardening
WRK-22/IPC-26: tombstone a WriteAsync/WriteBatchAsync cancelled while waiting for the write lock (PendingFrame.Claimed under _gate; DequeueNext skips cancelled, claims the frame it returns) so a cancelled write never reaches the wire unless already claimed mid-write (documented residual). WRK-25: add WriteBatchAsync; RunEventDrainLoopAsync submits the drained event batch through it, so a burst of N events costs one flush not N. IPC-30 oversized-event structured fault preserved via FindOversizedEvent. WRK-24: reject a below-1024 negotiated frame maximum at the handshake (MinNegotiableFrameBytes, matching GatewayOptionsValidator floor). WRK-27: alarm poll advertises StaCallInProgress on the heartbeat snapshot so the watchdog suppresses to the ceiling, not the grace. Docs (WorkerFrameProtocol.md, MxAccessWorkerInstanceDesign.md) and the 2026-07-12 remediation registers/change-log updated in the same commit.
This commit is contained in:
@@ -438,6 +438,53 @@ public sealed class MxAccessStaSessionTests
|
||||
Assert.Contains("alarm poll failed", fault.DiagnosticMessage, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// WRK-27. While the alarm poll's PollOnce is executing on the STA, a heartbeat captured mid-poll
|
||||
/// must report <see cref="WorkerRuntimeHeartbeatSnapshot.StaCallInProgress"/> so the watchdog
|
||||
/// grants the poll the same grace-to-ceiling suppression as a dispatched command; once the poll
|
||||
/// returns the flag clears. PollOnce is blocked on a gate so the heartbeat can be captured while
|
||||
/// the STA call is genuinely in flight.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task CaptureHeartbeat_DuringAlarmPoll_ReportsStaCallInProgress()
|
||||
{
|
||||
FakeAlarmCommandHandler handler = new() { BlockPoll = true };
|
||||
FakeMxAccessComObjectFactory factory = new();
|
||||
FakeMxAccessEventSink eventSink = new();
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(
|
||||
runtime,
|
||||
factory,
|
||||
eventSink,
|
||||
new MxAccessEventQueue(),
|
||||
(_eq, _affinity, _comFactory) => handler);
|
||||
|
||||
await session.StartAsync("session-1", workerProcessId: 1);
|
||||
|
||||
// Wait until PollOnce is blocked mid-call on the STA thread.
|
||||
Assert.True(
|
||||
handler.WaitForPollEntered(TimeSpan.FromSeconds(5)),
|
||||
"Expected the alarm poll to start within 5 seconds.");
|
||||
|
||||
// Captured mid-poll, the heartbeat advertises the in-progress STA call.
|
||||
Assert.True(session.CaptureHeartbeat().StaCallInProgress);
|
||||
|
||||
// Release the poll and stop blocking; the flag clears once the poll returns.
|
||||
handler.BlockPoll = false;
|
||||
handler.ReleasePoll();
|
||||
|
||||
using CancellationTokenSource timeout = new(TimeSpan.FromSeconds(5));
|
||||
while (session.CaptureHeartbeat().StaCallInProgress && !timeout.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(25, CancellationToken.None);
|
||||
}
|
||||
|
||||
Assert.False(
|
||||
session.CaptureHeartbeat().StaCallInProgress,
|
||||
"Expected StaCallInProgress to clear once the alarm poll returned.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The STA-affinity guard throws when an
|
||||
/// IMxAccessAlarmConsumer call is attempted off the thread that created
|
||||
@@ -472,6 +519,8 @@ public sealed class MxAccessStaSessionTests
|
||||
private sealed class FakeAlarmCommandHandler : IAlarmCommandHandler
|
||||
{
|
||||
private readonly object gate = new object();
|
||||
private readonly ManualResetEventSlim pollEntered = new(false);
|
||||
private readonly ManualResetEventSlim releasePoll = new(false);
|
||||
private int pollCount;
|
||||
private int? lastPollThreadId;
|
||||
|
||||
@@ -484,6 +533,17 @@ public sealed class MxAccessStaSessionTests
|
||||
/// <summary>Exception thrown by PollOnce; null to succeed.</summary>
|
||||
public Exception? PollException { get; set; }
|
||||
|
||||
/// <summary>When set, <see cref="PollOnce"/> blocks until <see cref="ReleasePoll"/> is called.</summary>
|
||||
public bool BlockPoll { get; set; }
|
||||
|
||||
/// <summary>Waits until a blocking <see cref="PollOnce"/> has entered and is blocked.</summary>
|
||||
/// <param name="timeout">Maximum time to wait.</param>
|
||||
/// <returns>True if a poll entered within the timeout.</returns>
|
||||
public bool WaitForPollEntered(TimeSpan timeout) => pollEntered.Wait(timeout);
|
||||
|
||||
/// <summary>Releases a <see cref="PollOnce"/> blocked on the gate.</summary>
|
||||
public void ReleasePoll() => releasePoll.Set();
|
||||
|
||||
/// <summary>Gets the count of PollOnce calls.</summary>
|
||||
public int PollCount
|
||||
{
|
||||
@@ -533,6 +593,12 @@ public sealed class MxAccessStaSessionTests
|
||||
lastPollThreadId = Thread.CurrentThread.ManagedThreadId;
|
||||
}
|
||||
|
||||
if (BlockPoll)
|
||||
{
|
||||
pollEntered.Set();
|
||||
releasePoll.Wait(TimeSpan.FromSeconds(10));
|
||||
}
|
||||
|
||||
if (PollException is not null)
|
||||
{
|
||||
throw PollException;
|
||||
@@ -540,6 +606,10 @@ public sealed class MxAccessStaSessionTests
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose() { }
|
||||
public void Dispose()
|
||||
{
|
||||
pollEntered.Dispose();
|
||||
releasePoll.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user