Resolve Worker-004, -005, -006, -007, -008 code-review findings

Worker-004: post-watchdog-fault heartbeats reported a non-faulted state.
ReportWatchdogFaultIfNeededAsync now sets _state = Faulted before writing
the StaHung fault.

Worker-005 (re-triaged): the cited OnPoll site was removed by Worker-001;
the real silent-failure bug was in MxAccessStaSession.RunAlarmPollLoopAsync,
which caught only graceful-stop exceptions. A failing PollOnce now records a
WorkerFault on the event queue instead of vanishing on a non-awaited task.

Worker-006: RunAsync's finally skipped runtime disposal when shutdown timed
out, leaking the STA thread and COM object. It now always disposes
(MxAccessStaSession.Dispose is idempotent and bounded).

Worker-007 (re-triaged): replaced MxAccessComServer's Type.InvokeMember
reflection fallback with an IMxAccessServer fast path plus typed
ILMXProxyServer* casts; a non-conforming object now fails fast.

Worker-008: alarm consumer STA affinity was unenforced. MxAccessStaSession
records the alarm consumer's STA thread id and asserts every PollOnce runs
on it via a unit-testable guard.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-18 21:31:23 -04:00
parent 1d9e3afadd
commit 54325343bd
8 changed files with 519 additions and 68 deletions
@@ -328,6 +328,77 @@ public sealed class MxAccessStaSessionTests
Assert.Equal(pollCountAtDispose, handler.PollCount);
}
/// <summary>
/// Worker-005 regression: when the alarm poll loop's PollOnce throws a
/// real failure (e.g. a COMException from GetXmlCurrentAlarms2), the
/// failure must be recorded as a fault on the event queue so a broken
/// alarm subscription becomes observable on the IPC fault path instead
/// of silently faulting the never-awaited poll task.
/// </summary>
[Fact]
public async Task RunAlarmPollLoop_WhenPollOnceThrows_RecordsFaultOnEventQueue()
{
FakeAlarmCommandHandler handler = new()
{
PollException = new System.Runtime.InteropServices.COMException(
"GetXmlCurrentAlarms2 failed.", unchecked((int)0x80004005)),
};
FakeMxAccessComObjectFactory factory = new();
FakeMxAccessEventSink eventSink = new();
using StaRuntime runtime = CreateRuntime();
MxAccessEventQueue eventQueue = new();
using MxAccessStaSession session = new(
runtime,
factory,
eventSink,
eventQueue,
_eq => handler);
await session.StartAsync("session-1", workerProcessId: 1);
// Wait up to 5s for the poll loop to fault the queue.
using CancellationTokenSource timeout = new CancellationTokenSource(TimeSpan.FromSeconds(5));
while (!eventQueue.IsFaulted && !timeout.IsCancellationRequested)
{
await Task.Delay(50, CancellationToken.None);
}
Assert.True(eventQueue.IsFaulted, "Expected the alarm poll failure to fault the event queue.");
WorkerFault? fault = session.DrainFault();
Assert.NotNull(fault);
Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, fault!.Category);
Assert.Contains("alarm poll failed", fault.DiagnosticMessage, StringComparison.OrdinalIgnoreCase);
Assert.Equal(typeof(System.Runtime.InteropServices.COMException).FullName, fault.ExceptionType);
}
/// <summary>
/// Worker-008 regression: the STA-affinity guard throws when an
/// IMxAccessAlarmConsumer call is attempted off the thread that created
/// the consumer, mirroring the MxAccessSession.CreationThreadId invariant.
/// </summary>
[Fact]
public void AssertOnAlarmConsumerThread_WhenOffOwningThread_Throws()
{
const int owningThread = 7;
const int otherThread = 99;
InvalidOperationException exception = Assert.Throws<InvalidOperationException>(
() => MxAccessStaSession.AssertOnAlarmConsumerThread(owningThread, otherThread));
Assert.Contains("off its owning STA thread", exception.Message, StringComparison.Ordinal);
}
/// <summary>
/// Worker-008: the STA-affinity guard is a no-op on the owning thread and
/// when no alarm consumer is configured (expected thread id null).
/// </summary>
[Fact]
public void AssertOnAlarmConsumerThread_OnOwningThreadOrUnset_DoesNotThrow()
{
MxAccessStaSession.AssertOnAlarmConsumerThread(expectedThreadId: 42, actualThreadId: 42);
MxAccessStaSession.AssertOnAlarmConsumerThread(expectedThreadId: null, actualThreadId: 123);
}
/// <summary>
/// Noop STA COM apartment initializer for testing.
/// </summary>
@@ -360,6 +431,9 @@ public sealed class MxAccessStaSessionTests
public bool IsSubscribed { get; private set; }
public string? LastSubscription { get; private set; }
/// <summary>Exception thrown by PollOnce; null to succeed.</summary>
public Exception? PollException { get; set; }
public int PollCount
{
get { lock (gate) return pollCount; }
@@ -400,6 +474,11 @@ public sealed class MxAccessStaSessionTests
pollCount++;
lastPollThreadId = Thread.CurrentThread.ManagedThreadId;
}
if (PollException is not null)
{
throw PollException;
}
}
public void Dispose() { }