fix(worker): review 2026-10-21 stream E — cache eviction, phased ReadBulk, poll/STA fault handling
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Failing after 1m3s
ci / java (push) Successful in 2m17s
ci / portable (push) Failing after 4m42s

- RemoveItem/Unregister evict value + write-completion caches (leak under churn)
- ReadBulk snapshot path: add/advise all, one shared deadline, then tear down
- alarm poll faults only after 3 consecutive failures; transient ones logged
- AlarmCommandHandler disposes primary when standby build throws
- StaRuntime pumps between queued work items
- WRK-02: post-start STA loop death surfaces as StaHung WorkerFault
- WRK-09: AppDomain unhandled-exception hook, exit code 7
- RETRIGGER documented as reserved, never synthesized
- TST-05: live facts for Ping/GetSessionState/GetWorkerInfo/DrainEvents/ShutdownWorker
This commit is contained in:
Joseph Doherty
2026-10-22 06:19:32 -04:00
parent 488649ce99
commit 7618cafb6c
22 changed files with 1859 additions and 90 deletions
@@ -1052,6 +1052,208 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
}
}
/// <summary>
/// TST-05: the real worker answers the four non-terminating control commands off-STA in
/// <c>WorkerPipeSession.HandleControlCommandAsync</c>. The default suite only ever sees the
/// FakeWorkerHarness canned replies, so this drives <c>Ping</c>, <c>GetSessionState</c>,
/// <c>GetWorkerInfo</c>, and <c>DrainEvents</c> through the gateway against a live worker and
/// asserts each reply carries real worker state rather than an <c>INVALID_REQUEST</c>.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_ControlCommands_ReturnRealWorkerState()
{
string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath();
Assert.True(
File.Exists(workerExecutablePath),
$"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}.");
TestWorkerProcessFactory processFactory = new(output);
await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, output);
string? sessionId = null;
try
{
OpenSessionReply openReply = await fixture.Service.OpenSession(
new OpenSessionRequest
{
ClientSessionName = "live-mxaccess-control",
ClientCorrelationId = "live-open-control",
CommandTimeout = Duration.FromTimeSpan(CommandTimeout),
},
new TestServerCallContext()).ConfigureAwait(false);
sessionId = openReply.SessionId;
Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code);
Assert.True(openReply.WorkerProcessId > 0);
MxCommandReply pingReply = await fixture.Service.Invoke(
CreateControlRequest(sessionId, "live-ping", new MxCommand
{
Kind = MxCommandKind.Ping,
Ping = new PingCommand { Message = "live-ping-echo" },
}),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("Ping", pingReply);
Assert.Equal(ProtocolStatusCode.Ok, pingReply.ProtocolStatus.Code);
Assert.Equal(MxCommandKind.Ping, pingReply.Kind);
MxCommandReply stateReply = await fixture.Service.Invoke(
CreateControlRequest(sessionId, "live-get-session-state", new MxCommand
{
Kind = MxCommandKind.GetSessionState,
GetSessionState = new GetSessionStateCommand(),
}),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("GetSessionState", stateReply);
Assert.Equal(ProtocolStatusCode.Ok, stateReply.ProtocolStatus.Code);
Assert.Equal(MxCommandKind.GetSessionState, stateReply.Kind);
Assert.NotNull(stateReply.SessionState);
Assert.Equal(SessionState.Ready, stateReply.SessionState.State);
MxCommandReply infoReply = await fixture.Service.Invoke(
CreateControlRequest(sessionId, "live-get-worker-info", new MxCommand
{
Kind = MxCommandKind.GetWorkerInfo,
GetWorkerInfo = new GetWorkerInfoCommand(),
}),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("GetWorkerInfo", infoReply);
Assert.Equal(ProtocolStatusCode.Ok, infoReply.ProtocolStatus.Code);
Assert.Equal(MxCommandKind.GetWorkerInfo, infoReply.Kind);
Assert.NotNull(infoReply.WorkerInfo);
output.WriteLine(
$"GetWorkerInfo pid={infoReply.WorkerInfo.WorkerProcessId} version={infoReply.WorkerInfo.WorkerVersion} "
+ $"progid={infoReply.WorkerInfo.MxaccessProgid} clsid={infoReply.WorkerInfo.MxaccessClsid}");
Assert.Equal(openReply.WorkerProcessId, infoReply.WorkerInfo.WorkerProcessId);
Assert.False(string.IsNullOrWhiteSpace(infoReply.WorkerInfo.WorkerVersion));
Assert.False(string.IsNullOrWhiteSpace(infoReply.WorkerInfo.MxaccessProgid));
Assert.False(string.IsNullOrWhiteSpace(infoReply.WorkerInfo.MxaccessClsid));
// No StreamEvents subscriber is attached, so whatever the worker queued since start
// is still drainable; the reply must carry the DrainEvents payload (possibly empty).
MxCommandReply drainReply = await fixture.Service.Invoke(
CreateControlRequest(sessionId, "live-drain-events", new MxCommand
{
Kind = MxCommandKind.DrainEvents,
DrainEvents = new DrainEventsCommand { MaxEvents = 16 },
}),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("DrainEvents", drainReply);
Assert.Equal(ProtocolStatusCode.Ok, drainReply.ProtocolStatus.Code);
Assert.Equal(MxCommandKind.DrainEvents, drainReply.Kind);
Assert.Equal(MxCommandReply.PayloadOneofCase.DrainEvents, drainReply.PayloadCase);
Assert.True(drainReply.DrainEvents.Events.Count <= 16);
output.WriteLine($"DrainEvents drained={drainReply.DrainEvents.Events.Count}");
// A control command must not disturb the session: it is still Ready afterwards.
Assert.True(fixture.TryGetSession(sessionId, out GatewaySession? session));
Assert.Equal(SessionState.Ready, session!.State);
}
finally
{
await ShutDownAsync(fixture, processFactory, sessionId, streamTask: null).ConfigureAwait(false);
}
}
/// <summary>
/// TST-05: <c>ShutdownWorker</c> writes its OK reply before the worker shuts down, so the
/// reply reaches the caller (a reply written after the pipe closed would surface as a
/// pipe-disconnected failure instead), and the worker process then exits on its own and the
/// session leaves <see cref="SessionState.Ready"/>. Uses its own fixture and worker because it
/// terminates the worker.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[LiveMxAccessFact]
public async Task GatewaySession_WithLiveWorker_ShutdownWorker_RepliesOkThenWorkerExits()
{
string workerExecutablePath = IntegrationTestEnvironment.ResolveLiveMxAccessWorkerExecutablePath();
Assert.True(
File.Exists(workerExecutablePath),
$"Live MXAccess worker executable was not found at {workerExecutablePath}. Build the worker or set {IntegrationTestEnvironment.LiveMxAccessWorkerExecutableVariableName}.");
TestWorkerProcessFactory processFactory = new(output);
await using GatewayServiceFixture fixture = new(workerExecutablePath, processFactory, output);
string? sessionId = null;
try
{
OpenSessionReply openReply = await fixture.Service.OpenSession(
new OpenSessionRequest
{
ClientSessionName = "live-mxaccess-shutdown-worker",
ClientCorrelationId = "live-open-shutdown-worker",
CommandTimeout = Duration.FromTimeSpan(CommandTimeout),
},
new TestServerCallContext()).ConfigureAwait(false);
sessionId = openReply.SessionId;
Assert.Equal(ProtocolStatusCode.Ok, openReply.ProtocolStatus.Code);
MxCommandReply shutdownReply = await fixture.Service.Invoke(
CreateControlRequest(sessionId, "live-shutdown-worker", new MxCommand
{
Kind = MxCommandKind.ShutdownWorker,
ShutdownWorker = new ShutdownWorkerCommand
{
GracePeriod = Duration.FromTimeSpan(TimeSpan.FromSeconds(5)),
},
}),
new TestServerCallContext()).ConfigureAwait(false);
LogReply("ShutdownWorker", shutdownReply);
Assert.Equal(ProtocolStatusCode.Ok, shutdownReply.ProtocolStatus.Code);
Assert.Equal(MxCommandKind.ShutdownWorker, shutdownReply.Kind);
// The worker exits on its own — no CloseSession, no kill. WaitForProcessesAsync
// throws on timeout, which fails the test.
await processFactory.WaitForProcessesAsync(StreamShutdownTimeout).ConfigureAwait(false);
DateTimeOffset waitDeadline = DateTimeOffset.UtcNow + StreamShutdownTimeout;
SessionState observedState = SessionState.Ready;
while (DateTimeOffset.UtcNow < waitDeadline)
{
if (!fixture.TryGetSession(sessionId, out GatewaySession? session))
{
observedState = SessionState.Closed;
break;
}
observedState = session.State;
if (observedState is SessionState.Faulted or SessionState.Closed or SessionState.Closing)
{
break;
}
await Task.Delay(TimeSpan.FromMilliseconds(50)).ConfigureAwait(false);
}
output.WriteLine($"ShutdownWorker observed_state={observedState}");
Assert.True(
observedState is SessionState.Faulted or SessionState.Closed or SessionState.Closing,
$"Expected the session to leave Ready after ShutdownWorker; observed {observedState}.");
}
finally
{
// The worker is gone; a CloseSession round-trip would only log a cleanup failure.
await ShutDownAsync(fixture, processFactory, sessionId: null, streamTask: null).ConfigureAwait(false);
}
}
private static MxCommandRequest CreateControlRequest(
string sessionId,
string correlationId,
MxCommand command)
{
return new MxCommandRequest
{
SessionId = sessionId,
ClientCorrelationId = correlationId,
Command = command,
};
}
/// <summary>
/// Closes the session and drains the event stream / worker processes without letting a
/// cleanup timeout mask the original failure from the test body.
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.MxGateway.Contracts;
@@ -121,6 +122,49 @@ public sealed class WorkerApplicationTests
Assert.Equal(typeof(InvalidOperationException).FullName, entry.Fields["exception_type"]);
}
/// <summary>
/// WRK-09: the AppDomain unhandled-exception hook logs the exception type
/// (never its message) and exits with the dedicated exit code.
/// </summary>
[Fact]
public void HandleUnhandledException_LogsTypeOnlyAndExitsWithUnhandledExceptionCode()
{
MemoryWorkerLogger logger = new();
int? exitCode = null;
ZB.MOM.WW.MxGateway.Worker.WorkerApplication.HandleUnhandledException(
logger,
new InvalidOperationException("password=hunter2"),
isTerminating: true,
code => exitCode = code);
Assert.Equal((int)WorkerExitCode.UnhandledException, exitCode);
Assert.Equal(7, (int)WorkerExitCode.UnhandledException);
MemoryWorkerLogEntry entry = Assert.Single(logger.Entries);
Assert.Equal("Error", entry.Level);
Assert.Equal("WorkerUnhandledException", entry.EventName);
Assert.Equal(typeof(InvalidOperationException).FullName, entry.Fields["exception_type"]);
Assert.True(entry.Fields["is_terminating"] is true);
Assert.DoesNotContain(
entry.Fields.Values,
value => value?.ToString()?.Contains("hunter2") == true);
}
/// <summary>A failing logger must not prevent the defined exit.</summary>
[Fact]
public void HandleUnhandledException_WhenLoggerThrows_StillExits()
{
int? exitCode = null;
ZB.MOM.WW.MxGateway.Worker.WorkerApplication.HandleUnhandledException(
new ThrowingLogger(),
exceptionObject: null,
isTerminating: false,
code => exitCode = code);
Assert.Equal((int)WorkerExitCode.UnhandledException, exitCode);
}
private static string[] ValidArgs(string? protocolVersion = null)
{
return
@@ -171,4 +215,15 @@ public sealed class WorkerApplicationTests
throw _exception;
}
}
private sealed class ThrowingLogger : IWorkerLogger
{
/// <inheritdoc />
public void Information(string eventName, IReadOnlyDictionary<string, object?> fields) =>
throw new InvalidOperationException("logger failed");
/// <inheritdoc />
public void Error(string eventName, IReadOnlyDictionary<string, object?> fields) =>
throw new InvalidOperationException("logger failed");
}
}
@@ -499,6 +499,40 @@ public sealed class AlarmCommandHandlerTests
Assert.Equal(AlarmProviderMode.Subtag, evt.OnAlarmProviderModeChanged.Mode);
}
/// <summary>
/// In auto mode the primary consumer is built before the standby. When
/// building the standby throws, the primary is owned by nothing — the
/// failed Subscribe never reaches the dispatcher that would dispose it —
/// so the handler must dispose it rather than leak its COM object, and
/// the original failure must still propagate.
/// </summary>
[Fact]
public void Subscribe_AutoModeWhenStandbyBuildThrows_DisposesPrimaryAndRethrows()
{
FakeConsumer primary = new FakeConsumer();
InvalidOperationException standbyFailure = new InvalidOperationException("standby build failed");
AlarmCommandHandler handler = new AlarmCommandHandler(
new MxAccessEventQueue(),
() => primary,
threadAffinityCheck: null,
comFactory: null,
standbyFactory: _ => throw standbyFailure);
SubscribeAlarmsCommand command = new SubscribeAlarmsCommand
{
SubscriptionExpression = @"\\HOST\Galaxy!Area",
ForcedMode = AlarmProviderMode.Unspecified,
};
command.WatchList.Add(NewTarget("Galaxy!Area.Tank01.Level.HiHi"));
InvalidOperationException thrown = Assert.Throws<InvalidOperationException>(
() => handler.Subscribe(command, "s1"));
Assert.Same(standbyFailure, thrown);
Assert.True(primary.Disposed, "The primary consumer built before the failing standby must be disposed.");
Assert.False(handler.IsSubscribed);
}
/// <summary>An explicitly fallback-capable auto subscription builds a replaceable standby even when discovery is initially empty.</summary>
[Fact]
public void Subscribe_AutoModeWithEmptyWatchListAndFallbackEnabled_BuildsReplaceableStandby()
@@ -102,6 +102,22 @@ public sealed class AlarmRecordTransitionMapperTests
Assert.Equal(expected, AlarmRecordTransitionMapper.MapTransition(previous, current));
}
/// <summary>
/// RETRIGGER is reserved and never synthesized: no state pair — including
/// cleared-to-active, the re-alarm shape — maps to it.
/// </summary>
[Fact]
public void MapTransition_ForEveryStatePair_NeverProducesRetrigger()
{
foreach (MxAlarmStateKind previous in System.Enum.GetValues(typeof(MxAlarmStateKind)))
{
foreach (MxAlarmStateKind current in System.Enum.GetValues(typeof(MxAlarmStateKind)))
{
Assert.NotEqual(AlarmTransitionKind.Retrigger, AlarmRecordTransitionMapper.MapTransition(previous, current));
}
}
}
/// <summary>Verifies that valid XML fields assemble into correct UTC timestamp.</summary>
[Fact]
public void ParseTransitionTimestampUtc_WithValidXmlFields_AssemblesUtc()
@@ -0,0 +1,448 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using Google.Protobuf.Collections;
using Google.Protobuf.WellKnownTypes;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Worker.MxAccess;
using ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
/// <summary>
/// Direct tests of <see cref="MxAccessSession"/> handle-lifetime cache
/// eviction and the phased ReadBulk snapshot path, driven through
/// <see cref="MxAccessSession.CreateForTesting"/> with a recording fake
/// server. Nothing here needs an STA: the session methods are plain calls,
/// and ReadBulk's pump step is a test delegate that stands in for the COM
/// OnDataChange dispatch by writing the value cache directly.
/// </summary>
public sealed class MxAccessSessionTests
{
private const string ReadBulkTimeoutText = "timed out";
/// <summary>
/// RemoveItem must evict the handle pair's OnWriteComplete completion
/// entry as well as its last-value entry; otherwise an
/// AddItem -> Write -> RemoveItem churn grows the completion cache by one
/// entry per cycle for the life of the worker.
/// </summary>
[Fact]
public void RemoveItem_EvictsValueAndWriteCompletionEntriesForTheHandle()
{
RecordingMxAccessServer server = new();
CompletionCacheSink sink = new();
MxAccessValueCache valueCache = new();
using MxAccessSession session = MxAccessSession.CreateForTesting(server, sink, valueCache: valueCache);
int serverHandle = session.Register("client");
int keep = session.AddItem(serverHandle, "Tag.Keep");
int drop = session.AddItem(serverHandle, "Tag.Drop");
RecordCompletion(sink.WriteCompletionCache, serverHandle, keep);
RecordCompletion(sink.WriteCompletionCache, serverHandle, drop);
valueCache.Set(serverHandle, keep, BuildDataChange(serverHandle, keep, 1));
valueCache.Set(serverHandle, drop, BuildDataChange(serverHandle, drop, 2));
session.RemoveItem(serverHandle, drop);
Assert.Equal(1, sink.WriteCompletionCache.Count);
Assert.Equal(0UL, sink.WriteCompletionCache.CurrentVersion(serverHandle, drop));
Assert.Equal(1UL, sink.WriteCompletionCache.CurrentVersion(serverHandle, keep));
Assert.False(valueCache.TryGet(serverHandle, drop, out _));
Assert.True(valueCache.TryGet(serverHandle, keep, out _));
}
/// <summary>
/// A long-lived writer that keeps adding, writing, and removing items
/// must leave the completion cache empty — the leak this fix closes.
/// </summary>
[Fact]
public void AddItemWriteRemoveItemChurn_DoesNotGrowWriteCompletionCache()
{
RecordingMxAccessServer server = new();
CompletionCacheSink sink = new();
using MxAccessSession session = MxAccessSession.CreateForTesting(server, sink);
int serverHandle = session.Register("client");
for (int cycle = 0; cycle < 50; cycle++)
{
int itemHandle = session.AddItem(serverHandle, "Tag.Churn");
session.Write(serverHandle, itemHandle, cycle, userId: 0);
// Stand in for the sink's OnWriteComplete post-publish hook.
RecordCompletion(sink.WriteCompletionCache, serverHandle, itemHandle);
session.RemoveItem(serverHandle, itemHandle);
}
Assert.Equal(0, sink.WriteCompletionCache.Count);
}
/// <summary>
/// Unregister drops every item under the server in one MXAccess call,
/// so the per-item eviction never runs for them: both caches must be
/// evicted by server handle, and other servers' entries kept.
/// </summary>
[Fact]
public void Unregister_EvictsValueAndWriteCompletionEntriesForThatServerOnly()
{
RecordingMxAccessServer server = new();
CompletionCacheSink sink = new();
MxAccessValueCache valueCache = new();
using MxAccessSession session = MxAccessSession.CreateForTesting(server, sink, valueCache: valueCache);
int doomed = session.Register("doomed");
int survivor = session.Register("survivor");
int doomedItemA = session.AddItem(doomed, "Tag.A");
int doomedItemB = session.AddItem(doomed, "Tag.B");
int survivorItem = session.AddItem(survivor, "Tag.C");
RecordCompletion(sink.WriteCompletionCache, doomed, doomedItemA);
RecordCompletion(sink.WriteCompletionCache, doomed, doomedItemB);
RecordCompletion(sink.WriteCompletionCache, survivor, survivorItem);
valueCache.Set(doomed, doomedItemA, BuildDataChange(doomed, doomedItemA, 1));
valueCache.Set(doomed, doomedItemB, BuildDataChange(doomed, doomedItemB, 2));
valueCache.Set(survivor, survivorItem, BuildDataChange(survivor, survivorItem, 3));
session.Unregister(doomed);
Assert.Equal(1, sink.WriteCompletionCache.Count);
Assert.Equal(1UL, sink.WriteCompletionCache.CurrentVersion(survivor, survivorItem));
Assert.Equal(1, valueCache.Count);
Assert.True(valueCache.TryGet(survivor, survivorItem, out _));
Assert.False(valueCache.TryGet(doomed, doomedItemA, out _));
Assert.False(valueCache.TryGet(doomed, doomedItemB, out _));
}
/// <summary>
/// N uncached tags that never deliver an OnDataChange must cost one
/// shared timeout, not N x timeout, while each still reports its own
/// timed-out failure with its own item handle.
/// </summary>
[Fact]
public void ReadBulk_UncachedTagsThatNeverDeliver_WaitOneSharedTimeoutAndEachTimesOut()
{
RecordingMxAccessServer server = new();
using MxAccessSession session = MxAccessSession.CreateForTesting(server, new NoopEventSink());
int serverHandle = session.Register("client");
string[] tags = { "Tag.1", "Tag.2", "Tag.3", "Tag.4", "Tag.5" };
TimeSpan timeout = TimeSpan.FromMilliseconds(300);
Stopwatch stopwatch = Stopwatch.StartNew();
IReadOnlyList<BulkReadResult> results = session.ReadBulk(serverHandle, tags, timeout, () => { });
stopwatch.Stop();
// Serial would be >= 5 x 300 ms = 1500 ms. Allow generous slack for a
// loaded CI host while still failing the serial shape.
Assert.True(
stopwatch.Elapsed < TimeSpan.FromMilliseconds(1200),
$"ReadBulk took {stopwatch.Elapsed.TotalMilliseconds:F0} ms; expected one shared {timeout.TotalMilliseconds:F0} ms wait.");
Assert.True(stopwatch.Elapsed >= TimeSpan.FromMilliseconds(250));
Assert.Equal(tags.Length, results.Count);
HashSet<int> handles = new();
for (int index = 0; index < tags.Length; index++)
{
BulkReadResult result = results[index];
Assert.Equal(tags[index], result.TagAddress);
Assert.False(result.WasSuccessful);
Assert.False(result.WasCached);
Assert.Equal(serverHandle, result.ServerHandle);
Assert.Equal(server.HandleForTag(tags[index]), result.ItemHandle);
Assert.Equal(
$"ReadBulk timed out after {timeout.TotalMilliseconds:F0} ms waiting for first OnDataChange.",
result.ErrorMessage);
Assert.True(handles.Add(result.ItemHandle));
}
// Every snapshot handle is still torn down.
foreach (int handle in handles)
{
Assert.Contains($"UnAdvise:{serverHandle}:{handle}", server.Operations);
Assert.Contains($"RemoveItem:{serverHandle}:{handle}", server.Operations);
}
Assert.Empty(session.HandleRegistry.ItemHandles);
}
/// <summary>
/// The phased lifecycle: every uncached tag is added and advised before
/// the wait, and none is torn down until the wait is over.
/// </summary>
[Fact]
public void ReadBulk_AddsAndAdvisesEverySnapshotTagBeforeTearingAnyDown()
{
RecordingMxAccessServer server = new();
using MxAccessSession session = MxAccessSession.CreateForTesting(server, new NoopEventSink());
int serverHandle = session.Register("client");
session.ReadBulk(serverHandle, new[] { "Tag.A", "Tag.B", "Tag.C" }, TimeSpan.FromMilliseconds(50), () => { });
List<string> lifecycle = server.Operations.Where(op => !op.StartsWith("Register", StringComparison.Ordinal)).ToList();
int lastAdvise = lifecycle.FindLastIndex(op => op.StartsWith("Advise:", StringComparison.Ordinal));
int firstUnAdvise = lifecycle.FindIndex(op => op.StartsWith("UnAdvise:", StringComparison.Ordinal));
int firstRemove = lifecycle.FindIndex(op => op.StartsWith("RemoveItem:", StringComparison.Ordinal));
Assert.Equal(3, lifecycle.Count(op => op.StartsWith("AddItem:", StringComparison.Ordinal)));
Assert.Equal(3, lifecycle.Count(op => op.StartsWith("Advise:", StringComparison.Ordinal)));
Assert.True(lastAdvise < firstUnAdvise, string.Join(",", lifecycle));
Assert.True(lastAdvise < firstRemove, string.Join(",", lifecycle));
}
/// <summary>
/// Every per-tag outcome keeps its exact pre-batching shape and request
/// order in one mixed request: blank address, cached-and-advised fast
/// path, a tag that delivers, an AddItem failure, an Advise failure, and
/// a tag that never delivers. A delivering tag returns as soon as its
/// value lands rather than waiting out the timeout for the others.
/// </summary>
[Fact]
public void ReadBulk_MixedOutcomes_PreservesPerTagResultsAndOrder()
{
RecordingMxAccessServer server = new();
server.FailAddItemFor.Add("Tag.AddFails");
server.FailAdviseFor.Add("Tag.AdviseFails");
MxAccessValueCache valueCache = new();
using MxAccessSession session = MxAccessSession.CreateForTesting(server, new NoopEventSink(), valueCache: valueCache);
int serverHandle = session.Register("client");
// Caller-owned advised tag with a cached value: served from the cache.
int cachedHandle = session.AddItem(serverHandle, "Tag.Cached");
session.Advise(serverHandle, cachedHandle);
valueCache.Set(serverHandle, cachedHandle, BuildDataChange(serverHandle, cachedHandle, 11));
int operationsBeforeRead = server.Operations.Count;
// Pump step: deliver Tag.Delivers' first OnDataChange once advised.
Action pumpStep = () =>
{
if (server.TryGetAdvisedHandle("Tag.Delivers", out int handle)
&& !valueCache.TryGet(serverHandle, handle, out _))
{
valueCache.Set(serverHandle, handle, BuildDataChange(serverHandle, handle, 42));
}
};
string[] tags = { " ", "Tag.Cached", "Tag.Delivers", "Tag.AddFails", "Tag.AdviseFails", "Tag.Silent" };
IReadOnlyList<BulkReadResult> results = session.ReadBulk(serverHandle, tags, TimeSpan.FromMilliseconds(150), pumpStep);
Assert.Equal(tags.Length, results.Count);
for (int index = 0; index < tags.Length; index++)
{
Assert.Equal(tags[index], results[index].TagAddress);
}
Assert.False(results[0].WasSuccessful);
Assert.Equal(0, results[0].ItemHandle);
Assert.Equal("Tag address is required.", results[0].ErrorMessage);
Assert.True(results[1].WasSuccessful);
Assert.True(results[1].WasCached);
Assert.Equal(cachedHandle, results[1].ItemHandle);
Assert.Equal(11, results[1].Value.Int32Value);
Assert.True(results[2].WasSuccessful);
Assert.False(results[2].WasCached);
Assert.Equal(server.HandleForTag("Tag.Delivers"), results[2].ItemHandle);
Assert.Equal(42, results[2].Value.Int32Value);
Assert.Equal(192, results[2].Quality);
Assert.False(results[3].WasSuccessful);
Assert.Equal(0, results[3].ItemHandle);
Assert.Equal("AddItem failed for Tag.AddFails", results[3].ErrorMessage);
Assert.False(results[4].WasSuccessful);
Assert.Equal(server.HandleForTag("Tag.AdviseFails"), results[4].ItemHandle);
Assert.Equal("Advise failed for Tag.AdviseFails", results[4].ErrorMessage);
Assert.False(results[5].WasSuccessful);
Assert.Equal(server.HandleForTag("Tag.Silent"), results[5].ItemHandle);
Assert.Contains(ReadBulkTimeoutText, results[5].ErrorMessage, StringComparison.OrdinalIgnoreCase);
// The caller's subscription is untouched; the snapshot handles are gone
// (the Advise-failed one removed without an UnAdvise).
List<string> readOperations = server.Operations.Skip(operationsBeforeRead).ToList();
Assert.DoesNotContain($"UnAdvise:{serverHandle}:{cachedHandle}", readOperations);
Assert.DoesNotContain($"RemoveItem:{serverHandle}:{cachedHandle}", readOperations);
Assert.DoesNotContain($"UnAdvise:{serverHandle}:{server.HandleForTag("Tag.AdviseFails")}", readOperations);
Assert.Contains($"RemoveItem:{serverHandle}:{server.HandleForTag("Tag.AdviseFails")}", readOperations);
Assert.Contains($"UnAdvise:{serverHandle}:{server.HandleForTag("Tag.Delivers")}", readOperations);
Assert.Contains($"RemoveItem:{serverHandle}:{server.HandleForTag("Tag.Silent")}", readOperations);
RegisteredItemHandle remaining = Assert.Single(session.HandleRegistry.ItemHandles);
Assert.Equal(cachedHandle, remaining.ItemHandle);
}
/// <summary>
/// When every snapshot tag delivers, the wait ends as soon as the last
/// value lands instead of running out the deadline.
/// </summary>
[Fact]
public void ReadBulk_AllTagsDeliver_ReturnsWithoutWaitingOutTheTimeout()
{
RecordingMxAccessServer server = new();
MxAccessValueCache valueCache = new();
using MxAccessSession session = MxAccessSession.CreateForTesting(server, new NoopEventSink(), valueCache: valueCache);
int serverHandle = session.Register("client");
string[] tags = { "Tag.1", "Tag.2", "Tag.3" };
Action pumpStep = () =>
{
foreach (string tag in tags)
{
if (server.TryGetAdvisedHandle(tag, out int handle) && !valueCache.TryGet(serverHandle, handle, out _))
{
valueCache.Set(serverHandle, handle, BuildDataChange(serverHandle, handle, handle));
}
}
};
Stopwatch stopwatch = Stopwatch.StartNew();
IReadOnlyList<BulkReadResult> results = session.ReadBulk(serverHandle, tags, TimeSpan.FromSeconds(10), pumpStep);
stopwatch.Stop();
Assert.True(stopwatch.Elapsed < TimeSpan.FromSeconds(5), $"ReadBulk took {stopwatch.Elapsed.TotalMilliseconds:F0} ms.");
Assert.All(results, result => Assert.True(result.WasSuccessful));
for (int index = 0; index < tags.Length; index++)
{
Assert.Equal(results[index].ItemHandle, results[index].Value.Int32Value);
}
}
private static void RecordCompletion(MxAccessWriteCompletionCache cache, int serverHandle, int itemHandle)
{
RepeatedField<MxStatusProxy> statuses = new() { new MxStatusProxy { Category = MxStatusCategory.Ok } };
cache.Record(serverHandle, itemHandle, statuses);
}
private static MxEvent BuildDataChange(int serverHandle, int itemHandle, int value)
{
MxEvent mxEvent = new()
{
Family = MxEventFamily.OnDataChange,
ServerHandle = serverHandle,
ItemHandle = itemHandle,
Quality = 192,
SourceTimestamp = Timestamp.FromDateTime(new DateTime(2026, 10, 22, 0, 0, 0, DateTimeKind.Utc)),
Value = new MxValue
{
DataType = MxDataType.Integer,
VariantType = "VT_I4",
Int32Value = value,
},
OnDataChange = new OnDataChangeEvent(),
};
mxEvent.Statuses.Add(new MxStatusProxy { Category = MxStatusCategory.Ok });
return mxEvent;
}
/// <summary>Test sink that owns a real completion cache (Attach/Detach are no-ops).</summary>
private sealed class CompletionCacheSink : IMxAccessEventSink, IWriteCompletionCacheProvider
{
/// <inheritdoc />
public MxAccessWriteCompletionCache WriteCompletionCache { get; } = new();
/// <inheritdoc />
public void Attach(object mxAccessComObject, string sessionId)
{
}
/// <inheritdoc />
public void Detach()
{
}
}
/// <summary>
/// Fake server that hands out increasing handles, records every
/// lifecycle call, and can fail AddItem or Advise for named tags.
/// </summary>
private sealed class RecordingMxAccessServer : IMxAccessServer
{
private readonly NoopMxAccessServer noop = new();
private readonly Dictionary<string, int> handleByTag = new(StringComparer.Ordinal);
private readonly Dictionary<int, string> tagByHandle = new();
private readonly HashSet<int> advised = new();
private int nextServerHandle = 10;
private int nextItemHandle = 100;
public List<string> Operations { get; } = new();
public HashSet<string> FailAddItemFor { get; } = new(StringComparer.Ordinal);
public HashSet<string> FailAdviseFor { get; } = new(StringComparer.Ordinal);
public int HandleForTag(string tag) => handleByTag[tag];
public bool TryGetAdvisedHandle(string tag, out int handle)
{
return handleByTag.TryGetValue(tag, out handle) && advised.Contains(handle);
}
public int Register(string clientName)
{
Operations.Add($"Register:{clientName}");
return nextServerHandle++;
}
public void Unregister(int serverHandle) => Operations.Add($"Unregister:{serverHandle}");
public int AddItem(int serverHandle, string itemDefinition)
{
Operations.Add($"AddItem:{serverHandle}:{itemDefinition}");
if (FailAddItemFor.Contains(itemDefinition))
{
throw new InvalidOperationException($"AddItem failed for {itemDefinition}");
}
int handle = nextItemHandle++;
handleByTag[itemDefinition] = handle;
tagByHandle[handle] = itemDefinition;
return handle;
}
public int AddItem2(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition);
public void RemoveItem(int serverHandle, int itemHandle) => Operations.Add($"RemoveItem:{serverHandle}:{itemHandle}");
public void Advise(int serverHandle, int itemHandle)
{
Operations.Add($"Advise:{serverHandle}:{itemHandle}");
if (tagByHandle.TryGetValue(itemHandle, out string? tag) && FailAdviseFor.Contains(tag))
{
throw new InvalidOperationException($"Advise failed for {tag}");
}
advised.Add(itemHandle);
}
public void UnAdvise(int serverHandle, int itemHandle)
{
Operations.Add($"UnAdvise:{serverHandle}:{itemHandle}");
advised.Remove(itemHandle);
}
public void AdviseSupervisory(int serverHandle, int itemHandle) => Advise(serverHandle, itemHandle);
public int AddBufferedItem(int serverHandle, string itemDefinition, string itemContext) => AddItem(serverHandle, itemDefinition);
public void SetBufferedUpdateInterval(int serverHandle, int updateIntervalMilliseconds) =>
noop.SetBufferedUpdateInterval(serverHandle, updateIntervalMilliseconds);
public object Suspend(int serverHandle, int itemHandle) => noop.Suspend(serverHandle, itemHandle);
public object Activate(int serverHandle, int itemHandle) => noop.Activate(serverHandle, itemHandle);
public void Write(int serverHandle, int itemHandle, object? value, int userId) =>
Operations.Add($"Write:{serverHandle}:{itemHandle}");
public void Write2(int serverHandle, int itemHandle, object? value, object? timestampValue, int userId) =>
Operations.Add($"Write2:{serverHandle}:{itemHandle}");
public void WriteSecured(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value) =>
Operations.Add($"WriteSecured:{serverHandle}:{itemHandle}");
public void WriteSecured2(int serverHandle, int itemHandle, int currentUserId, int verifierUserId, object? value, object? timestampValue) =>
Operations.Add($"WriteSecured2:{serverHandle}:{itemHandle}");
public int AuthenticateUser(int serverHandle, string verifyUser, string verifyUserPassword) => 0;
public int ArchestrAUserToId(int serverHandle, string userIdGuid) => 0;
}
}
@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading;
@@ -573,6 +574,136 @@ public sealed class MxAccessStaSessionTests
"Expected StaCallInProgress to clear once the alarm poll returned.");
}
/// <summary>
/// A transient PollOnce failure must not fault the whole session: with a
/// failure threshold of 3, a poll pattern that never fails three times in
/// a row (fail, fail, succeed, ...) keeps the session healthy, and each
/// transient failure is logged with type/HRESULT only — never the
/// exception message.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunAlarmPollLoop_TransientFailuresBelowThreshold_DoNotFaultSession()
{
FakeAlarmCommandHandler handler = new()
{
PollExceptionFactory = call => call % 3 == 0
? null
: new COMException("secret-tag-name GetXmlCurrentAlarms2 failed.", unchecked((int)0x80004005)),
};
ConcurrentQueue<string> warnings = new();
using StaRuntime runtime = CreateRuntime();
MxAccessEventQueue eventQueue = new();
using MxAccessStaSession session = new(
runtime,
new FakeMxAccessComObjectFactory(),
new FakeMxAccessEventSink(),
eventQueue,
(_eq, _affinity, _comFactory) => handler);
session.AlarmPollInterval = TimeSpan.FromMilliseconds(10);
session.AlarmPollFailureThreshold = 3;
session.AlarmPollWarningSink = warnings.Enqueue;
await session.StartAsync("session-1", workerProcessId: 1);
DateTime deadline = DateTime.UtcNow.AddSeconds(10);
while (handler.PollCount < 12 && !eventQueue.IsFaulted && DateTime.UtcNow < deadline)
{
await Task.Delay(10);
}
Assert.False(eventQueue.IsFaulted, "Transient alarm-poll failures below the threshold must not fault the session.");
Assert.True(handler.PollCount >= 12, $"Expected the poll loop to keep polling; saw {handler.PollCount} polls.");
Assert.NotEmpty(warnings);
Assert.All(warnings, line =>
{
Assert.Contains("event=AlarmPollFailed", line, StringComparison.Ordinal);
Assert.Contains("hresult=0x80004005", line, StringComparison.Ordinal);
Assert.DoesNotContain("secret-tag-name", line, StringComparison.Ordinal);
});
}
/// <summary>
/// A run of failures reaching the threshold with no success in between
/// faults the session exactly once and stops the loop: two warnings, then
/// the fault on the third consecutive failure, then no further polls.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RunAlarmPollLoop_ConsecutiveFailuresReachThreshold_FaultsAndStops()
{
FakeAlarmCommandHandler handler = new()
{
PollException = new COMException("GetXmlCurrentAlarms2 failed.", unchecked((int)0x80004005)),
};
ConcurrentQueue<string> warnings = new();
using StaRuntime runtime = CreateRuntime();
MxAccessEventQueue eventQueue = new();
using MxAccessStaSession session = new(
runtime,
new FakeMxAccessComObjectFactory(),
new FakeMxAccessEventSink(),
eventQueue,
(_eq, _affinity, _comFactory) => handler);
session.AlarmPollInterval = TimeSpan.FromMilliseconds(10);
session.AlarmPollFailureThreshold = 3;
session.AlarmPollWarningSink = warnings.Enqueue;
await session.StartAsync("session-1", workerProcessId: 1);
DateTime deadline = DateTime.UtcNow.AddSeconds(10);
while (!eventQueue.IsFaulted && DateTime.UtcNow < deadline)
{
await Task.Delay(10);
}
Assert.True(eventQueue.IsFaulted);
await Task.Delay(200);
Assert.Equal(3, handler.PollCount);
Assert.Equal(2, warnings.Count);
WorkerFault? fault = session.DrainFault();
Assert.NotNull(fault);
Assert.Equal(WorkerFaultCategory.MxaccessEventConversionFailed, fault!.Category);
}
/// <summary>
/// WRK-02: when the STA loop dies after the session started, the death is
/// recorded as a StaHung fault on the event queue — the path the IPC drain
/// loop turns into a WorkerFault frame and a terminated pipe session.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task StaLoopDeath_AfterStart_RecordsStaHungFaultOnEventQueue()
{
ControllableMessagePump pump = new();
using StaRuntime runtime = new(new NoopComApartmentInitializer(), pump, TimeSpan.FromMilliseconds(25));
MxAccessEventQueue eventQueue = new();
using MxAccessStaSession session = new(
runtime,
new FakeMxAccessComObjectFactory(),
new FakeMxAccessEventSink(),
eventQueue);
await session.StartAsync("session-1", workerProcessId: 1);
Assert.False(eventQueue.IsFaulted);
pump.ThrowOnNextPump(new InvalidOperationException("The worker STA message pump failed."));
DateTime deadline = DateTime.UtcNow.AddSeconds(5);
while (!eventQueue.IsFaulted && DateTime.UtcNow < deadline)
{
await Task.Delay(10);
}
Assert.True(eventQueue.IsFaulted, "Expected the STA loop death to fault the event queue.");
WorkerFault? fault = session.DrainFault();
Assert.NotNull(fault);
Assert.Equal(WorkerFaultCategory.StaHung, fault!.Category);
Assert.Equal(typeof(InvalidOperationException).FullName, fault.ExceptionType);
Assert.Contains("STA loop terminated", fault.DiagnosticMessage, StringComparison.Ordinal);
Assert.Equal(ProtocolStatusCode.WorkerUnavailable, fault.ProtocolStatus.Code);
}
/// <summary>
/// The STA-affinity guard throws when an
/// IMxAccessAlarmConsumer call is attempted off the thread that created
@@ -621,6 +752,9 @@ public sealed class MxAccessStaSessionTests
/// <summary>Exception thrown by PollOnce; null to succeed.</summary>
public Exception? PollException { get; set; }
/// <summary>Per-call exception factory (1-based call number); null result succeeds.</summary>
public Func<int, Exception?>? PollExceptionFactory { get; set; }
/// <summary>When set, <see cref="PollOnce"/> blocks until <see cref="ReleasePoll"/> is called.</summary>
public bool BlockPoll { get; set; }
@@ -699,6 +833,22 @@ public sealed class MxAccessStaSessionTests
{
throw PollException;
}
Func<int, Exception?>? factory = PollExceptionFactory;
if (factory is not null)
{
int callNumber;
lock (gate)
{
callNumber = pollCount;
}
Exception? exception = factory(callNumber);
if (exception is not null)
{
throw exception;
}
}
}
/// <inheritdoc />
@@ -155,6 +155,124 @@ public sealed class StaRuntimeTests
Assert.Contains("shutting down", exception.Message);
}
/// <summary>
/// A backlog of queued work items must not starve inbound COM
/// callbacks: the loop pumps Windows messages between items rather
/// than only after draining the whole queue. A gate item holds the STA
/// while two more items queue behind it; once released, the second
/// queued item must observe a pump that happened after the first.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task ProcessQueuedCommands_PumpsMessagesBetweenQueuedWorkItems()
{
ControllableMessagePump pump = new();
using StaRuntime runtime = new(new RecordingComApartmentInitializer(), pump, TimeSpan.FromSeconds(30));
runtime.Start();
using ManualResetEventSlim gateEntered = new(false);
using ManualResetEventSlim releaseGate = new(false);
Task gate = runtime.InvokeAsync(() =>
{
gateEntered.Set();
releaseGate.Wait(TimeSpan.FromSeconds(10));
});
Assert.True(gateEntered.Wait(TimeSpan.FromSeconds(5)));
Task<int> first = runtime.InvokeAsync(() => pump.PumpCount);
Task<int> second = runtime.InvokeAsync(() => pump.PumpCount);
releaseGate.Set();
await gate;
int pumpsSeenByFirst = await first;
int pumpsSeenBySecond = await second;
Assert.True(
pumpsSeenBySecond > pumpsSeenByFirst,
$"Expected a pump between queued work items (first saw {pumpsSeenByFirst}, second saw {pumpsSeenBySecond}).");
}
/// <summary>
/// WRK-02: an exception that kills the STA loop after a successful
/// <see cref="StaRuntime.Start"/> must be surfaced — recorded on
/// <see cref="StaRuntime.LoopFault"/>, handed to the registered fault
/// handler, and turned into fast failures for every later
/// <see cref="StaRuntime.InvokeAsync{T}(Func{T}, CancellationToken)"/> —
/// rather than parked in a startup field nothing reads.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task LoopException_AfterStart_IsReportedToHandlerAndFailsLaterCommands()
{
ControllableMessagePump pump = new();
RecordingComApartmentInitializer initializer = new();
using StaRuntime runtime = new(initializer, pump, TimeSpan.FromMilliseconds(25));
runtime.Start();
TaskCompletionSource<Exception> reported = new(TaskCreationOptions.RunContinuationsAsynchronously);
runtime.RegisterLoopFaultHandler(exception => reported.TrySetResult(exception));
Assert.Null(runtime.LoopFault);
InvalidOperationException loopException = new("pump failed");
pump.ThrowOnNextPump(loopException);
Task completed = await Task.WhenAny(reported.Task, Task.Delay(TimeSpan.FromSeconds(5)));
Assert.Same(reported.Task, completed);
Assert.Same(loopException, await reported.Task);
Assert.Same(loopException, runtime.LoopFault);
InvalidOperationException rejected = await Assert.ThrowsAsync<InvalidOperationException>(
() => runtime.InvokeAsync(() => 1));
Assert.IsNotType<StaRuntimeShutdownException>(rejected);
Assert.Same(loopException, rejected.InnerException);
// The dying thread still runs its apartment cleanup.
Assert.True(runtime.Shutdown(TimeSpan.FromSeconds(5)));
Assert.False(runtime.IsRunning);
Assert.Equal(1, initializer.UninitializeCount);
}
/// <summary>
/// A handler registered after the loop already died still receives the
/// fault, so the window between Start and registration loses nothing.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task RegisterLoopFaultHandler_AfterLoopDied_InvokesHandlerImmediately()
{
ControllableMessagePump pump = new();
using StaRuntime runtime = new(new RecordingComApartmentInitializer(), pump, TimeSpan.FromMilliseconds(25));
runtime.Start();
InvalidOperationException loopException = new("pump failed");
pump.ThrowOnNextPump(loopException);
DateTime deadline = DateTime.UtcNow.AddSeconds(5);
while (runtime.LoopFault is null && DateTime.UtcNow < deadline)
{
await Task.Delay(10);
}
Exception? seen = null;
runtime.RegisterLoopFaultHandler(exception => seen = exception);
Assert.Same(loopException, seen);
}
/// <summary>A requested shutdown is not a loop fault.</summary>
[Fact]
public void Shutdown_DoesNotRecordLoopFault()
{
using StaRuntime runtime = new(new RecordingComApartmentInitializer(), new ControllableMessagePump(), TimeSpan.FromMilliseconds(25));
runtime.Start();
bool invoked = false;
runtime.RegisterLoopFaultHandler(_ => invoked = true);
Assert.True(runtime.Shutdown(TimeSpan.FromSeconds(5)));
Assert.Null(runtime.LoopFault);
Assert.False(invoked);
}
private static StaRuntime CreateRuntime(RecordingComApartmentInitializer initializer)
{
return new StaRuntime(
@@ -0,0 +1,47 @@
using System;
using System.Threading;
using ZB.MOM.WW.MxGateway.Worker.Sta;
namespace ZB.MOM.WW.MxGateway.Worker.Tests.TestSupport;
/// <summary>
/// Test <see cref="IStaMessagePump"/> that counts pump calls and can be armed
/// to throw from the next pump, so tests can observe the STA loop's pump
/// interleaving and drive a post-start loop death without a real Win32
/// message queue. The idle wait blocks on the command wake event exactly
/// like the production pump's <c>MsgWaitForMultipleObjectsEx</c> does.
/// </summary>
internal sealed class ControllableMessagePump : IStaMessagePump
{
private int pumpCount;
private Exception? throwOnNextPump;
/// <summary>Gets the number of <see cref="PumpPendingMessages"/> calls so far.</summary>
public int PumpCount => Volatile.Read(ref pumpCount);
/// <summary>Arms the pump to throw <paramref name="exception"/> from its next pump call.</summary>
/// <param name="exception">The exception to throw once.</param>
public void ThrowOnNextPump(Exception exception)
{
Volatile.Write(ref throwOnNextPump, exception);
}
/// <inheritdoc />
public void WaitForWorkOrMessages(WaitHandle commandWakeEvent, TimeSpan timeout)
{
commandWakeEvent.WaitOne(timeout);
}
/// <inheritdoc />
public int PumpPendingMessages()
{
Interlocked.Increment(ref pumpCount);
Exception? exception = Interlocked.Exchange(ref throwOnNextPump, null);
if (exception is not null)
{
throw exception;
}
return 0;
}
}
@@ -9,4 +9,13 @@ public enum WorkerExitCode
MissingNonce = 4,
PipeConnectionFailed = 5,
ProtocolViolation = 6,
/// <summary>
/// An exception escaped to <c>AppDomain.UnhandledException</c> on some
/// thread (a background task, a COM callback thread, a timer) rather
/// than through <c>WorkerApplication.Run</c>'s own catch blocks. The
/// worker logs it and exits with this code instead of the CLR's crash
/// exit code.
/// </summary>
UnhandledException = 7,
}
@@ -246,13 +246,32 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
{
IMxAccessAlarmConsumer primary = consumerFactory()
?? throw new InvalidOperationException("Alarm consumer factory returned null.");
IMxAccessAlarmConsumer standby = BuildStandby(watchList);
AlarmFailoverConfig? failoverConfig = command.Failover;
FailoverSettings settings = new FailoverSettings(
failoverConfig?.ConsecutiveFailureThreshold ?? 3,
failoverConfig?.FailbackProbeIntervalSeconds ?? 30,
failoverConfig?.FailbackStableProbes ?? 3);
return new FailoverAlarmConsumer(primary, standby, settings);
IMxAccessAlarmConsumer? standby = null;
try
{
standby = BuildStandby(watchList);
AlarmFailoverConfig? failoverConfig = command.Failover;
FailoverSettings settings = new FailoverSettings(
failoverConfig?.ConsecutiveFailureThreshold ?? 3,
failoverConfig?.FailbackProbeIntervalSeconds ?? 30,
failoverConfig?.FailbackStableProbes ?? 3);
return new FailoverAlarmConsumer(primary, standby, settings);
}
catch
{
// The primary (and a standby that was built before the
// composite constructor threw) is owned by nothing yet: the
// failed Subscribe never reaches the dispatcher that would
// dispose it, so release it here or its wnwrap COM object
// leaks for the life of the worker.
if (standby is not null)
{
try { standby.Dispose(); } catch { /* swallow — preserve the build failure */ }
}
try { primary.Dispose(); } catch { /* swallow — preserve the build failure */ }
throw;
}
}
// Alarmmgr, or Unspecified with an empty watch list — primary only.
@@ -49,6 +49,17 @@ public static class AlarmRecordTransitionMapper
/// <item><description><c>previous=*Rtn</c> + <c>current=*Alm</c> → Raise (re-trigger after clear).</description></item>
/// <item><description>Anything else → Unspecified (no proto kind to emit).</description></item>
/// </list>
/// <para>
/// <see cref="AlarmTransitionKind.Retrigger"/> is never returned. It is
/// reserved in the contract, but neither alarm source gives the worker
/// a native re-alarm signal: the alarmmgr XML carries only the four
/// <c>*_ALM</c>/<c>*_RTN</c> states, so a return-to-alarm is
/// indistinguishable from a fresh raise and is reported as Raise, and
/// the subtag source observes no re-alarm counter. Deliberately not
/// synthesized (the gateway forwards only transitions the source
/// reports — see docs/AlarmClientDiscovery.md, "Retrigger
/// transitions").
/// </para>
/// </summary>
/// <param name="previous">The previous alarm state kind.</param>
/// <param name="current">The current alarm state kind.</param>
@@ -222,6 +222,13 @@ public sealed class MxAccessSession : IDisposable
mxAccessServer.Unregister(serverHandle);
handleRegistry.UnregisterServerHandle(serverHandle);
// Unregister drops every item under the server in one call, so the
// per-item evictions in RemoveItem never run for them. Evict both
// per-handle caches by server so a long-lived worker that registers
// and unregisters clients does not accumulate one entry per item it
// ever wrote or read, and a reused server handle id starts clean.
valueCache.RemoveServer(serverHandle);
writeCompletionCache.RemoveServer(serverHandle);
}
/// <summary>Adds an item to an MXAccess server and returns the item handle.</summary>
@@ -283,6 +290,11 @@ public sealed class MxAccessSession : IDisposable
// same handle id (which MXAccess may reuse) does not serve a stale
// OnDataChange snapshot from the previous lifetime.
valueCache.Remove(serverHandle, itemHandle);
// Same for the OnWriteComplete completion entry: the event sink records
// one per written handle pair, so without this an AddItem -> Write ->
// RemoveItem churn grows the cache by one entry per cycle for the life
// of the worker.
writeCompletionCache.Remove(serverHandle, itemHandle);
}
/// <summary>Advises on item changes with plain subscription.</summary>
@@ -870,14 +882,28 @@ public sealed class MxAccessSession : IDisposable
/// OnDataChange value if the tag is already advised AND a cached value
/// exists (no subscription side effects); otherwise takes the AddItem
/// + Advise + wait + UnAdvise + RemoveItem snapshot lifecycle itself.
/// <paramref name="timeout"/> bounds the wait per-tag in the snapshot
/// case; <paramref name="pumpStep"/> is invoked on every poll
/// iteration so the worker's STA can dispatch the incoming MXAccess
/// message that carries the value.
/// <paramref name="timeout"/> bounds the wait for every snapshot tag;
/// <paramref name="pumpStep"/> is invoked on every poll iteration so the
/// worker's STA can dispatch the incoming MXAccess message that carries
/// the value.
/// </summary>
/// <remarks>
/// The snapshot tags are processed in phases rather than one at a time:
/// every cache probe runs first, then every uncached tag is added and
/// advised, then one wait with a single shared deadline collects the
/// first OnDataChange per handle, then every snapshot handle is torn
/// down. A serial lifecycle held the STA for up to N x timeout when N
/// tags never produced a value; the phased form holds it for at most
/// one timeout. Each tag's result is still its own: a tag that never
/// delivered fails with the same timed-out diagnostic and its own item
/// handle, an AddItem or Advise failure fails only that tag, and results
/// keep request order. The cache probes run before any snapshot AddItem
/// so a later tag can never be served from an earlier tag's own
/// snapshot subscription.
/// </remarks>
/// <param name="serverHandle">The MXAccess server handle.</param>
/// <param name="tagAddresses">The tag addresses to read.</param>
/// <param name="timeout">The timeout per tag.</param>
/// <param name="timeout">The timeout bounding the shared snapshot wait.</param>
/// <param name="pumpStep">Action invoked on each poll iteration.</param>
/// <returns>The per-tag read results, in the order the tags were provided.</returns>
public IReadOnlyList<BulkReadResult> ReadBulk(
@@ -897,89 +923,157 @@ public sealed class MxAccessSession : IDisposable
throw new ArgumentNullException(nameof(pumpStep));
}
List<BulkReadResult> results = new(tagAddresses.Count);
foreach (string? tagAddress in tagAddresses)
int tagCount = tagAddresses.Count;
BulkReadResult?[] results = new BulkReadResult?[tagCount];
List<SnapshotRead> snapshots = new();
// Phase 1: validation and the cached-and-advised fast path. A tag
// already advised by the caller with a cached value is answered without
// touching the existing subscription — the caller didn't create it, so
// ReadBulk must not tear it down.
for (int index = 0; index < tagCount; index++)
{
string? tagAddress = tagAddresses[index];
if (string.IsNullOrWhiteSpace(tagAddress))
{
results.Add(FailedRead(serverHandle, tagAddress ?? string.Empty, itemHandle: 0, wasCached: false, "Tag address is required."));
results[index] = FailedRead(serverHandle, tagAddress ?? string.Empty, itemHandle: 0, wasCached: false, "Tag address is required.");
continue;
}
results.Add(ReadOneTag(serverHandle, tagAddress, timeout, pumpStep));
if (TryGetCachedReadFor(serverHandle, tagAddress!, out int cachedItemHandle, out MxAccessValueCache.CachedValue cachedValue))
{
results[index] = SucceededRead(serverHandle, tagAddress!, cachedItemHandle, wasCached: true, cachedValue);
continue;
}
snapshots.Add(new SnapshotRead(index, tagAddress!));
}
return results;
if (snapshots.Count > 0)
{
try
{
ReadSnapshots(serverHandle, snapshots, results, timeout, pumpStep);
}
finally
{
TearDownSnapshots(serverHandle, snapshots);
}
}
List<BulkReadResult> ordered = new(tagCount);
for (int index = 0; index < tagCount; index++)
{
ordered.Add(results[index]!);
}
return ordered;
}
private BulkReadResult ReadOneTag(
private void ReadSnapshots(
int serverHandle,
string tagAddress,
List<SnapshotRead> snapshots,
BulkReadResult?[] results,
TimeSpan timeout,
Action pumpStep)
{
// 1. Cached-and-advised fast path: scan the registry for a live item
// handle matching this tag and check whether the value cache has a
// payload for it. If so, return the cached value without touching
// the existing subscription — the caller didn't create it, so
// ReadBulk must not tear it down.
if (TryGetCachedReadFor(serverHandle, tagAddress, out int cachedItemHandle, out MxAccessValueCache.CachedValue cachedValue))
// Phase 2: reserve our own item handle and advise for every uncached
// tag. A per-tag AddItem/Advise failure fails only that tag.
List<SnapshotRead> waiting = new(snapshots.Count);
foreach (SnapshotRead snapshot in snapshots)
{
return SucceededRead(
serverHandle,
tagAddress,
cachedItemHandle,
wasCached: true,
cachedValue);
try
{
snapshot.ItemHandle = AddItem(serverHandle, snapshot.TagAddress);
snapshot.Baseline = valueCache.CurrentVersion(serverHandle, snapshot.ItemHandle);
Advise(serverHandle, snapshot.ItemHandle);
snapshot.Advised = true;
waiting.Add(snapshot);
}
catch (Exception exception)
{
results[snapshot.Index] = FailedRead(serverHandle, snapshot.TagAddress, snapshot.ItemHandle, wasCached: false, exception.Message);
}
}
// 2. Snapshot lifecycle. Reserve our own item handle, advise, pump
// until we see a fresh OnDataChange (or the deadline elapses),
// then tear it down.
int itemHandle = 0;
bool advised = false;
if (waiting.Count == 0)
{
return;
}
// Phase 3: one shared deadline. Pump until every advised handle has
// seen its first OnDataChange since its baseline, or the deadline
// elapses.
int[] itemHandles = new int[waiting.Count];
ulong[] baselines = new ulong[waiting.Count];
for (int index = 0; index < waiting.Count; index++)
{
itemHandles[index] = waiting[index].ItemHandle;
baselines[index] = waiting[index].Baseline;
}
bool[] received = new bool[waiting.Count];
MxAccessValueCache.CachedValue[] values = new MxAccessValueCache.CachedValue[waiting.Count];
try
{
itemHandle = AddItem(serverHandle, tagAddress);
ulong baseline = valueCache.CurrentVersion(serverHandle, itemHandle);
Advise(serverHandle, itemHandle);
advised = true;
DateTime deadline = DateTime.UtcNow + timeout;
bool gotValue = valueCache.TryWaitForUpdate(
serverHandle,
itemHandle,
baseline,
deadline,
pumpStep,
out MxAccessValueCache.CachedValue snapshot);
return gotValue
? SucceededRead(serverHandle, tagAddress, itemHandle, wasCached: false, snapshot)
: FailedRead(
serverHandle,
tagAddress,
itemHandle,
wasCached: false,
$"ReadBulk timed out after {timeout.TotalMilliseconds:F0} ms waiting for first OnDataChange.");
valueCache.TryWaitForUpdates(serverHandle, itemHandles, baselines, deadline, pumpStep, received, values);
}
catch (Exception exception)
{
return FailedRead(serverHandle, tagAddress, itemHandle, wasCached: false, exception.Message);
}
finally
{
// Snapshot teardown — best-effort. Errors here are noted on the
// diagnostic message of the original result (above) by appending
// a cleanup suffix; we never re-throw from finally.
if (advised)
// A pump failure ends the wait for every tag still outstanding;
// tags captured before it keep their values.
for (int index = 0; index < waiting.Count; index++)
{
try { UnAdvise(serverHandle, itemHandle); } catch { /* swallow — best effort */ }
if (!received[index])
{
SnapshotRead failed = waiting[index];
results[failed.Index] = FailedRead(serverHandle, failed.TagAddress, failed.ItemHandle, wasCached: false, exception.Message);
}
}
}
for (int index = 0; index < waiting.Count; index++)
{
SnapshotRead snapshot = waiting[index];
if (results[snapshot.Index] is not null)
{
continue;
}
if (itemHandle != 0)
results[snapshot.Index] = received[index]
? SucceededRead(serverHandle, snapshot.TagAddress, snapshot.ItemHandle, wasCached: false, values[index])
: FailedRead(
serverHandle,
snapshot.TagAddress,
snapshot.ItemHandle,
wasCached: false,
$"ReadBulk timed out after {timeout.TotalMilliseconds:F0} ms waiting for first OnDataChange.");
}
}
private void TearDownSnapshots(
int serverHandle,
List<SnapshotRead> snapshots)
{
// Phase 4: snapshot teardown — best-effort, never re-throws. A handle
// is torn down once even if MXAccess handed the same id to two tags in
// the batch.
HashSet<int> unadvised = new();
HashSet<int> removed = new();
foreach (SnapshotRead snapshot in snapshots)
{
if (snapshot.Advised && unadvised.Add(snapshot.ItemHandle))
{
try { RemoveItem(serverHandle, itemHandle); } catch { /* swallow — best effort */ }
try { UnAdvise(serverHandle, snapshot.ItemHandle); } catch { /* swallow — best effort */ }
}
}
foreach (SnapshotRead snapshot in snapshots)
{
if (snapshot.ItemHandle != 0 && removed.Add(snapshot.ItemHandle))
{
try { RemoveItem(serverHandle, snapshot.ItemHandle); } catch { /* swallow — best effort */ }
}
}
}
@@ -1308,6 +1402,35 @@ public sealed class MxAccessSession : IDisposable
}
}
/// <summary>
/// Mutable per-tag state for one ReadBulk snapshot lifecycle. A plain
/// class with get/set properties (no records or init accessors) so it
/// compiles under the worker's net48 target.
/// </summary>
private sealed class SnapshotRead
{
public SnapshotRead(int index, string tagAddress)
{
Index = index;
TagAddress = tagAddress;
}
/// <summary>Gets the position of the tag in the request.</summary>
public int Index { get; }
/// <summary>Gets the requested tag address.</summary>
public string TagAddress { get; }
/// <summary>Gets or sets the snapshot item handle, 0 until AddItem succeeds.</summary>
public int ItemHandle { get; set; }
/// <summary>Gets or sets the value-cache version captured before Advise.</summary>
public ulong Baseline { get; set; }
/// <summary>Gets or sets a value indicating whether Advise succeeded.</summary>
public bool Advised { get; set; }
}
private void ThrowIfDisposed()
{
if (disposed)
@@ -42,7 +42,15 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
/// <summary>Default alarm poll cadence when the environment says nothing usable.</summary>
internal static readonly TimeSpan DefaultAlarmPollInterval = TimeSpan.FromMilliseconds(500);
private readonly TimeSpan alarmPollInterval = ResolveAlarmPollInterval();
/// <summary>
/// Consecutive alarm-poll failures that fault the session. Matches the
/// default of <see cref="FailoverSettings.Threshold"/>
/// (<c>AlarmFailoverConfig.consecutive_failure_threshold</c>, default 3):
/// one transient COM hiccup in <c>PollOnce</c> is logged and retried on
/// the next tick, and only a run of failures with no success in between
/// is treated as a dead subscription.
/// </summary>
internal const int DefaultAlarmPollFailureThreshold = 3;
private readonly IMxAccessComObjectFactory factory;
private readonly IMxAccessEventSink eventSink;
@@ -221,6 +229,28 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
/// </summary>
internal TimeSpan WriteCompletionTimeout { get; set; } = ResolveWriteCompletionTimeout();
/// <summary>
/// Cadence the alarm poll loop waits between polls. Resolved from the
/// launcher environment; internal-settable as a test seam (set before
/// <see cref="StartAsync(string, int, CancellationToken)"/>).
/// </summary>
internal TimeSpan AlarmPollInterval { get; set; } = ResolveAlarmPollInterval();
/// <summary>
/// Consecutive alarm-poll failures that fault the session; clamped to a
/// minimum of 1. Internal-settable as a test seam.
/// </summary>
internal int AlarmPollFailureThreshold { get; set; } = DefaultAlarmPollFailureThreshold;
/// <summary>
/// Sink for the transient alarm-poll failure warning. Defaults to
/// <see cref="Console.Error"/>, the stream <c>WorkerConsoleLogger</c>
/// writes to. The line carries the exception type, HRESULT, and
/// failure counts only — never the exception message, which can echo
/// alarm or tag names. Tests substitute a collector.
/// </summary>
internal Action<string>? AlarmPollWarningSink { get; set; }
/// <summary>
/// Resolves the write-completion wait from the launcher-provided
/// environment variable; a missing or invalid value falls back to
@@ -288,6 +318,13 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
{
staRuntime.Start();
// WRK-02: a post-start STA loop death has no other observer. Record it
// on the event queue so the IPC drain loop sends a WorkerFault to the
// gateway and ends the pipe session (and with it the worker process),
// instead of leaving a live process whose commands queue forever.
staRuntime.RegisterLoopFaultHandler(
exception => eventQueue.RecordFault(CreateStaLoopFault(exception)));
WorkerReady ready = await staRuntime.InvokeAsync(
() =>
{
@@ -345,13 +382,16 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
IAlarmCommandHandler handler,
CancellationToken cancellationToken)
{
TimeSpan pollInterval = AlarmPollInterval;
int failureThreshold = AlarmPollFailureThreshold < 1 ? 1 : AlarmPollFailureThreshold;
return Task.Run(async () =>
{
int consecutiveFailures = 0;
while (!cancellationToken.IsCancellationRequested)
{
try
{
await Task.Delay(alarmPollInterval, cancellationToken).ConfigureAwait(false);
await Task.Delay(pollInterval, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
@@ -384,6 +424,7 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
}
},
cancellationToken).ConfigureAwait(false);
consecutiveFailures = 0;
}
catch (OperationCanceledException)
{
@@ -413,19 +454,57 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
// A real alarm-poll failure (COMException from
// GetXmlCurrentAlarms2, malformed-XML parse failure, an
// STA-affinity InvalidOperationException from
// EnsureOnAlarmConsumerThread, etc.). Record it as a
// fault on the event queue so a broken alarm subscription
// — or an affinity-invariant regression — becomes
// EnsureOnAlarmConsumerThread, etc.). A single failure is
// logged and retried on the next tick: one transient COM
// hiccup must not tear down the whole session (every data
// subscription with it). Only a run of
// AlarmPollFailureThreshold failures with no success in
// between is recorded as a fault on the event queue, so a
// broken alarm subscription — or an affinity-invariant
// regression, which fails every tick — still 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;
consecutiveFailures++;
if (consecutiveFailures >= failureThreshold)
{
eventQueue.RecordFault(CreateAlarmPollFault(exception));
return;
}
WriteAlarmPollWarning(exception, consecutiveFailures, failureThreshold);
}
}
}, CancellationToken.None);
}
private void WriteAlarmPollWarning(Exception exception, int consecutiveFailures, int failureThreshold)
{
// Same "level=<L> event=<E> k=v" line shape as WorkerConsoleLogger and
// the wnwrap truncation warning. Type and HRESULT only — the exception
// message is deliberately left out (it can carry alarm or tag names).
string hresult = exception is System.Runtime.InteropServices.COMException comException
? "0x" + comException.HResult.ToString("X8", System.Globalization.CultureInfo.InvariantCulture)
: "none";
string message = string.Format(
System.Globalization.CultureInfo.InvariantCulture,
"level=Warning event=AlarmPollFailed exceptionType={0} hresult={1} consecutiveFailures={2} faultThreshold={3}",
exception.GetType().FullName,
hresult,
consecutiveFailures,
failureThreshold);
try
{
Action<string> sink = AlarmPollWarningSink ?? Console.Error.WriteLine;
sink(message);
}
catch
{
// Logging must never end the poll loop.
}
}
private void EnsureOnAlarmConsumerThread()
{
AssertOnAlarmConsumerThread(alarmConsumerThreadId, Environment.CurrentManagedThreadId);
@@ -458,6 +537,36 @@ public sealed class MxAccessStaSession : IWorkerRuntimeSession
}
}
/// <summary>
/// Builds the fault reported when the STA loop dies after start. Uses
/// <see cref="WorkerFaultCategory.StaHung"/>: the gateway already treats
/// that category as "this worker's apartment can no longer run COM
/// work", which is exactly the state a dead loop leaves behind.
/// </summary>
internal static WorkerFault CreateStaLoopFault(Exception exception)
{
string message =
$"Worker STA loop terminated unexpectedly: {exception.GetType().FullName}: {exception.Message}";
WorkerFault fault = new()
{
Category = WorkerFaultCategory.StaHung,
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;
}
private static WorkerFault CreateAlarmPollFault(Exception exception)
{
string message =
@@ -162,6 +162,46 @@ public sealed class MxAccessValueCache
}
}
/// <summary>
/// Removes every cache slot recorded under a server handle. The session
/// calls this when a server handle is unregistered: MXAccess drops all of
/// that server's items with it, so the per-item <see cref="Remove"/>
/// never runs for them and their entries would otherwise outlive the
/// handle (and could be served to a later server that reuses the id).
/// </summary>
/// <param name="serverHandle">MXAccess server handle that was unregistered.</param>
public void RemoveServer(int serverHandle)
{
lock (syncRoot)
{
List<long> stale = new();
foreach (long key in entries.Keys)
{
if ((int)(key >> 32) == serverHandle)
{
stale.Add(key);
}
}
foreach (long key in stale)
{
entries.Remove(key);
}
}
}
/// <summary>Gets the number of handle pairs with a cached value (test seam).</summary>
internal int Count
{
get
{
lock (syncRoot)
{
return entries.Count;
}
}
}
/// <summary>
/// Waits until the cache entry's version exceeds <paramref name="sinceVersion"/>
/// or the deadline elapses, calling <paramref name="pumpStep"/> on every
@@ -231,6 +271,117 @@ public sealed class MxAccessValueCache
}
}
/// <summary>
/// Multi-handle form of <see cref="TryWaitForUpdate"/>: waits until every
/// handle in <paramref name="itemHandles"/> has an entry newer than its
/// matching <paramref name="sinceVersions"/> slot, or the single shared
/// deadline elapses. Each handle is captured the first time an update is
/// observed for it (its first OnDataChange since the baseline), exactly
/// as a dedicated per-handle wait would have captured it, so a bulk
/// snapshot read waits at most one timeout in total rather than one per
/// tag.
/// </summary>
/// <remarks>
/// Pumps unconditionally at the top of every iteration and blocks the
/// idle part of each iteration in
/// <see cref="StaWaitHelper.WaitForSignalOrMessages"/> — the same pump
/// discipline as <see cref="TryWaitForUpdate"/>, for the same reason.
/// </remarks>
/// <param name="serverHandle">MXAccess server handle shared by every item.</param>
/// <param name="itemHandles">Item handles to wait on.</param>
/// <param name="sinceVersions">Per-handle version baselines captured before advising.</param>
/// <param name="deadlineUtc">Absolute UTC deadline shared by every handle.</param>
/// <param name="pumpStep">Action that pumps any pending Windows messages.</param>
/// <param name="received">Output: <see langword="true"/> at index i when handle i received an update before the deadline.</param>
/// <param name="values">Output: the captured value at index i when <paramref name="received"/>[i] is set.</param>
/// <param name="pollIntervalMs">Upper bound on one idle wait slice.</param>
/// <returns>The number of handles that received an update before the deadline.</returns>
public int TryWaitForUpdates(
int serverHandle,
IReadOnlyList<int> itemHandles,
IReadOnlyList<ulong> sinceVersions,
DateTime deadlineUtc,
Action pumpStep,
bool[] received,
CachedValue[] values,
int pollIntervalMs = 5)
{
if (itemHandles is null)
{
throw new ArgumentNullException(nameof(itemHandles));
}
if (sinceVersions is null)
{
throw new ArgumentNullException(nameof(sinceVersions));
}
if (pumpStep is null)
{
throw new ArgumentNullException(nameof(pumpStep));
}
if (received is null)
{
throw new ArgumentNullException(nameof(received));
}
if (values is null)
{
throw new ArgumentNullException(nameof(values));
}
int count = itemHandles.Count;
if (sinceVersions.Count != count || received.Length < count || values.Length < count)
{
throw new ArgumentException("Handle, baseline, and output arrays must have matching lengths.");
}
int receivedCount = 0;
for (int index = 0; index < count; index++)
{
if (received[index])
{
receivedCount++;
}
}
while (true)
{
pumpStep();
for (int index = 0; index < count; index++)
{
if (received[index])
{
continue;
}
if (TryGet(serverHandle, itemHandles[index], out CachedValue value) && value.Version > sinceVersions[index])
{
received[index] = true;
values[index] = value;
receivedCount++;
}
}
if (receivedCount >= count)
{
return receivedCount;
}
TimeSpan remaining = deadlineUtc - DateTime.UtcNow;
if (remaining <= TimeSpan.Zero)
{
return receivedCount;
}
StaWaitHelper.WaitForSignalOrMessages(
updateSignal,
StaWaitHelper.ClampWaitMilliseconds(remaining, pollIntervalMs));
}
}
/// <summary>Returns the current version for a handle pair, or 0 if no entry exists.</summary>
/// <param name="serverHandle">MXAccess server handle.</param>
/// <param name="itemHandle">MXAccess item handle.</param>
@@ -0,0 +1,22 @@
using System;
using System.Threading;
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
/// <summary>
/// The two Win32 pump operations <see cref="StaRuntime"/> drives on its
/// STA thread. <see cref="StaMessagePump"/> is the only production
/// implementation; the seam exists so Worker.Tests can observe pump
/// interleaving and inject a pump failure without a real message queue.
/// </summary>
internal interface IStaMessagePump
{
/// <summary>Waits for a command wake event or Windows messages.</summary>
/// <param name="commandWakeEvent">Event signalled when command work is available.</param>
/// <param name="timeout">Maximum time to wait.</param>
void WaitForWorkOrMessages(WaitHandle commandWakeEvent, TimeSpan timeout);
/// <summary>Pumps and dispatches all pending Windows messages.</summary>
/// <returns>The number of messages dispatched.</returns>
int PumpPendingMessages();
}
@@ -6,7 +6,7 @@ using Microsoft.Win32.SafeHandles;
namespace ZB.MOM.WW.MxGateway.Worker.Sta;
/// <summary>Pumps Windows messages on the STA thread to allow MXAccess COM events to deliver.</summary>
public sealed class StaMessagePump
public sealed class StaMessagePump : IStaMessagePump
{
private const uint Infinite = 0xFFFFFFFF;
private const uint PmRemove = 0x0001;
@@ -8,7 +8,7 @@ namespace ZB.MOM.WW.MxGateway.Worker.Sta;
public sealed class StaRuntime : IDisposable
{
private readonly IStaComApartmentInitializer comApartmentInitializer;
private readonly StaMessagePump messagePump;
private readonly IStaMessagePump messagePump;
private readonly ConcurrentQueue<IStaWorkItem> commandQueue = new();
private readonly AutoResetEvent commandWakeEvent = new(false);
private readonly ManualResetEventSlim startedEvent = new(false);
@@ -20,6 +20,8 @@ public sealed class StaRuntime : IDisposable
private bool startRequested;
private bool shutdownRequested;
private Exception? startupException;
private Exception? loopFault;
private Action<Exception>? loopFaultHandler;
private long lastActivityUtcTicks;
private bool comInitialized;
@@ -41,6 +43,21 @@ public sealed class StaRuntime : IDisposable
IStaComApartmentInitializer comApartmentInitializer,
StaMessagePump messagePump,
TimeSpan idlePumpInterval)
: this(comApartmentInitializer, (IStaMessagePump)messagePump, idlePumpInterval)
{
}
/// <summary>
/// Initializes a new instance of <see cref="StaRuntime"/> over any
/// <see cref="IStaMessagePump"/> (test seam).
/// </summary>
/// <param name="comApartmentInitializer">COM apartment initializer.</param>
/// <param name="messagePump">Message pump for the STA thread.</param>
/// <param name="idlePumpInterval">Interval for idle message pump waits.</param>
internal StaRuntime(
IStaComApartmentInitializer comApartmentInitializer,
IStaMessagePump messagePump,
TimeSpan idlePumpInterval)
{
this.comApartmentInitializer = comApartmentInitializer
?? throw new ArgumentNullException(nameof(comApartmentInitializer));
@@ -79,6 +96,59 @@ public sealed class StaRuntime : IDisposable
/// </summary>
public bool IsRunning => startedEvent.IsSet && !stoppedEvent.IsSet;
/// <summary>
/// Gets the exception that terminated the STA loop after a successful
/// <see cref="Start"/>, or <see langword="null"/> while the loop is healthy
/// (or stopped by a requested shutdown). Startup failures are not reported
/// here — <see cref="Start"/> throws them.
/// </summary>
public Exception? LoopFault
{
get
{
lock (gate)
{
return loopFault;
}
}
}
/// <summary>
/// Registers the callback invoked once, on the dying STA thread, when the
/// STA loop terminates with an exception after a successful
/// <see cref="Start"/>. When the loop has already died the callback runs
/// immediately on the calling thread, so a fault that lands between
/// <see cref="Start"/> and registration is never lost. Only one handler is
/// kept; a later registration replaces an earlier one.
/// </summary>
/// <remarks>
/// Without this, a post-start loop death (a failed
/// <c>MsgWaitForMultipleObjectsEx</c>, or an exception escaping a dispatched
/// window message) left the worker process alive with no STA: every later
/// command queued forever and the gateway only learned of it through the
/// heartbeat watchdog, if at all.
/// </remarks>
/// <param name="handler">Callback receiving the terminating exception.</param>
public void RegisterLoopFaultHandler(Action<Exception> handler)
{
if (handler is null)
{
throw new ArgumentNullException(nameof(handler));
}
Exception? alreadyFaulted;
lock (gate)
{
loopFaultHandler = handler;
alreadyFaulted = loopFault;
}
if (alreadyFaulted is not null)
{
handler(alreadyFaulted);
}
}
/// <summary>
/// Pumps any pending Windows messages on the calling thread and refreshes
/// the STA activity timestamp. Intended for commands that synchronously
@@ -176,6 +246,14 @@ public sealed class StaRuntime : IDisposable
lock (gate)
{
if (loopFault is not null)
{
// The loop is gone; nothing would ever dequeue this item.
return Task.FromException<T>(new InvalidOperationException(
"The worker STA loop terminated unexpectedly.",
loopFault));
}
if (shutdownRequested)
{
return Task.FromException<T>(new StaRuntimeShutdownException());
@@ -244,12 +322,14 @@ public sealed class StaRuntime : IDisposable
private void ThreadMain()
{
bool started = false;
try
{
StaThreadId = Thread.CurrentThread.ManagedThreadId;
comApartmentInitializer.Initialize();
comInitialized = true;
MarkActivity();
started = true;
startedEvent.Set();
while (!IsShutdownRequested())
@@ -264,8 +344,40 @@ public sealed class StaRuntime : IDisposable
}
catch (Exception exception)
{
startupException = exception;
startedEvent.Set();
if (!started)
{
// Start() is blocked on startedEvent and rethrows this.
startupException = exception;
startedEvent.Set();
}
else
{
// Post-start loop death. Start() returned long ago, so nothing
// would ever read startupException: record the fault where
// InvokeAsync and the registered handler see it. Set under the
// gate so every item enqueued before it is still in the queue
// for the finally block's CancelQueuedCommands, and every
// InvokeAsync after it fails fast instead of queueing forever.
Action<Exception>? handler;
lock (gate)
{
loopFault = exception;
handler = loopFaultHandler;
}
if (handler is not null)
{
try
{
handler(exception);
}
catch
{
// The handler reports the fault; a failure inside it
// must not skip the apartment cleanup below.
}
}
}
}
finally
{
@@ -292,6 +404,17 @@ public sealed class StaRuntime : IDisposable
MarkActivity();
workItem.Execute();
MarkActivity();
// Pump between work items, not only once the whole queue is
// drained: a burst of queued commands (bulk callers, the alarm
// poll, a reconnecting client replaying subscriptions) would
// otherwise hold every inbound COM callback — OnDataChange,
// OnWriteComplete — behind the entire backlog. PeekMessage with an
// empty queue is cheap, so the per-item cost is negligible.
if (!commandQueue.IsEmpty)
{
messagePump.PumpPendingMessages();
}
}
}
@@ -14,10 +14,61 @@ public static class WorkerApplication
/// <returns>The process exit code.</returns>
public static int Run(string[] args)
{
WorkerConsoleLogger logger = new(Console.Error);
// WRK-09: installed only on the production entry point (not the
// injectable overloads tests call) because the hook is process-global.
AppDomain.CurrentDomain.UnhandledException += (_, eventArgs) =>
HandleUnhandledException(
logger,
eventArgs.ExceptionObject,
eventArgs.IsTerminating,
Environment.Exit);
return Run(
args,
new EnvironmentVariableWorkerEnvironment(),
new WorkerConsoleLogger(Console.Error));
logger);
}
/// <summary>
/// Body of the worker's <c>AppDomain.CurrentDomain.UnhandledException</c>
/// hook. Logs one <c>WorkerUnhandledException</c> line and exits the
/// process with <see cref="WorkerExitCode.UnhandledException"/>, so the
/// gateway's launcher sees a defined worker exit code rather than the
/// CLR's crash code.
/// </summary>
/// <remarks>
/// Logs the exception type only — never its message or stack, which
/// can carry tag values or credentials passed through a command — the
/// same rule every other worker failure line follows. A logging
/// failure is swallowed so the exit always happens.
/// </remarks>
/// <param name="logger">Worker logger.</param>
/// <param name="exceptionObject">The unhandled exception object (not necessarily an <see cref="Exception"/>).</param>
/// <param name="isTerminating">Whether the CLR is terminating the process.</param>
/// <param name="exit">Process-exit action; <see cref="Environment.Exit"/> in production.</param>
internal static void HandleUnhandledException(
IWorkerLogger logger,
object? exceptionObject,
bool isTerminating,
Action<int> exit)
{
try
{
logger.Error("WorkerUnhandledException", new Dictionary<string, object?>
{
["exit_code"] = WorkerExitCode.UnhandledException,
["exception_type"] = exceptionObject?.GetType().FullName ?? "null",
["is_terminating"] = isTerminating,
});
}
catch
{
// Never let a logging failure skip the defined exit.
}
exit((int)WorkerExitCode.UnhandledException);
}
/// <summary>Initializes and runs the worker with custom environment and logging.</summary>