feat(worker): correlate OnWriteComplete onto plain Write/Write2 replies (06/S-1 follow-up)
OtOpcUa's dominant FreeAccess write path goes out as MX_COMMAND_KIND_WRITE, not WriteSecured — the original 06/S-1 brief mis-scoped the correlation, so a refused plain write was invisible on the unary reply (verified live on windev 2026-08-09). ExecuteWrite/ExecuteWrite2 now use the same pre-call version baseline + bounded pump-wait as the secured kinds. Bulk writes stay fire-and-forget.
This commit is contained in:
@@ -851,6 +851,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(CreateWriteCommand(
|
||||
@@ -874,6 +877,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 Write forwarding test above.
|
||||
session.WriteCompletionTimeout = TimeSpan.Zero;
|
||||
await session.StartAsync(workerProcessId: 1234);
|
||||
DateTime timestamp = new(2026, 5, 19, 12, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
@@ -952,7 +957,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
FakeMxAccessComObject fakeComObject = new(registerHandle: 82);
|
||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||
CompletionCacheEventSink sink = new();
|
||||
fakeComObject.OnWriteSecuredCallback = () =>
|
||||
fakeComObject.OnWriteCallback = () =>
|
||||
sink.WriteCompletionCache.Record(82, 820, CreateCompletionRows(detail: 4321));
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, sink);
|
||||
@@ -989,7 +994,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
// baseline is committed and a Record from the test thread is
|
||||
// guaranteed to be "newer" — no fixed sleep racing the STA thread.
|
||||
using System.Threading.ManualResetEventSlim comCallReached = new(initialState: false);
|
||||
fakeComObject.OnWriteSecuredCallback = () => comCallReached.Set();
|
||||
fakeComObject.OnWriteCallback = () => comCallReached.Set();
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, sink);
|
||||
session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
|
||||
@@ -1068,7 +1073,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
FakeMxAccessComObject fakeComObject = new(registerHandle: 86);
|
||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||
CompletionCacheEventSink sink = new();
|
||||
fakeComObject.OnWriteSecuredCallback = () =>
|
||||
fakeComObject.OnWriteCallback = () =>
|
||||
sink.WriteCompletionCache.Record(86, 860, CreateCompletionRows(detail: 2222));
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, sink);
|
||||
@@ -1085,25 +1090,110 @@ public sealed class MxAccessCommandExecutorTests
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// Verifies plain Write correlates the same way as WriteSecured
|
||||
/// (fast-completion edge): OtOpcUa's dominant FreeAccess path goes out
|
||||
/// as MX_COMMAND_KIND_WRITE, so its reply must carry the OnWriteComplete
|
||||
/// rows too.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_Write_DoesNotWaitForCompletion()
|
||||
public async Task DispatchAsync_Write_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
|
||||
{
|
||||
FakeMxAccessComObject fakeComObject = new(registerHandle: 87);
|
||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||
CompletionCacheEventSink sink = new();
|
||||
fakeComObject.OnWriteCallback = () =>
|
||||
sink.WriteCompletionCache.Record(87, 870, CreateCompletionRows(detail: 5555));
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, sink);
|
||||
// Hermetic: don't inherit MXGATEWAY_WORKER_WRITE_COMPLETION_WAIT_MS
|
||||
// from the test runner's environment.
|
||||
session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
|
||||
await session.StartAsync(workerProcessId: 1234);
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(CreateWriteCommand(
|
||||
"plain-write-fast", serverHandle: 87, itemHandle: 870, value: 1, userId: 5));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.True(reply.HasHresult);
|
||||
Assert.Equal(0, reply.Hresult);
|
||||
MxStatusProxy row = Assert.Single(reply.Statuses);
|
||||
Assert.Equal(5555, row.Detail);
|
||||
Assert.Equal(MxStatusCategory.Ok, row.Category);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies the plain-Write timeout fallback mirrors the secured one:
|
||||
/// no completion within the bounded wait returns protocol OK with EMPTY
|
||||
/// statuses — unconfirmed, never a synthesized failure row.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_Write_WhenNoCompletion_TimesOutWithEmptyStatusesAndOkProtocol()
|
||||
{
|
||||
FakeMxAccessComObject fakeComObject = new(registerHandle: 88);
|
||||
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(CreateWriteCommand(
|
||||
"plain-write-timeout", serverHandle: 88, itemHandle: 880, value: 1, userId: 5));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.True(reply.HasHresult);
|
||||
Assert.Equal(0, reply.Hresult);
|
||||
Assert.Empty(reply.Statuses);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies Write2 correlates the same way as Write (fast-completion
|
||||
/// edge).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_Write2_WhenCompletionArrivesDuringComCall_ReturnsStatuses()
|
||||
{
|
||||
FakeMxAccessComObject fakeComObject = new(registerHandle: 89);
|
||||
FakeMxAccessComObjectFactory factory = new(fakeComObject);
|
||||
CompletionCacheEventSink sink = new();
|
||||
fakeComObject.OnWriteCallback = () =>
|
||||
sink.WriteCompletionCache.Record(89, 890, CreateCompletionRows(detail: 6666));
|
||||
using StaRuntime runtime = CreateRuntime();
|
||||
using MxAccessStaSession session = new(runtime, factory, sink);
|
||||
session.WriteCompletionTimeout = TimeSpan.FromSeconds(10);
|
||||
await session.StartAsync(workerProcessId: 1234);
|
||||
|
||||
MxCommandReply reply = await session.DispatchAsync(CreateWrite2Command(
|
||||
"plain-write2-fast", serverHandle: 89, itemHandle: 890, value: 1,
|
||||
timestamp: new DateTime(2026, 8, 9, 12, 0, 0, DateTimeKind.Utc), userId: 6));
|
||||
|
||||
Assert.Equal(ProtocolStatusCode.Ok, reply.ProtocolStatus.Code);
|
||||
Assert.Equal(6666, Assert.Single(reply.Statuses).Detail);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies WriteBulk stays fire-and-forget: even with a huge completion
|
||||
/// timeout configured and no completion source, the reply returns
|
||||
/// immediately with per-entry results — bulk writes never enter the
|
||||
/// bounded wait (latency for high-rate loops).
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task DispatchAsync_WriteBulk_DoesNotWaitForCompletion()
|
||||
{
|
||||
FakeMxAccessComObject fakeComObject = new(registerHandle: 90);
|
||||
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<MxCommandReply> pending = session.DispatchAsync(CreateWriteCommand(
|
||||
"plain-write-no-wait", serverHandle: 87, itemHandle: 870, value: 1, userId: 5));
|
||||
Task<MxCommandReply> pending = session.DispatchAsync(CreateWriteBulkCommand(
|
||||
"bulk-write-no-wait", serverHandle: 90, entries: new[] { (itemHandle: 900, value: 1, userId: 5) }));
|
||||
Task completed = await Task.WhenAny(pending, Task.Delay(TimeSpan.FromSeconds(5)));
|
||||
|
||||
Assert.Same(pending, completed);
|
||||
@@ -2004,12 +2094,13 @@ public sealed class MxAccessCommandExecutorTests
|
||||
private readonly List<string> operationNames = new();
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// Invoked at the end of a successful Write/Write2/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 on every correlated write kind.
|
||||
/// </summary>
|
||||
public Action? OnWriteSecuredCallback { get; set; }
|
||||
public Action? OnWriteCallback { get; set; }
|
||||
|
||||
/// <summary>Initializes a fake MXAccess COM object with the given handles and optional exceptions.</summary>
|
||||
/// <param name="registerHandle">Return value for Register method.</param>
|
||||
@@ -2285,6 +2376,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
WriteUserId = userId;
|
||||
WriteThreadId = Environment.CurrentManagedThreadId;
|
||||
ThrowIfWriteFailureConfigured(itemHandle);
|
||||
OnWriteCallback?.Invoke();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -2303,6 +2395,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
WriteUserId = userId;
|
||||
WriteThreadId = Environment.CurrentManagedThreadId;
|
||||
ThrowIfWriteFailureConfigured(itemHandle);
|
||||
OnWriteCallback?.Invoke();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -2321,7 +2414,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
WriteValue = value;
|
||||
WriteThreadId = Environment.CurrentManagedThreadId;
|
||||
ThrowIfWriteFailureConfigured(itemHandle);
|
||||
OnWriteSecuredCallback?.Invoke();
|
||||
OnWriteCallback?.Invoke();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -2342,7 +2435,7 @@ public sealed class MxAccessCommandExecutorTests
|
||||
WriteTimestamp = timestamp;
|
||||
WriteThreadId = Environment.CurrentManagedThreadId;
|
||||
ThrowIfWriteFailureConfigured(itemHandle);
|
||||
OnWriteSecuredCallback?.Invoke();
|
||||
OnWriteCallback?.Invoke();
|
||||
}
|
||||
|
||||
private void ThrowIfWriteFailureConfigured(int itemHandle)
|
||||
|
||||
Reference in New Issue
Block a user