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
@@ -0,0 +1,138 @@
using System;
using System.Collections.Generic;
using MxGateway.Worker.MxAccess;
namespace MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Worker-007 regression tests for <see cref="MxAccessComServer"/>. The
/// adapter no longer falls back to late-bound <c>Type.InvokeMember</c>
/// reflection: a COM object must implement either the typed
/// <c>ILMXProxyServer</c> COM interface family (production) or
/// <see cref="IMxAccessServer"/> directly (test fakes).
/// </summary>
public sealed class MxAccessComServerTests
{
/// <summary>
/// A COM object implementing <see cref="IMxAccessServer"/> is routed
/// through the typed interface — no reflection — preserving arguments
/// and return values.
/// </summary>
[Fact]
public void Methods_WithTypedServer_RouteThroughTypedInterface()
{
RecordingMxAccessServer typed = new(registerHandle: 77);
MxAccessComServer adapter = new(typed);
int serverHandle = adapter.Register("client-a");
adapter.Advise(serverHandle, itemHandle: 9);
adapter.Unregister(serverHandle);
Assert.Equal(77, serverHandle);
Assert.Equal("client-a", typed.RegisteredClientName);
Assert.Equal(new[] { "Register:client-a", "Advise:77:9", "Unregister:77" }, typed.Calls);
}
/// <summary>
/// A COM object that implements neither the typed COM interface family
/// nor <see cref="IMxAccessServer"/> fails fast with a clear
/// <see cref="InvalidOperationException"/> instead of a late-bound
/// reflection call.
/// </summary>
[Fact]
public void Methods_WithUntypedObject_ThrowInvalidOperation()
{
MxAccessComServer adapter = new(new object());
InvalidOperationException exception =
Assert.Throws<InvalidOperationException>(() => adapter.Register("client"));
Assert.Contains("does not implement", exception.Message, StringComparison.Ordinal);
Assert.Contains(nameof(IMxAccessServer), exception.Message, StringComparison.Ordinal);
}
/// <summary>
/// Exceptions thrown by the typed server propagate unchanged — no
/// <c>TargetInvocationException</c> wrapping (reflection is gone).
/// </summary>
[Fact]
public void Methods_WhenTypedServerThrows_PropagateOriginalException()
{
RecordingMxAccessServer typed = new(registerHandle: 1)
{
ThrowOnRegister = new InvalidOperationException("register failed"),
};
MxAccessComServer adapter = new(typed);
InvalidOperationException exception =
Assert.Throws<InvalidOperationException>(() => adapter.Register("client"));
Assert.Equal("register failed", exception.Message);
}
private sealed class RecordingMxAccessServer : IMxAccessServer
{
private readonly int registerHandle;
private readonly List<string> calls = new();
public RecordingMxAccessServer(int registerHandle)
{
this.registerHandle = registerHandle;
}
public string? RegisteredClientName { get; private set; }
public Exception? ThrowOnRegister { get; set; }
public IReadOnlyList<string> Calls => calls.ToArray();
public int Register(string clientName)
{
calls.Add($"Register:{clientName}");
RegisteredClientName = clientName;
if (ThrowOnRegister is not null)
{
throw ThrowOnRegister;
}
return registerHandle;
}
public void Unregister(int serverHandle)
{
calls.Add($"Unregister:{serverHandle}");
}
public int AddItem(int serverHandle, string itemDefinition)
{
calls.Add($"AddItem:{serverHandle}:{itemDefinition}");
return 0;
}
public int AddItem2(int serverHandle, string itemDefinition, string itemContext)
{
calls.Add($"AddItem2:{serverHandle}:{itemDefinition}:{itemContext}");
return 0;
}
public void RemoveItem(int serverHandle, int itemHandle)
{
calls.Add($"RemoveItem:{serverHandle}:{itemHandle}");
}
public void Advise(int serverHandle, int itemHandle)
{
calls.Add($"Advise:{serverHandle}:{itemHandle}");
}
public void UnAdvise(int serverHandle, int itemHandle)
{
calls.Add($"UnAdvise:{serverHandle}:{itemHandle}");
}
public void AdviseSupervisory(int serverHandle, int itemHandle)
{
calls.Add($"AdviseSupervisory:{serverHandle}:{itemHandle}");
}
}
}