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:
@@ -7,7 +7,7 @@
|
||||
| Review date | 2026-05-18 |
|
||||
| Commit reviewed | `6c64030` |
|
||||
| Status | Reviewed |
|
||||
| Open findings | 12 |
|
||||
| Open findings | 7 |
|
||||
|
||||
## Checklist coverage
|
||||
|
||||
@@ -78,13 +78,13 @@
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/MxGateway.Worker/Ipc/WorkerPipeSession.cs:565-588` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** After `ReportWatchdogFaultIfNeededAsync` sends an `StaHung` fault, the heartbeat loop continues sending normal heartbeats with `State` derived from `_state`, which the watchdog path never sets to `Faulted`. The heartbeat then keeps reporting a non-faulted state that contradicts the fault just sent.
|
||||
|
||||
**Recommendation:** Set `_state = WorkerState.Faulted` (thread-safely) when the watchdog fault fires so heartbeat state and fault stay consistent.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — `ReportWatchdogFaultIfNeededAsync` now sets `_state = WorkerState.Faulted` immediately after `_watchdogFaultSent = true` and before the `StaHung` fault is written, so the next heartbeat reports `Faulted` instead of contradicting the fault. `_state` is already `volatile` (Worker-003), so the cross-thread write from the heartbeat loop is observed correctly by the heartbeat's own `CreateHeartbeat` read; no further locking is required. Verified by the regression test `WorkerPipeSessionTests.RunAsync_AfterWatchdogFault_HeartbeatReportsFaultedState`, which uses a stale-activity snapshot with an empty current-command correlation id so the heartbeat `State` is derived from `_state` rather than forced to `ExecutingCommand`.
|
||||
|
||||
### Worker-005
|
||||
|
||||
@@ -92,14 +92,16 @@
|
||||
|---|---|
|
||||
| Severity | Medium |
|
||||
| Category | Error handling & resilience |
|
||||
| Location | `src/MxGateway.Worker/MxAccess/WnWrapAlarmConsumer.cs:297-313` |
|
||||
| Status | Open |
|
||||
| Location | `src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs:205-258` (production alarm poll loop) |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `OnPoll` catches every exception from `PollOnce()` and discards it (`_ = ex;`). The production poll path (`MxAccessStaSession.RunAlarmPollLoopAsync` → `AlarmCommandHandler.PollOnce` → `AlarmDispatcher.PollOnce` → `consumer.PollOnce()`) has no fault recording either. A permanently failing alarm provider (e.g. `GetXmlCurrentAlarms2` returning `E_FAIL`, malformed XML throwing in `XmlDocument.LoadXml`) is therefore completely silent — no fault on the event queue, no log.
|
||||
|
||||
**Recommendation:** Route poll failures to `MxAccessEventQueue.RecordFault` (or a logger) so a broken alarm subscription becomes observable. Update the now-stale comment.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Re-triage:** The cited location `WnWrapAlarmConsumer.cs:297-313` and the `OnPoll` callback no longer exist as of this branch — Worker-001 removed the off-STA `Timer` and its `OnPoll` callback entirely. The substantive concern still held, however: the **production** poll path in `MxAccessStaSession.RunAlarmPollLoopAsync` caught only `OperationCanceledException`, `ObjectDisposedException`, and `InvalidOperationException`. A genuine poll failure (`COMException` from `GetXmlCurrentAlarms2`, a malformed-XML `XmlException`) escaped uncaught, faulted the never-awaited `Task.Run` poll task, and was silently lost — exactly the silent-failure the finding describes. The finding was re-pointed at the live location and fixed there rather than at the removed `OnPoll`.
|
||||
|
||||
**Resolution:** 2026-05-18 — `RunAlarmPollLoopAsync` gained a trailing `catch (Exception exception)` arm after the three graceful-stop catches. A real alarm-poll failure is now converted to a `WorkerFault` (category `MxaccessEventConversionFailed`, carrying the exception type and, for a `COMException`, its `HResult`) by the new `CreateAlarmPollFault` helper and recorded on the session's `MxAccessEventQueue` via `RecordFault`. The worker's event-drain loop drains that fault and forwards it to the gateway, so a broken alarm subscription is now observable on the IPC fault path instead of vanishing. The poll loop still stops after the failure (the subscription is dead). No new proto enum value was added — `MxaccessEventConversionFailed` is the closest existing alarm-path category, avoiding a contracts regeneration across all clients. Verified by the regression test `MxAccessStaSessionTests.RunAlarmPollLoop_WhenPollOnceThrows_RecordsFaultOnEventQueue`.
|
||||
|
||||
### Worker-006
|
||||
|
||||
@@ -108,13 +110,13 @@
|
||||
| Severity | Medium |
|
||||
| Category | Correctness & logic bugs |
|
||||
| Location | `src/MxGateway.Worker/Ipc/WorkerPipeSession.cs:117-124`, `src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs:386-491` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `RunAsync`'s `finally` calls `_runtimeSession?.Dispose()` unless `_shutdownTimedOut`. On the normal path `ShutdownGracefullyAsync` already disposed the STA runtime, so re-entering `Dispose()` is a harmless no-op only because `ShutdownGracefullyAsync` reached its end and set `disposed = true`. If `ShutdownGracefullyAsync` throws `TimeoutException` after partial teardown with `_shutdownTimedOut` set, the session is never disposed at all — the `finally` skips it — leaking the STA thread and COM object, leaving cleanup to rely solely on process exit.
|
||||
|
||||
**Recommendation:** Make the dispose decision explicit and confirm process exit always follows a timed-out shutdown; otherwise dispose defensively. At minimum document why disposal is deliberately skipped on timeout.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — `RunAsync`'s `finally` now always calls `_runtimeSession?.Dispose()`; the `if (!_shutdownTimedOut)` guard and the `_shutdownTimedOut` field (which had become write-only) were removed. `MxAccessStaSession.Dispose` is idempotent (`if (disposed) return`) and bounded — each STA join is capped with `Wait(TimeSpan.FromSeconds(2))` — so re-entering it on the normal path (where `ShutdownGracefullyAsync` already disposed the runtime) is a harmless no-op, while on the timed-out path it is now the only thing that reclaims the STA thread and releases the MXAccess COM object. The previous behaviour leaked both on a shutdown timeout and relied solely on process exit. A code comment in the `finally` block documents the reasoning. Verified by the regression test `WorkerPipeSessionTests.RunAsync_WhenShutdownTimesOut_StillDisposesRuntimeSession`, which forces a `TimeoutException` from `ShutdownGracefullyAsync` and asserts the runtime session is disposed before `RunAsync` rethrows.
|
||||
|
||||
### Worker-007
|
||||
|
||||
@@ -123,13 +125,15 @@
|
||||
| Severity | Medium |
|
||||
| Category | mxaccessgw conventions |
|
||||
| Location | `src/MxGateway.Worker/MxAccess/MxAccessComServer.cs:130-150` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `Invoke` uses late-bound `Type.InvokeMember` reflection as a fallback when the COM object does not cast to `ILMXProxyServer*`. In production the object is always `LMXProxyServerClass`, so the reflection path exists only for test doubles — it is dead/untested code on the production path and obscures the interface contract. `params object[] arguments` also boxes value-type handles on every call.
|
||||
|
||||
**Recommendation:** Drop the reflection fallback and require the COM object to implement the interface (tests can supply a typed fake), or clearly mark the fallback as test-only.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Re-triage:** The finding's claim that the reflection path is "dead/untested code" is partly inaccurate — it was in fact the path exercised by the entire `MxAccessCommandExecutorTests` suite, whose `FakeMxAccessComObject` did not implement any typed interface. So the reflection fallback was test-only but *not* untested. The convention concern (bypassing the typed interface contract, boxing value-type handles) is valid, so the fix follows the recommendation's first option.
|
||||
|
||||
**Resolution:** 2026-05-18 — The late-bound `Type.InvokeMember` reflection fallback and its `params object[]`-boxing `Invoke` helper were removed from `MxAccessComServer`. Each adapter method now takes one of two typed paths: an `is IMxAccessServer` fast path (test fakes implement `IMxAccessServer` directly) and the production path that casts to the typed `ILMXProxyServer` / `ILMXProxyServer3` / `ILMXProxyServer4` COM interfaces via new `AsProxyServer*` helpers. A COM object implementing neither now fails fast with a clear `InvalidOperationException` naming the missing interface, instead of an opaque late-bound call. The test seam was migrated accordingly: `MxAccessCommandExecutorTests.FakeMxAccessComObject` now declares `: IMxAccessServer` (its method signatures already matched the interface exactly, so no behavioural change). Verified by the new `MxAccessComServerTests` (typed-server routing, untyped-object rejection, original-exception propagation — no more `TargetInvocationException` wrapping) plus the unchanged, still-passing `MxAccessCommandExecutorTests` suite which now exercises the typed `IMxAccessServer` path.
|
||||
|
||||
### Worker-008
|
||||
|
||||
@@ -138,13 +142,13 @@
|
||||
| Severity | Medium |
|
||||
| Category | Concurrency & thread safety |
|
||||
| Location | `src/MxGateway.Worker/MxAccess/MxAccessStaSession.cs:205-249`, `:429-447` |
|
||||
| Status | Open |
|
||||
| Status | Resolved |
|
||||
|
||||
**Description:** `RunAlarmPollLoopAsync` correctly marshals `handler.PollOnce()` onto the STA via `staRuntime.InvokeAsync`, and the cancel/await/dispose ordering in `ShutdownGracefullyAsync` is sound. However, nothing enforces that the `consumerFactory` and all `IMxAccessAlarmConsumer` calls run on the STA thread; a future caller could break STA affinity silently.
|
||||
|
||||
**Recommendation:** Add an assertion or documented invariant that the consumer factory and all `IMxAccessAlarmConsumer` calls run on the STA thread, mirroring the existing `MxAccessSession.CreationThreadId` pattern.
|
||||
|
||||
**Resolution:** _(open)_
|
||||
**Resolution:** 2026-05-18 — `MxAccessStaSession` now records the STA thread id (`alarmConsumerThreadId`) at the point the alarm-command-handler factory is invoked — which already runs inside `staRuntime.InvokeAsync` during `StartAsync`, mirroring the `MxAccessSession.CreationThreadId` capture. `RunAlarmPollLoopAsync`'s marshalled poll lambda now calls `EnsureOnAlarmConsumerThread()` before `handler.PollOnce()`, asserting the poll runs on the recorded STA thread. The check is delegated to a new `internal static` guard `AssertOnAlarmConsumerThread(int? expected, int actual)` that throws a descriptive `InvalidOperationException` on an affinity violation and is a no-op when the consumer thread is unrecorded (no alarm handler configured). Making the guard `static` and `internal` keeps it directly unit-testable. The STA-affinity invariant is documented in the guard's XML doc. Verified by the regression tests `MxAccessStaSessionTests.AssertOnAlarmConsumerThread_WhenOffOwningThread_Throws` and `AssertOnAlarmConsumerThread_OnOwningThreadOrUnset_DoesNotThrow`.
|
||||
|
||||
### Worker-009
|
||||
|
||||
|
||||
@@ -313,6 +313,121 @@ public sealed class WorkerPipeSessionTests
|
||||
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker-004 regression: once the watchdog reports an StaHung fault,
|
||||
/// subsequent heartbeats must report <see cref="WorkerState.Faulted"/>
|
||||
/// rather than a non-faulted state that contradicts the fault. The
|
||||
/// snapshot uses an empty current-command correlation id so the
|
||||
/// heartbeat State is derived from the session state, not forced to
|
||||
/// ExecutingCommand.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_AfterWatchdogFault_HeartbeatReportsFaultedState()
|
||||
{
|
||||
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(10));
|
||||
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
|
||||
FakeRuntimeSession runtime = new();
|
||||
runtime.SetSnapshot(new WorkerRuntimeHeartbeatSnapshot(
|
||||
DateTimeOffset.UtcNow - TimeSpan.FromSeconds(5),
|
||||
pendingCommandCount: 0,
|
||||
outboundEventQueueDepth: 0,
|
||||
lastEventSequence: 0,
|
||||
currentCommandCorrelationId: string.Empty));
|
||||
WorkerPipeSession session = CreatePipeSession(
|
||||
pipePair.WorkerStream,
|
||||
runtime,
|
||||
new WorkerPipeSessionOptions
|
||||
{
|
||||
HeartbeatInterval = TimeSpan.FromMilliseconds(20),
|
||||
HeartbeatGrace = TimeSpan.FromMilliseconds(50),
|
||||
});
|
||||
Task runTask = session.RunAsync(cancellation.Token);
|
||||
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
|
||||
|
||||
WorkerEnvelope fault = await ReadUntilAsync(
|
||||
pipePair.GatewayReader,
|
||||
WorkerEnvelope.BodyOneofCase.WorkerFault,
|
||||
cancellation.Token);
|
||||
Assert.Equal(WorkerFaultCategory.StaHung, fault.WorkerFault.Category);
|
||||
|
||||
// The next heartbeat after the fault must agree with it.
|
||||
WorkerEnvelope heartbeat = await ReadUntilAsync(
|
||||
pipePair.GatewayReader,
|
||||
WorkerEnvelope.BodyOneofCase.WorkerHeartbeat,
|
||||
cancellation.Token);
|
||||
Assert.Equal(WorkerState.Faulted, heartbeat.WorkerHeartbeat.State);
|
||||
|
||||
await SendShutdownAndWaitAsync(pipePair, runTask, cancellation.Token);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Worker-006 regression: when graceful shutdown times out, RunAsync
|
||||
/// must still dispose the runtime session in its finally block.
|
||||
/// Skipping disposal on the timed-out path leaked the STA thread and
|
||||
/// the MXAccess COM object.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenShutdownTimesOut_StillDisposesRuntimeSession()
|
||||
{
|
||||
using CancellationTokenSource cancellation = new(TimeSpan.FromSeconds(10));
|
||||
using PipePair pipePair = await PipePair.CreateAsync(cancellation.Token);
|
||||
FakeRuntimeSession runtime = new()
|
||||
{
|
||||
ThrowTimeoutOnShutdown = true,
|
||||
};
|
||||
WorkerPipeSession session = CreatePipeSession(
|
||||
pipePair.WorkerStream,
|
||||
runtime,
|
||||
new WorkerPipeSessionOptions
|
||||
{
|
||||
HeartbeatInterval = TimeSpan.FromSeconds(1),
|
||||
HeartbeatGrace = TimeSpan.FromSeconds(30),
|
||||
});
|
||||
Task runTask = session.RunAsync(cancellation.Token);
|
||||
await CompleteGatewayHandshakeAsync(pipePair, cancellation.Token);
|
||||
|
||||
await pipePair.GatewayWriter
|
||||
.WriteAsync(CreateShutdownEnvelope(), cancellation.Token);
|
||||
|
||||
// Drain the gateway-side pipe (heartbeats + the shutdown-timeout
|
||||
// fault) so the worker's writes never block on a full pipe buffer.
|
||||
Task drainTask = DrainReaderUntilFaultedAsync(pipePair.GatewayReader, cancellation.Token);
|
||||
|
||||
// RunAsync must rethrow the TimeoutException and still reach its
|
||||
// finally block, which disposes the runtime session.
|
||||
await Assert.ThrowsAsync<TimeoutException>(async () => await runTask);
|
||||
Assert.True(
|
||||
runtime.Disposed,
|
||||
"RunAsync must dispose the runtime session even when shutdown times out.");
|
||||
|
||||
await drainTask;
|
||||
}
|
||||
|
||||
private static async Task DrainReaderUntilFaultedAsync(
|
||||
WorkerFrameReader reader,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
while (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
WorkerEnvelope envelope = await reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerFault
|
||||
&& envelope.WorkerFault.Category == WorkerFaultCategory.ShutdownTimeout)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception exception) when (
|
||||
exception is OperationCanceledException
|
||||
|| exception is IOException
|
||||
|| exception is WorkerFrameProtocolException)
|
||||
{
|
||||
// The worker pipe closed once RunAsync completed — expected.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies that shutdown drops late replies and sends shutdown ack.</summary>
|
||||
[Fact]
|
||||
public async Task RunAsync_WhenShutdownArrivesDuringCommand_DropsLateReplyAndWritesShutdownAck()
|
||||
@@ -813,6 +928,12 @@ public sealed class WorkerPipeSessionTests
|
||||
/// <summary>Gets or sets whether to throw an exception after dispatch is released.</summary>
|
||||
public bool ThrowAfterDispatchReleased { get; set; }
|
||||
|
||||
/// <summary>Gets or sets whether ShutdownGracefullyAsync throws a TimeoutException.</summary>
|
||||
public bool ThrowTimeoutOnShutdown { get; set; }
|
||||
|
||||
/// <summary>Gets a value indicating whether Dispose was called.</summary>
|
||||
public bool Disposed { get; private set; }
|
||||
|
||||
/// <summary>Starts the worker session with the given session ID and process ID.</summary>
|
||||
/// <param name="sessionId">The session identifier.</param>
|
||||
/// <param name="workerProcessId">The worker process ID.</param>
|
||||
@@ -939,6 +1060,12 @@ public sealed class WorkerPipeSessionTests
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
releaseDispatch.Set();
|
||||
if (ThrowTimeoutOnShutdown)
|
||||
{
|
||||
return Task.FromException<MxAccessShutdownResult>(
|
||||
new TimeoutException("Simulated graceful shutdown timeout."));
|
||||
}
|
||||
|
||||
return Task.FromResult(new MxAccessShutdownResult(Array.Empty<MxAccessShutdownFailure>()));
|
||||
}
|
||||
|
||||
@@ -971,6 +1098,7 @@ public sealed class WorkerPipeSessionTests
|
||||
/// <summary>Disposes resources.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
Disposed = true;
|
||||
releaseDispatch.Set();
|
||||
releaseDispatch.Dispose();
|
||||
DispatchStarted.Dispose();
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -816,7 +816,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
TimeSpan.FromMilliseconds(25));
|
||||
}
|
||||
|
||||
private sealed class FakeMxAccessComObject
|
||||
private sealed class FakeMxAccessComObject : IMxAccessServer
|
||||
{
|
||||
private readonly int registerHandle;
|
||||
private readonly int addItemHandle;
|
||||
|
||||
@@ -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() { }
|
||||
|
||||
@@ -36,7 +36,6 @@ public sealed class WorkerPipeSession
|
||||
private volatile WorkerState _state = WorkerState.Starting;
|
||||
private bool _acceptingCommands = true;
|
||||
private bool _watchdogFaultSent;
|
||||
private bool _shutdownTimedOut;
|
||||
|
||||
/// <summary>Initializes a new worker pipe session over the provided stream.</summary>
|
||||
/// <param name="stream">Network stream for reading and writing frames.</param>
|
||||
@@ -119,11 +118,14 @@ public sealed class WorkerPipeSession
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!_shutdownTimedOut)
|
||||
{
|
||||
_runtimeSession?.Dispose();
|
||||
}
|
||||
|
||||
// Always dispose the runtime session, including after a
|
||||
// shutdown timeout. MxAccessStaSession.Dispose is idempotent and
|
||||
// bounded (each STA join is capped at 2s), so re-entering it on
|
||||
// the normal path is a harmless no-op, while on the timed-out
|
||||
// path it is the only thing that reclaims the STA thread and
|
||||
// releases the MXAccess COM object — skipping it leaked both and
|
||||
// left cleanup to rely solely on process exit.
|
||||
_runtimeSession?.Dispose();
|
||||
_runtimeSession = null;
|
||||
_state = WorkerState.Stopped;
|
||||
}
|
||||
@@ -480,7 +482,6 @@ public sealed class WorkerPipeSession
|
||||
}
|
||||
catch (TimeoutException exception)
|
||||
{
|
||||
_shutdownTimedOut = true;
|
||||
_state = WorkerState.Faulted;
|
||||
await TryWriteFaultAsync(CreateShutdownTimeoutFault(exception), cancellationToken).ConfigureAwait(false);
|
||||
throw;
|
||||
@@ -615,6 +616,12 @@ public sealed class WorkerPipeSession
|
||||
}
|
||||
|
||||
_watchdogFaultSent = true;
|
||||
|
||||
// The STA is hung — move the session to Faulted before the next
|
||||
// heartbeat so the heartbeat's reported State stays consistent with
|
||||
// the StaHung fault just sent. Without this the heartbeat loop keeps
|
||||
// advertising a non-faulted state that contradicts the fault.
|
||||
_state = WorkerState.Faulted;
|
||||
await TryWriteFaultAsync(
|
||||
CreateFault(
|
||||
WorkerFaultCategory.StaHung,
|
||||
|
||||
@@ -1,13 +1,22 @@
|
||||
using System;
|
||||
using System.Reflection;
|
||||
using System.Runtime.ExceptionServices;
|
||||
using ArchestrA.MxAccess;
|
||||
|
||||
namespace MxGateway.Worker.MxAccess;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter exposing MXAccess COM object methods through the IMxAccessServer interface.
|
||||
/// Adapter exposing MXAccess COM object methods through the <see cref="IMxAccessServer"/>
|
||||
/// interface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The supplied object must implement the typed MXAccess COM interface contract.
|
||||
/// In production it is the <c>LMXProxyServerClass</c> RCW, which implements
|
||||
/// <see cref="ILMXProxyServer"/> / <see cref="ILMXProxyServer3"/> /
|
||||
/// <see cref="ILMXProxyServer4"/>. Tests substitute a typed fake that
|
||||
/// implements <see cref="IMxAccessServer"/> directly. The earlier late-bound
|
||||
/// <c>Type.InvokeMember</c> reflection fallback was removed: it bypassed the
|
||||
/// typed interface contract, boxed value-type handles on every call, and only
|
||||
/// ever served test doubles — a typed fake is the supported test seam now.
|
||||
/// </remarks>
|
||||
public sealed class MxAccessComServer : IMxAccessServer
|
||||
{
|
||||
private readonly object mxAccessComObject;
|
||||
@@ -15,7 +24,11 @@ public sealed class MxAccessComServer : IMxAccessServer
|
||||
/// <summary>
|
||||
/// Initializes the adapter with the MXAccess COM object.
|
||||
/// </summary>
|
||||
/// <param name="mxAccessComObject">MXAccess COM object instance.</param>
|
||||
/// <param name="mxAccessComObject">
|
||||
/// MXAccess COM object instance. Must implement either the typed
|
||||
/// <see cref="ILMXProxyServer"/> COM interface family (production) or
|
||||
/// <see cref="IMxAccessServer"/> directly (test fakes).
|
||||
/// </param>
|
||||
public MxAccessComServer(object mxAccessComObject)
|
||||
{
|
||||
this.mxAccessComObject = mxAccessComObject ?? throw new ArgumentNullException(nameof(mxAccessComObject));
|
||||
@@ -24,24 +37,24 @@ public sealed class MxAccessComServer : IMxAccessServer
|
||||
/// <inheritdoc />
|
||||
public int Register(string clientName)
|
||||
{
|
||||
if (mxAccessComObject is ILMXProxyServer mxAccessServer)
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
return mxAccessServer.Register(clientName);
|
||||
return typedFake.Register(clientName);
|
||||
}
|
||||
|
||||
return (int)Invoke(nameof(Register), clientName);
|
||||
return AsProxyServer().Register(clientName);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Unregister(int serverHandle)
|
||||
{
|
||||
if (mxAccessComObject is ILMXProxyServer mxAccessServer)
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
mxAccessServer.Unregister(serverHandle);
|
||||
typedFake.Unregister(serverHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
Invoke(nameof(Unregister), serverHandle);
|
||||
AsProxyServer().Unregister(serverHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -49,12 +62,12 @@ public sealed class MxAccessComServer : IMxAccessServer
|
||||
int serverHandle,
|
||||
string itemDefinition)
|
||||
{
|
||||
if (mxAccessComObject is ILMXProxyServer mxAccessServer)
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
return mxAccessServer.AddItem(serverHandle, itemDefinition);
|
||||
return typedFake.AddItem(serverHandle, itemDefinition);
|
||||
}
|
||||
|
||||
return (int)Invoke(nameof(AddItem), serverHandle, itemDefinition);
|
||||
return AsProxyServer().AddItem(serverHandle, itemDefinition);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -63,12 +76,12 @@ public sealed class MxAccessComServer : IMxAccessServer
|
||||
string itemDefinition,
|
||||
string itemContext)
|
||||
{
|
||||
if (mxAccessComObject is ILMXProxyServer3 mxAccessServer)
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
return mxAccessServer.AddItem2(serverHandle, itemDefinition, itemContext);
|
||||
return typedFake.AddItem2(serverHandle, itemDefinition, itemContext);
|
||||
}
|
||||
|
||||
return (int)Invoke(nameof(AddItem2), serverHandle, itemDefinition, itemContext);
|
||||
return AsProxyServer3().AddItem2(serverHandle, itemDefinition, itemContext);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -76,13 +89,13 @@ public sealed class MxAccessComServer : IMxAccessServer
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
if (mxAccessComObject is ILMXProxyServer mxAccessServer)
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
mxAccessServer.RemoveItem(serverHandle, itemHandle);
|
||||
typedFake.RemoveItem(serverHandle, itemHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
Invoke(nameof(RemoveItem), serverHandle, itemHandle);
|
||||
AsProxyServer().RemoveItem(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -90,13 +103,13 @@ public sealed class MxAccessComServer : IMxAccessServer
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
if (mxAccessComObject is ILMXProxyServer mxAccessServer)
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
mxAccessServer.Advise(serverHandle, itemHandle);
|
||||
typedFake.Advise(serverHandle, itemHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
Invoke(nameof(Advise), serverHandle, itemHandle);
|
||||
AsProxyServer().Advise(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -104,13 +117,13 @@ public sealed class MxAccessComServer : IMxAccessServer
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
if (mxAccessComObject is ILMXProxyServer mxAccessServer)
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
mxAccessServer.UnAdvise(serverHandle, itemHandle);
|
||||
typedFake.UnAdvise(serverHandle, itemHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
Invoke(nameof(UnAdvise), serverHandle, itemHandle);
|
||||
AsProxyServer().UnAdvise(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -118,34 +131,36 @@ public sealed class MxAccessComServer : IMxAccessServer
|
||||
int serverHandle,
|
||||
int itemHandle)
|
||||
{
|
||||
if (mxAccessComObject is ILMXProxyServer4 mxAccessServer)
|
||||
if (mxAccessComObject is IMxAccessServer typedFake)
|
||||
{
|
||||
mxAccessServer.AdviseSupervisory(serverHandle, itemHandle);
|
||||
typedFake.AdviseSupervisory(serverHandle, itemHandle);
|
||||
return;
|
||||
}
|
||||
|
||||
Invoke(nameof(AdviseSupervisory), serverHandle, itemHandle);
|
||||
AsProxyServer4().AdviseSupervisory(serverHandle, itemHandle);
|
||||
}
|
||||
|
||||
private object Invoke(
|
||||
string methodName,
|
||||
params object[] arguments)
|
||||
private ILMXProxyServer AsProxyServer()
|
||||
{
|
||||
try
|
||||
{
|
||||
return mxAccessComObject
|
||||
.GetType()
|
||||
.InvokeMember(
|
||||
methodName,
|
||||
BindingFlags.Instance | BindingFlags.Public | BindingFlags.InvokeMethod,
|
||||
binder: null,
|
||||
target: mxAccessComObject,
|
||||
args: arguments);
|
||||
}
|
||||
catch (TargetInvocationException exception) when (exception.InnerException is not null)
|
||||
{
|
||||
ExceptionDispatchInfo.Capture(exception.InnerException).Throw();
|
||||
throw;
|
||||
}
|
||||
return mxAccessComObject as ILMXProxyServer
|
||||
?? throw new InvalidOperationException(
|
||||
$"MXAccess COM object of type '{mxAccessComObject.GetType().FullName}' does not implement "
|
||||
+ $"{nameof(ILMXProxyServer)} or {nameof(IMxAccessServer)}.");
|
||||
}
|
||||
|
||||
private ILMXProxyServer3 AsProxyServer3()
|
||||
{
|
||||
return mxAccessComObject as ILMXProxyServer3
|
||||
?? throw new InvalidOperationException(
|
||||
$"MXAccess COM object of type '{mxAccessComObject.GetType().FullName}' does not implement "
|
||||
+ $"{nameof(ILMXProxyServer3)} or {nameof(IMxAccessServer)}.");
|
||||
}
|
||||
|
||||
private ILMXProxyServer4 AsProxyServer4()
|
||||
{
|
||||
return mxAccessComObject as ILMXProxyServer4
|
||||
?? throw new InvalidOperationException(
|
||||
$"MXAccess COM object of type '{mxAccessComObject.GetType().FullName}' does not implement "
|
||||
+ $"{nameof(ILMXProxyServer4)} or {nameof(IMxAccessServer)}.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
private IAlarmCommandHandler? alarmCommandHandler;
|
||||
private CancellationTokenSource? alarmPollCts;
|
||||
private Task? alarmPollTask;
|
||||
private int? alarmConsumerThreadId;
|
||||
private bool disposed;
|
||||
|
||||
/// <summary>
|
||||
@@ -180,6 +181,14 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
session = MxAccessSession.Create(factory, eventSink, sessionId);
|
||||
if (alarmCommandHandlerFactory is not null)
|
||||
{
|
||||
// STA-affinity invariant: the alarm consumer factory and
|
||||
// every IMxAccessAlarmConsumer call must run on the STA
|
||||
// thread, because the production wnwrap consumer holds an
|
||||
// Apartment-threaded COM object. The factory runs here
|
||||
// inside staRuntime.InvokeAsync, so this records the STA
|
||||
// thread id; RunAlarmPollLoopAsync then asserts each
|
||||
// PollOnce executes on the same thread.
|
||||
alarmConsumerThreadId = Environment.CurrentManagedThreadId;
|
||||
alarmCommandHandler = alarmCommandHandlerFactory(eventQueue);
|
||||
}
|
||||
commandDispatcher = new StaCommandDispatcher(
|
||||
@@ -227,7 +236,11 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
try
|
||||
{
|
||||
await staRuntime.InvokeAsync(
|
||||
() => handler.PollOnce(),
|
||||
() =>
|
||||
{
|
||||
EnsureOnAlarmConsumerThread();
|
||||
handler.PollOnce();
|
||||
},
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
@@ -244,10 +257,77 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
|
||||
// STA runtime shutting down — stop the loop gracefully.
|
||||
return;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A real alarm-poll failure (COMException from
|
||||
// GetXmlCurrentAlarms2, malformed-XML parse failure, etc.).
|
||||
// Record it as a fault on the event queue so a broken
|
||||
// alarm subscription becomes observable on the IPC fault
|
||||
// path instead of silently faulting this never-awaited
|
||||
// task. The loop then stops — the subscription is dead.
|
||||
eventQueue.RecordFault(CreateAlarmPollFault(exception));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}, CancellationToken.None);
|
||||
}
|
||||
|
||||
private void EnsureOnAlarmConsumerThread()
|
||||
{
|
||||
AssertOnAlarmConsumerThread(alarmConsumerThreadId, Environment.CurrentManagedThreadId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Enforces the STA-affinity invariant for the alarm consumer: every
|
||||
/// <see cref="IMxAccessAlarmConsumer"/> call (and the consumer factory)
|
||||
/// must run on the same thread the consumer was created on (the worker's
|
||||
/// STA). Throws <see cref="InvalidOperationException"/> when a caller
|
||||
/// breaks affinity — a programming error that would otherwise risk a
|
||||
/// cross-apartment COM deadlock in the production wnwrap consumer, since
|
||||
/// its CLSID is registered <c>ThreadingModel=Apartment</c>. The check is
|
||||
/// a no-op until the consumer thread has been recorded (no alarm handler
|
||||
/// configured, or session not yet started).
|
||||
/// </summary>
|
||||
/// <param name="expectedThreadId">
|
||||
/// The managed thread id the alarm consumer was created on, or
|
||||
/// <c>null</c> if no alarm consumer is configured.
|
||||
/// </param>
|
||||
/// <param name="actualThreadId">The current managed thread id.</param>
|
||||
internal static void AssertOnAlarmConsumerThread(int? expectedThreadId, int actualThreadId)
|
||||
{
|
||||
if (expectedThreadId is not null && actualThreadId != expectedThreadId.Value)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"Alarm consumer accessed off its owning STA thread. Expected thread {expectedThreadId.Value}, "
|
||||
+ $"actual {actualThreadId}. All IMxAccessAlarmConsumer calls must run on the STA that "
|
||||
+ "created the consumer.");
|
||||
}
|
||||
}
|
||||
|
||||
private static WorkerFault CreateAlarmPollFault(Exception exception)
|
||||
{
|
||||
string message =
|
||||
$"MXAccess alarm poll failed: {exception.Message}";
|
||||
WorkerFault fault = new()
|
||||
{
|
||||
Category = WorkerFaultCategory.MxaccessEventConversionFailed,
|
||||
ExceptionType = exception.GetType().FullName ?? string.Empty,
|
||||
DiagnosticMessage = message,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.WorkerUnavailable,
|
||||
Message = message,
|
||||
},
|
||||
};
|
||||
|
||||
if (exception is System.Runtime.InteropServices.COMException comException)
|
||||
{
|
||||
fault.Hresult = comException.HResult;
|
||||
}
|
||||
|
||||
return fault;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Dispatches a command to the STA thread for execution asynchronously.
|
||||
/// </summary>
|
||||
|
||||
Reference in New Issue
Block a user