fix(WRK-22,WRK-24,WRK-25,WRK-27,IPC-26): worker write-seam hardening
ci / java (push) Successful in 2m7s
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Failing after 1m13s
ci / portable (push) Failing after 4m6s

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:
Joseph Doherty
2026-08-07 07:50:38 -04:00
parent 10534ec906
commit 8df35cd63a
14 changed files with 912 additions and 58 deletions
@@ -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();
}
}
}