diff --git a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs
index 2b4b0aa..16e53c9 100644
--- a/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs
+++ b/src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/MxAccessCommandExecutorTests.cs
@@ -897,6 +897,9 @@ public sealed class MxAccessCommandExecutorTests
FakeMxAccessComObjectFactory factory = new(fakeComObject);
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
+ // No completion source in this test — disable the bounded reply wait
+ // so the forwarding assertions don't pay the default 1.5 s timeout.
+ session.WriteCompletionTimeout = TimeSpan.Zero;
await session.StartAsync(workerProcessId: 1234);
MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
@@ -919,6 +922,8 @@ public sealed class MxAccessCommandExecutorTests
FakeMxAccessComObjectFactory factory = new(fakeComObject);
using StaRuntime runtime = CreateRuntime();
using MxAccessStaSession session = new(runtime, factory, new NoopEventSink());
+ // Same rationale as the WriteSecured forwarding test above.
+ session.WriteCompletionTimeout = TimeSpan.Zero;
await session.StartAsync(workerProcessId: 1234);
DateTime timestamp = new(2026, 5, 19, 13, 30, 0, DateTimeKind.Utc);
@@ -934,6 +939,181 @@ public sealed class MxAccessCommandExecutorTests
Assert.Equal(44, fakeComObject.WriteVerifierUserId);
}
+ ///
+ /// Verifies the fast-completion ordering edge: a completion recorded while
+ /// the WriteSecured COM call is still on the stack (MXAccess committing
+ /// synchronously) is newer than the pre-call baseline and lands on the
+ /// reply — the wait never misses a callback that beat it.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task DispatchAsync_WriteSecured_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
+ {
+ FakeMxAccessComObject fakeComObject = new(registerHandle: 82);
+ FakeMxAccessComObjectFactory factory = new(fakeComObject);
+ CompletionCacheEventSink sink = new();
+ fakeComObject.OnWriteSecuredCallback = () =>
+ sink.WriteCompletionCache.Record(82, 820, CreateCompletionRows(detail: 4321));
+ using StaRuntime runtime = CreateRuntime();
+ using MxAccessStaSession session = new(runtime, factory, sink);
+ await session.StartAsync(workerProcessId: 1234);
+
+ MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
+ "write-secured-fast", serverHandle: 82, itemHandle: 820, value: 1, currentUserId: 11, verifierUserId: 22));
+
+ Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
+ Assert.True(reply.HasHresult);
+ Assert.Equal(0, reply.Hresult);
+ MxStatusProxy row = Assert.Single(reply.Statuses);
+ Assert.Equal(4321, row.Detail);
+ Assert.Equal(MxStatusCategory.Ok, row.Category);
+ }
+
+ ///
+ /// Verifies the pump-wait path: the completion arrives after the COM call
+ /// returned, while the executor is pump-waiting, and still lands on the
+ /// reply.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task DispatchAsync_WriteSecured_WhenCompletionArrivesWhileWaiting_ReturnsStatuses()
+ {
+ FakeMxAccessComObject fakeComObject = new(registerHandle: 83);
+ FakeMxAccessComObjectFactory factory = new(fakeComObject);
+ CompletionCacheEventSink sink = new();
+ using StaRuntime runtime = CreateRuntime();
+ using MxAccessStaSession session = new(runtime, factory, sink);
+ session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
+ await session.StartAsync(workerProcessId: 1234);
+
+ Task pending = session.DispatchAsync(CreateWriteSecuredCommand(
+ "write-secured-waiting", serverHandle: 83, itemHandle: 830, value: 1, currentUserId: 11, verifierUserId: 22));
+ await Task.Delay(50);
+ sink.WriteCompletionCache.Record(83, 830, CreateCompletionRows(detail: 99));
+
+ MxCommandReply reply = await pending;
+
+ Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
+ Assert.Equal(99, Assert.Single(reply.Statuses).Detail);
+ }
+
+ ///
+ /// Verifies the timeout fallback: no completion within the bounded wait
+ /// returns today's reply shape — protocol OK with EMPTY statuses (the
+ /// consumer's honest-unconfirmed path), never a synthesized failure row.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task DispatchAsync_WriteSecured_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol()
+ {
+ FakeMxAccessComObject fakeComObject = new(registerHandle: 84);
+ FakeMxAccessComObjectFactory factory = new(fakeComObject);
+ CompletionCacheEventSink sink = new();
+ using StaRuntime runtime = CreateRuntime();
+ using MxAccessStaSession session = new(runtime, factory, sink);
+ session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100);
+ await session.StartAsync(workerProcessId: 1234);
+
+ MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
+ "write-secured-timeout", serverHandle: 84, itemHandle: 840, value: 1, currentUserId: 11, verifierUserId: 22));
+
+ Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
+ Assert.True(reply.HasHresult);
+ Assert.Equal(0, reply.Hresult);
+ Assert.Empty(reply.Statuses);
+ }
+
+ ///
+ /// Verifies the version-baseline rule end to end: a completion recorded
+ /// BEFORE the write was dispatched is stale and must not be misattributed
+ /// to this write — the reply times out empty instead.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task DispatchAsync_WriteSecured_IgnoresStaleCompletionFromBeforeTheCall()
+ {
+ FakeMxAccessComObject fakeComObject = new(registerHandle: 85);
+ FakeMxAccessComObjectFactory factory = new(fakeComObject);
+ CompletionCacheEventSink sink = new();
+ sink.WriteCompletionCache.Record(85, 850, CreateCompletionRows(detail: 1111));
+ using StaRuntime runtime = CreateRuntime();
+ using MxAccessStaSession session = new(runtime, factory, sink);
+ session.WriteCompletionTimeout = TimeSpan.FromMilliseconds(100);
+ await session.StartAsync(workerProcessId: 1234);
+
+ MxCommandReply reply = await session.DispatchAsync(CreateWriteSecuredCommand(
+ "write-secured-stale", serverHandle: 85, itemHandle: 850, value: 1, currentUserId: 11, verifierUserId: 22));
+
+ Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
+ Assert.Empty(reply.Statuses);
+ }
+
+ ///
+ /// Verifies that WriteSecured2 correlates the same way as WriteSecured
+ /// (fast-completion edge).
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task DispatchAsync_WriteSecured2_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
+ {
+ FakeMxAccessComObject fakeComObject = new(registerHandle: 86);
+ FakeMxAccessComObjectFactory factory = new(fakeComObject);
+ CompletionCacheEventSink sink = new();
+ fakeComObject.OnWriteSecuredCallback = () =>
+ sink.WriteCompletionCache.Record(86, 860, CreateCompletionRows(detail: 2222));
+ using StaRuntime runtime = CreateRuntime();
+ using MxAccessStaSession session = new(runtime, factory, sink);
+ await session.StartAsync(workerProcessId: 1234);
+
+ MxCommandReply reply = await session.DispatchAsync(CreateWriteSecured2Command(
+ "write-secured2-fast", serverHandle: 86, itemHandle: 860, value: 1,
+ timestamp: new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc), currentUserId: 33, verifierUserId: 44));
+
+ Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
+ Assert.Equal(2222, Assert.Single(reply.Statuses).Detail);
+ }
+
+ ///
+ /// Verifies plain Write stays fire-and-forget: even with a huge completion
+ /// timeout configured and no completion source, the reply returns
+ /// immediately (guarded well under the configured wait) with empty
+ /// statuses — only the secured write kinds enter the bounded wait.
+ ///
+ /// A task that represents the asynchronous operation.
+ [Fact]
+ public async Task DispatchAsync_Write_DoesNotWaitForCompletion()
+ {
+ FakeMxAccessComObject fakeComObject = new(registerHandle: 87);
+ FakeMxAccessComObjectFactory factory = new(fakeComObject);
+ CompletionCacheEventSink sink = new();
+ using StaRuntime runtime = CreateRuntime();
+ using MxAccessStaSession session = new(runtime, factory, sink);
+ session.WriteCompletionTimeout = TimeSpan.FromSeconds(30);
+ await session.StartAsync(workerProcessId: 1234);
+
+ Task pending = session.DispatchAsync(CreateWriteCommand(
+ "plain-write-no-wait", serverHandle: 87, itemHandle: 870, value: 1, userId: 5));
+ Task completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(5)));
+
+ Assert.Same(pending, completed);
+ MxCommandReply reply = await pending;
+ Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
+ Assert.Empty(reply.Statuses);
+ }
+
+ private static Google.Protobuf.Collections.RepeatedField CreateCompletionRows(int detail)
+ {
+ return new Google.Protobuf.Collections.RepeatedField
+ {
+ new MxStatusProxy
+ {
+ Success = 1,
+ Category = MxStatusCategory.Ok,
+ Detail = detail,
+ },
+ };
+ }
+
/// Verifies that Write without a payload returns an invalid request error.
/// A task that represents the asynchronous operation.
[Fact]
@@ -1775,6 +1955,28 @@ public sealed class MxAccessCommandExecutorTests
TimeSpan.FromMilliseconds(25));
}
+ ///
+ /// Test sink that owns a real write-completion cache without touching the
+ /// MXAccess COM RCW (Attach is a no-op). Implements the provider seam so
+ /// shares this cache with the write
+ /// executor, letting tests record completions the executor's bounded
+ /// wait then observes.
+ ///
+ private sealed class CompletionCacheEventSink : IMxAccessEventSink, IWriteCompletionCacheProvider
+ {
+ public MxAccessWriteCompletionCache WriteCompletionCache { get; } = new MxAccessWriteCompletionCache();
+
+ public void Attach(
+ object mxAccessComObject,
+ string sessionId)
+ {
+ }
+
+ public void Detach()
+ {
+ }
+ }
+
private sealed class FakeMxAccessComObject : IMxAccessServer
{
private readonly int registerHandle;
@@ -1790,6 +1992,14 @@ public sealed class MxAccessCommandExecutorTests
private readonly IReadOnlyDictionary writeExceptionByItemHandle;
private readonly List operationNames = new();
+ ///
+ /// Invoked at the end of a successful WriteSecured/WriteSecured2 —
+ /// stands in for MXAccess committing synchronously and delivering
+ /// OnWriteComplete while the COM call is still on the stack, so
+ /// tests can exercise the fast-completion ordering edge.
+ ///
+ public Action? OnWriteSecuredCallback { get; set; }
+
/// Initializes a fake MXAccess COM object with the given handles and optional exceptions.
/// Return value for Register method.
/// Return value for AddItem method.
@@ -2100,6 +2310,7 @@ public sealed class MxAccessCommandExecutorTests
WriteValue = value;
WriteThreadId = Environment.CurrentManagedThreadId;
ThrowIfWriteFailureConfigured(itemHandle);
+ OnWriteSecuredCallback?.Invoke();
}
///
@@ -2120,6 +2331,7 @@ public sealed class MxAccessCommandExecutorTests
WriteTimestamp = timestamp;
WriteThreadId = Environment.CurrentManagedThreadId;
ThrowIfWriteFailureConfigured(itemHandle);
+ OnWriteSecuredCallback?.Invoke();
}
private void ThrowIfWriteFailureConfigured(int itemHandle)