using System.IO.Pipes; using Google.Protobuf.WellKnownTypes; using ZB.MOM.WW.MxGateway.Contracts; using ZB.MOM.WW.MxGateway.Contracts.Proto; using ZB.MOM.WW.MxGateway.Server.Metrics; using ZB.MOM.WW.MxGateway.Server.Workers; using ZB.MOM.WW.MxGateway.Tests.Gateway.Workers.Fakes; using ZB.MOM.WW.MxGateway.Tests.TestSupport; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Workers; public sealed class WorkerClientTests { private const string SessionId = "session-worker-client"; private const string Nonce = "nonce-worker-client"; private const int WorkerProcessId = 4321; /// Upper bound on the events writes. private const ulong EmittedEventCeiling = 2000; private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5); /// Verifies that StartAsync enters ready state after receiving worker hello and ready messages. /// A task that represents the asynchronous operation. [Fact] public async Task StartAsync_WithWorkerHelloAndReady_EntersReadyState() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient(pipePair); await CompleteHandshakeAsync(client, pipePair); Assert.Equal(WorkerClientState.Ready, client.State); Assert.Equal(WorkerProcessId, client.ProcessId); } /// /// The GatewayHello carries the negotiated worker-frame maximum so the worker adopts the /// configured limit instead of its own default; a regression to sending 0 means /// "older gateway, use default" to the worker and would silently downgrade the negotiated limit. /// The adoption half is asserted only in the Windows-only worker suite, so the gateway half is /// pinned here, in the portable suite. Both the default and an override are covered so /// the assertion tracks configuration rather than a constant. /// /// Configured worker-frame maximum to negotiate. /// A task that represents the asynchronous operation. [Theory] [InlineData(WorkerFrameProtocolOptions.DefaultMaxMessageBytes)] [InlineData(2 * 1024 * 1024)] public async Task StartAsync_SendsGatewayHelloWithConfiguredMaxFrameBytes(int maxMessageBytes) { await using FakeWorkerHarness harness = await FakeWorkerHarness.CreateConnectedPairAsync(maxMessageBytes: maxMessageBytes); await using WorkerClient client = harness.CreateClient(); Task startTask = client.StartAsync(CancellationToken.None); WorkerEnvelope gatewayHello = await harness.CompleteStartupAsync().WaitAsync(TestTimeout); await startTask.WaitAsync(TestTimeout); Assert.NotEqual(0u, gatewayHello.GatewayHello.MaxFrameBytes); Assert.Equal((uint)maxMessageBytes, gatewayHello.GatewayHello.MaxFrameBytes); } /// Verifies that InvokeAsync completes a pending command when a matching reply arrives. /// A task that represents the asynchronous operation. [Fact] public async Task InvokeAsync_WithMatchingReply_CompletesPendingCommand() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient(pipePair); await CompleteHandshakeAsync(client, pipePair); Task invokeTask = client.InvokeAsync( CreateCommand(MxCommandKind.Ping), TestTimeout, CancellationToken.None); WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase); Assert.False(string.IsNullOrWhiteSpace(commandEnvelope.CorrelationId)); await pipePair.WriteAsync( CreateCommandReplyEnvelope(commandEnvelope.CorrelationId, MxCommandKind.Ping)); WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout); Assert.Equal(commandEnvelope.CorrelationId, reply.Reply.CorrelationId); Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind); } /// /// Verifies that a command whose serialized envelope exceeds the negotiated worker-frame maximum /// fails only that command with at the enqueue /// boundary, leaving the client ready for subsequent commands. Without the pre-check the /// oversized frame would reach the write loop and fault the whole session. /// /// A task that represents the asynchronous operation. [Fact] public async Task InvokeAsync_WhenCommandExceedsFrameMax_FailsOnlyThatCommandAndStaysReady() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient(pipePair, maxMessageBytes: 4096); await CompleteHandshakeAsync(client, pipePair); WorkerCommand oversized = new() { Command = new MxCommand { Kind = MxCommandKind.Write, Write = new WriteCommand { ServerHandle = 1, ItemHandle = 2, Value = new MxValue { StringValue = new string('x', 8192) }, }, }, }; WorkerClientException exception = await Assert.ThrowsAsync( async () => await client.InvokeAsync(oversized, TestTimeout, CancellationToken.None)); Assert.Equal(WorkerClientErrorCode.CommandTooLarge, exception.ErrorCode); Assert.Equal(WorkerClientState.Ready, client.State); // A subsequent normally-sized command still round-trips: the session was not faulted. Task nextInvoke = client.InvokeAsync( CreateCommand(MxCommandKind.Ping), TestTimeout, CancellationToken.None); WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); await pipePair.WriteAsync( CreateCommandReplyEnvelope(commandEnvelope.CorrelationId, MxCommandKind.Ping)); WorkerCommandReply reply = await nextInvoke.WaitAsync(TestTimeout); Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind); Assert.Equal(WorkerClientState.Ready, client.State); } /// Verifies that InvokeAsync ignores late replies and keeps the client ready. /// A task that represents the asynchronous operation. [Fact] public async Task InvokeAsync_WithLateReply_IgnoresLateReplyAndKeepsClientReady() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient(pipePair); await CompleteHandshakeAsync(client, pipePair); Task timedOutInvokeTask = client.InvokeAsync( CreateCommand(MxCommandKind.Ping), TimeSpan.FromMilliseconds(50), CancellationToken.None); WorkerEnvelope timedOutCommand = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); WorkerClientException exception = await Assert.ThrowsAsync( async () => await timedOutInvokeTask); Assert.Equal(WorkerClientErrorCode.CommandTimeout, exception.ErrorCode); // Send the stale reply for the already-timed-out command, then the second // command's reply. The pipe is FIFO, so the read loop processes (and discards) // the stale reply before the second reply — no fixed Task.Delay needed. await pipePair.WriteAsync( CreateCommandReplyEnvelope(timedOutCommand.CorrelationId, MxCommandKind.Ping)); Task secondInvokeTask = client.InvokeAsync( CreateCommand(MxCommandKind.GetWorkerInfo), TestTimeout, CancellationToken.None); // The timeout also emits a WorkerCancel for the abandoned correlation, which sits // ahead of the second command on the FIFO pipe; skip it rather than mistaking it for the // command this assertion is about. WorkerEnvelope secondCommand = await ReadNextCommandAsync(pipePair, timedOutCommand.CorrelationId); await pipePair.WriteAsync( CreateCommandReplyEnvelope(secondCommand.CorrelationId, MxCommandKind.GetWorkerInfo)); WorkerCommandReply reply = await secondInvokeTask.WaitAsync(TestTimeout); Assert.Equal(WorkerClientState.Ready, client.State); Assert.Equal(MxCommandKind.GetWorkerInfo, reply.Reply.Kind); } /// /// A command timeout abandons the gateway-side wait, but the worker keeps the correlation on /// its single STA queue and would still run it — so the gateway forwards a WorkerCancel /// for the abandoned correlation id. Without it, a client that retries after a /// timeout stacks work the worker still intends to execute. Asserted on the wire because the /// cancel is protocol behavior the worker depends on, not an internal detail. /// /// A task that represents the asynchronous operation. [Fact] public async Task InvokeAsync_WhenCommandTimesOut_SendsWorkerCancelForThatCorrelation() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient(pipePair); await CompleteHandshakeAsync(client, pipePair); // Advise rather than a control command: control commands bypass the worker's STA queue, so a // data command is the case the cancel actually exists for. Task invokeTask = client.InvokeAsync( CreateCommand(MxCommandKind.Advise), TimeSpan.FromMilliseconds(50), CancellationToken.None); WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase); WorkerClientException exception = await Assert.ThrowsAsync( async () => await invokeTask.WaitAsync(TestTimeout)); Assert.Equal(WorkerClientErrorCode.CommandTimeout, exception.ErrorCode); WorkerEnvelope cancelEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCancel, cancelEnvelope.BodyCase); Assert.Equal(commandEnvelope.CorrelationId, cancelEnvelope.CorrelationId); Assert.False(string.IsNullOrWhiteSpace(cancelEnvelope.WorkerCancel.Reason)); Assert.True( cancelEnvelope.Sequence > commandEnvelope.Sequence, $"The cancel arrived with sequence {cancelEnvelope.Sequence} after {commandEnvelope.Sequence}; " + "envelope sequences must be strictly increasing in wire order."); // The timeout fails one command; it is not a session fault. Assert.Equal(WorkerClientState.Ready, client.State); } /// /// Caller cancellation abandons the correlation exactly as a gateway timeout does — a client /// deadline shorter than the gateway's command timeout, or a disconnect, is the common /// abandonment path — so it forwards the same WorkerCancel. Without it a /// retry storm of deadline-cancelled calls leaves the worker's single STA working through /// commands nobody is waiting for while live callers queue behind them. Twin of /// , driven /// by the caller's token instead of the timer. /// /// A task that represents the asynchronous operation. [Fact] public async Task InvokeAsync_WhenCallerCancels_SendsWorkerCancelForThatCorrelation() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient(pipePair); await CompleteHandshakeAsync(client, pipePair); using CancellationTokenSource callerCts = new(); // Advise rather than a control command: control commands bypass the worker's STA queue, so a // data command is the case the cancel actually exists for. The timeout is the full test // timeout so the cancellation, not the timer, is unambiguously what abandons the command. Task invokeTask = client.InvokeAsync( CreateCommand(MxCommandKind.Advise), TestTimeout, callerCts.Token); WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase); await callerCts.CancelAsync(); await Assert.ThrowsAnyAsync( async () => await invokeTask.WaitAsync(TestTimeout)); WorkerEnvelope cancelEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCancel, cancelEnvelope.BodyCase); Assert.Equal(commandEnvelope.CorrelationId, cancelEnvelope.CorrelationId); Assert.False(string.IsNullOrWhiteSpace(cancelEnvelope.WorkerCancel.Reason)); Assert.True( cancelEnvelope.Sequence > commandEnvelope.Sequence, $"The cancel arrived with sequence {cancelEnvelope.Sequence} after {commandEnvelope.Sequence}; " + "envelope sequences must be strictly increasing in wire order."); // One caller gave up; the session did not fault. Assert.Equal(WorkerClientState.Ready, client.State); } /// /// The cancel is forwarded for a caller that abandoned its command, never for the client's own /// teardown: once the stop token has fired the outbound channel is being completed and /// every pending command is failed anyway, so a cancel would at best be pointless and at worst /// a write racing teardown. Asserted on the wire — the guard in the cancellation arm is the /// code-level expression of it, and the completed outbound channel is the second line of /// defense, so this pins the observable contract rather than either mechanism. /// /// A task that represents the asynchronous operation. [Fact] public async Task InvokeAsync_WhenClientStopsUnderInFlightCommand_DoesNotSendWorkerCancel() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient(pipePair); await CompleteHandshakeAsync(client, pipePair); using CancellationTokenSource callerCts = new(); Task invokeTask = client.InvokeAsync( CreateCommand(MxCommandKind.Advise), TestTimeout, callerCts.Token); WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase); // Read ahead before tearing down so anything the teardown path puts on the wire is caught. // Exceptions are swallowed: the read is expected to be still pending at the assertion, and // the stream is disposed underneath it when the test ends. Task pendingRead = Task.Run(async () => { try { return await pipePair.WorkerReader.ReadAsync(); } catch (Exception) { return null; } }); // Kill fires the client's stop token and then fails the pending command; a caller token that // is wired to gateway shutdown (as the session's is) cancels in the same window. client.Kill("test teardown"); await callerCts.CancelAsync(); await Assert.ThrowsAnyAsync(async () => await invokeTask.WaitAsync(TestTimeout)); await Task.Delay(TimeSpan.FromMilliseconds(250)); if (pendingRead.IsCompleted) { WorkerEnvelope? unexpected = await pendingRead; Assert.True( unexpected is null || unexpected.BodyCase != WorkerEnvelope.BodyOneofCase.WorkerCancel, "A stopping worker client must not put a WorkerCancel on the wire."); } } /// /// A WorkerFault arriving in place of a handshake frame is the worker reporting a /// structured startup failure — most often MXAccess COM creation — not a protocol problem, so /// the gateway surfaces with the fault's /// category and diagnostic rather than a generic protocol violation naming only the body type. This is the window where the information matters most: first session open on a /// freshly deployed host, where the message reaches the operator log and the client's /// OpenFailed detail. /// /// A task that represents the asynchronous operation. [Fact] public async Task StartAsync_WhenWorkerFaultsDuringHandshake_SurfacesFaultCategoryAndDiagnostic() { const string diagnostic = "CoCreateInstance for LMXProxy.LMXProxyServer.1 failed (0x80040154)."; await using FakeWorkerHarness harness = await FakeWorkerHarness.CreateConnectedPairAsync(); await using WorkerClient client = harness.CreateClient(); Task startTask = client.StartAsync(CancellationToken.None); WorkerEnvelope gatewayHello = await harness.ReadGatewayEnvelopeAsync().WaitAsync(TestTimeout); Assert.Equal(WorkerEnvelope.BodyOneofCase.GatewayHello, gatewayHello.BodyCase); // The hello succeeds; the fault replaces the WorkerReady, which is where a COM creation // failure actually lands (the worker creates MXAccess after answering the hello). await harness.SendWorkerHelloAsync(); await harness.EmitFaultAsync(WorkerFaultCategory.MxaccessCreationFailed, diagnostic); WorkerClientException exception = await Assert.ThrowsAsync( async () => await startTask.WaitAsync(TestTimeout)); Assert.Equal(WorkerClientErrorCode.WorkerFaulted, exception.ErrorCode); Assert.Contains( WorkerFaultCategory.MxaccessCreationFailed.ToString(), exception.Message, StringComparison.Ordinal); Assert.Contains(diagnostic, exception.Message, StringComparison.Ordinal); } /// /// The envelope sequence is a monotonic per-sender counter (gateway.md), so the values /// observed on the pipe must be strictly increasing in wire order. Stamping the sequence when /// the envelope is constructed lets two concurrent invokes stamp 1 and 2 but enqueue 2 then 1; stamping on the single-consumer write loop makes wire order and sequence order the /// same thing by construction. /// /// A task that represents the asynchronous operation. [Fact] public async Task ConcurrentInvokesEmitStrictlyIncreasingSequencesOnTheWire() { const int commandCount = 32; await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient(pipePair); await CompleteHandshakeAsync(client, pipePair); Task[] invokeTasks = Enumerable.Range(0, commandCount) .Select(_ => Task.Run(async () => await client.InvokeAsync( CreateCommand(MxCommandKind.Ping), TestTimeout, CancellationToken.None))) .ToArray(); ulong previousSequence = 0; for (int index = 0; index < commandCount; index++) { WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase); Assert.True( commandEnvelope.Sequence > previousSequence, $"Command {index} arrived with sequence {commandEnvelope.Sequence} after {previousSequence}; " + "envelope sequences must be strictly increasing in wire order."); previousSequence = commandEnvelope.Sequence; await pipePair.WriteAsync( CreateCommandReplyEnvelope(commandEnvelope.CorrelationId, MxCommandKind.Ping)); } await Task.WhenAll(invokeTasks).WaitAsync(TestTimeout); Assert.Equal(WorkerClientState.Ready, client.State); } /// Verifies that ReadEventsAsync yields events in pipe order from the worker. /// A task that represents the asynchronous operation. [Fact] public async Task ReadEventsAsync_WithWorkerEvents_YieldsEventsInPipeOrder() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient(pipePair); await CompleteHandshakeAsync(client, pipePair); using CancellationTokenSource cancellationTokenSource = new(TestTimeout); await using IAsyncEnumerator events = client.ReadEventsAsync(cancellationTokenSource.Token).GetAsyncEnumerator(cancellationTokenSource.Token); await pipePair.WriteAsync( CreateEventEnvelope(sequence: 11, MxEventFamily.OnDataChange)); await pipePair.WriteAsync( CreateEventEnvelope(sequence: 12, MxEventFamily.OperationComplete)); Assert.True(await events.MoveNextAsync()); Assert.Equal((ulong)11, events.Current.Event.WorkerSequence); Assert.Equal(MxEventFamily.OnDataChange, events.Current.Event.Family); Assert.True(await events.MoveNextAsync()); Assert.Equal((ulong)12, events.Current.Event.WorkerSequence); Assert.Equal(MxEventFamily.OperationComplete, events.Current.Event.Family); } /// /// The worker event channel is single-reader: a second /// enumerator must throw rather than silently split events between two consumers. /// /// A task that represents the asynchronous operation. [Fact] public async Task ReadEventsAsync_SecondEnumerator_Throws() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient(pipePair); await CompleteHandshakeAsync(client, pipePair); using CancellationTokenSource cancellationTokenSource = new(TestTimeout); // First call claims the single reader. IAsyncEnumerable first = client.ReadEventsAsync(cancellationTokenSource.Token); await using IAsyncEnumerator firstEnumerator = first.GetAsyncEnumerator(cancellationTokenSource.Token); // A second call must fail loudly at call time rather than racing the first for events. Assert.Throws(() => client.ReadEventsAsync(cancellationTokenSource.Token)); } /// Verifies that the read loop faults the client when the event queue overflows. /// A task that represents the asynchronous operation. [Fact] public async Task ReadLoop_WhenEventQueueOverflows_FaultsClient() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient( pipePair, new WorkerClientOptions { EventChannelCapacity = 1, EventChannelFullModeTimeout = TimeSpan.FromMilliseconds(50), HeartbeatGrace = TimeSpan.FromSeconds(30), HeartbeatCheckInterval = TimeSpan.FromSeconds(30), }); await CompleteHandshakeAsync(client, pipePair); await pipePair.WriteAsync( CreateEventEnvelope(sequence: 11, MxEventFamily.OnDataChange)); await pipePair.WriteAsync( CreateEventEnvelope(sequence: 12, MxEventFamily.OnDataChange)); await WaitUntilAsync( () => client.State == WorkerClientState.Faulted, TestTimeout); Assert.Equal(WorkerClientState.Faulted, client.State); } /// /// Verifies that a command reply arriving on the pipe after events is dispatched promptly even /// when the event channel is full and has no consumer — event enqueue is decoupled from the read /// loop, so a blocked event writer cannot delay a reply. The event full-mode timeout is /// set far above the command timeout: without the decoupling the read loop would block behind the /// full event channel and the in-flight InvokeAsync would hit CommandTimeout. /// /// A task that represents the asynchronous operation. [Fact] public async Task ReadLoop_WhenEventChannelFull_DispatchesCommandReplyWithoutBlocking() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient( pipePair, new WorkerClientOptions { EventChannelCapacity = 1, // Much larger than TestTimeout: the read loop must not block for this long behind the // full event channel while a reply waits. EventChannelFullModeTimeout = TimeSpan.FromSeconds(30), HeartbeatGrace = TimeSpan.FromSeconds(60), HeartbeatCheckInterval = TimeSpan.FromSeconds(60), }); await CompleteHandshakeAsync(client, pipePair); // No StreamEvents consumer is attached, so the capacity-1 event channel fills and the event // writer blocks on the second event's timed WriteAsync. await pipePair.WriteAsync( CreateEventEnvelope(sequence: 11, MxEventFamily.OnDataChange)); await pipePair.WriteAsync( CreateEventEnvelope(sequence: 12, MxEventFamily.OnDataChange)); Task invokeTask = client.InvokeAsync( CreateCommand(MxCommandKind.Ping), TestTimeout, CancellationToken.None); WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); await pipePair.WriteAsync( CreateCommandReplyEnvelope(commandEnvelope.CorrelationId, MxCommandKind.Ping)); WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout); Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind); Assert.Equal(WorkerClientState.Ready, client.State); } /// /// Verifies that when the client faults it kills the owned worker process. /// The assertion waits on , which /// completes exactly when Kill runs, instead of polling client.State. /// Polling state is racy: publishes the /// Faulted state before it calls KillOwnedProcess, so a state-based /// wait can observe Faulted while KillCount is still 0. /// /// A task that represents the asynchronous operation. [Fact] public async Task ReadLoop_WhenClientFaults_KillsOwnedWorkerProcess() { await using PipePair pipePair = await PipePair.CreateAsync(); FakeWorkerProcess process = new(WorkerProcessId); await using WorkerClient client = CreateClient( pipePair, new WorkerClientOptions { EventChannelCapacity = 1, EventChannelFullModeTimeout = TimeSpan.FromMilliseconds(50), HeartbeatGrace = TimeSpan.FromSeconds(30), HeartbeatCheckInterval = TimeSpan.FromSeconds(30), }, processHandle: CreateProcessHandle(process)); await CompleteHandshakeAsync(client, pipePair); await pipePair.WriteAsync( CreateEventEnvelope(sequence: 11, MxEventFamily.OnDataChange)); await pipePair.WriteAsync( CreateEventEnvelope(sequence: 12, MxEventFamily.OnDataChange)); // Deterministic: this completes the instant Kill() runs, with no timing window. using CancellationTokenSource exitTimeout = new(TestTimeout); await process.WaitForExitAsync(exitTimeout.Token); Assert.Equal(WorkerClientState.Faulted, client.State); Assert.Equal(1, process.KillCount); Assert.True(process.KillEntireProcessTree); Assert.True(process.HasExited); } /// /// Verifies that a worker faulting mid-command — the pipe dropping while an /// is still pending — completes the pending /// invoke task with a carrying the /// pipe-disconnected error code rather than hanging until the command timeout. /// /// A task that represents the asynchronous operation. [Fact] public async Task InvokeAsync_WhenPipeDisconnectsMidCommand_FailsPendingInvokeWithPipeDisconnected() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient(pipePair); await CompleteHandshakeAsync(client, pipePair); Task invokeTask = client.InvokeAsync( CreateCommand(MxCommandKind.Ping), TestTimeout, CancellationToken.None); // The worker received the command but disconnects before replying. WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase); await pipePair.DisposeWorkerSideAsync(); WorkerClientException exception = await Assert.ThrowsAsync( async () => await invokeTask.WaitAsync(TestTimeout)); Assert.Equal(WorkerClientErrorCode.PipeDisconnected, exception.ErrorCode); await WaitUntilAsync(() => client.State == WorkerClientState.Faulted, TestTimeout); Assert.Equal(WorkerClientState.Faulted, client.State); } /// /// Verifies that a worker emitting a WorkerFault envelope while an /// is pending completes the pending invoke /// task with a carrying the worker-faulted /// error code. /// /// A task that represents the asynchronous operation. [Fact] public async Task InvokeAsync_WhenWorkerFaultsMidCommand_FailsPendingInvokeWithWorkerFaulted() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient(pipePair); await CompleteHandshakeAsync(client, pipePair); Task invokeTask = client.InvokeAsync( CreateCommand(MxCommandKind.Ping), TestTimeout, CancellationToken.None); WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase); await pipePair.WriteAsync(CreateWorkerFaultEnvelope("scripted mid-command fault")); WorkerClientException exception = await Assert.ThrowsAsync( async () => await invokeTask.WaitAsync(TestTimeout)); Assert.Equal(WorkerClientErrorCode.WorkerFaulted, exception.ErrorCode); await WaitUntilAsync(() => client.State == WorkerClientState.Faulted, TestTimeout); Assert.Equal(WorkerClientState.Faulted, client.State); } /// Verifies that pipe disconnect faults the client. /// A task that represents the asynchronous operation. [Fact] public async Task ReadLoop_WhenPipeDisconnects_FaultsClient() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient(pipePair); await CompleteHandshakeAsync(client, pipePair); await pipePair.DisposeWorkerSideAsync(); await WaitUntilAsync( () => client.State == WorkerClientState.Faulted, TestTimeout); Assert.Equal(WorkerClientState.Faulted, client.State); } /// Verifies that the read loop stops the running worker metric when the pipe disconnects. /// A task that represents the asynchronous operation. [Fact] public async Task ReadLoop_WhenPipeDisconnects_StopsRunningWorkerMetric() { await using PipePair pipePair = await PipePair.CreateAsync(); using GatewayMetrics metrics = new(); await using WorkerClient client = CreateClient(pipePair, metrics: metrics); await CompleteHandshakeAsync(client, pipePair); Assert.Equal(1, metrics.GetSnapshot().WorkersRunning); await pipePair.DisposeWorkerSideAsync(); await WaitUntilAsync( () => client.State == WorkerClientState.Faulted && metrics.GetSnapshot().WorkersRunning == 0, TestTimeout); GatewayMetricsSnapshot snapshot = metrics.GetSnapshot(); Assert.Equal(0, snapshot.WorkersRunning); Assert.Equal(1, snapshot.WorkerExits); } /// Verifies that DisposeAsync returns within a bounded timeout when the pipe read is blocked. /// A task that represents the asynchronous operation. [Fact] public async Task DisposeAsync_WhenPipeReadIsBlocked_ReturnsWithinBoundedTimeout() { await using PipePair pipePair = await PipePair.CreateAsync(); WorkerClient client = CreateClient(pipePair); await CompleteHandshakeAsync(client, pipePair); DateTimeOffset startedAt = DateTimeOffset.UtcNow; await client.DisposeAsync().AsTask().WaitAsync(TestTimeout); TimeSpan elapsed = DateTimeOffset.UtcNow - startedAt; Assert.True( elapsed < TimeSpan.FromSeconds(4), $"DisposeAsync took {elapsed.TotalMilliseconds:N0}ms."); } /// Verifies that DisposeAsync kills the still-running owned worker process before disposing. /// A task that represents the asynchronous operation. [Fact] public async Task DisposeAsync_WhenOwnedWorkerStillRuns_KillsProcessBeforeDisposing() { await using PipePair pipePair = await PipePair.CreateAsync(); FakeWorkerProcess process = new(WorkerProcessId); WorkerClient client = CreateClient(pipePair, processHandle: CreateProcessHandle(process)); await client.DisposeAsync().AsTask().WaitAsync(TestTimeout); Assert.Equal(1, process.KillCount); Assert.True(process.KillEntireProcessTree); Assert.True(process.Disposed); } /// /// Disposing a still-Ready client while the worker is emitting events must be /// an ordinary teardown, not a fault. DisposeAsync completes the same three channels /// MarkClosed and SetFaulted do, so it must enter a terminal state first; /// otherwise the read loop — already inside DispatchEnvelope when the staging channel /// is completed, or reading a stream that is torn down underneath it — misdiagnoses the /// completed channel as the sustained-backpressure overflow and raises a spurious /// ProtocolViolation fault, a worker-event-staging overflow metric and a /// redundant kill during an intentional dispose. The event channel is sized far above the /// burst so a genuine overflow is impossible, and DisposeAsync awaits its background /// tasks, so the metrics read afterwards is not racing a late fault. /// /// A task that represents the asynchronous operation. [Fact] public async Task DisposeAsync_WhileWorkerIsEmittingEvents_ClosesWithoutFaultingOrOverflowing() { // The burst is capped at EmittedEventCeiling and the staging channel holds 2 x capacity, so // even if every emitted event were staged and none consumed the staging bound is unreachable: // an overflow metric in this test can only be the dispose-race misdiagnosis. const int capacity = 2048; using GatewayMetrics metrics = new(); await using PipePair pipePair = await PipePair.CreateAsync(); WorkerClient client = CreateClient( pipePair, new WorkerClientOptions { EventChannelCapacity = capacity, EventChannelFullModeTimeout = TimeSpan.FromMinutes(5), HeartbeatGrace = TimeSpan.FromSeconds(30), HeartbeatCheckInterval = TimeSpan.FromSeconds(30), }, metrics: metrics); await CompleteHandshakeAsync(client, pipePair); using CancellationTokenSource emitCts = new(TestTimeout); Task emitTask = EmitEventsUntilStoppedAsync(pipePair, emitCts.Token); // Proves the read loop is mid-stream — not parked before its first event — when dispose runs. await WaitUntilAsync(() => metrics.GetSnapshot().WorkerEventQueueDepth > 0, TestTimeout); await client.DisposeAsync().AsTask().WaitAsync(TestTimeout); await emitCts.CancelAsync(); await emitTask.WaitAsync(TestTimeout); Assert.Equal(WorkerClientState.Closed, client.State); GatewayMetricsSnapshot snapshot = metrics.GetSnapshot(); Assert.Equal(0L, snapshot.Faults); Assert.Equal(0L, snapshot.QueueOverflows); Assert.Equal(0L, snapshot.WorkerKills); // Disposal is now a terminal transition, so the worker that reached Ready is recorded as // stopped exactly once — the running-worker gauge no longer leaks on this path. Assert.Equal(0, snapshot.WorkersRunning); Assert.Equal(1L, snapshot.WorkerExits); } /// /// Writes worker events from the fake worker side until cancelled or until /// events have been written, so the gateway read loop has a /// continuous stream to dispatch while a test tears the client down. The ceiling keeps the /// burst structurally below the staging bound the caller configures. Every failure is /// swallowed: disposal closes the gateway end of the pipe underneath this writer, which is /// the situation under test rather than a test failure. /// /// The connected pipe pair whose worker side emits the events. /// Stops the emission. /// A task that represents the asynchronous operation. private static async Task EmitEventsUntilStoppedAsync( PipePair pipePair, CancellationToken cancellationToken) { await Task.Yield(); try { for (ulong sequence = 1; sequence <= EmittedEventCeiling && !cancellationToken.IsCancellationRequested; sequence++) { await pipePair.WorkerWriter.WriteAsync( CreateEventEnvelope(sequence, MxEventFamily.OnDataChange), cancellationToken); } } catch (Exception) { // Broken pipe, disposed stream or cancellation — all expected once dispose starts. } } /// /// Verifies that a heartbeat envelope updates the last-heartbeat timestamp and worker /// process id. Uses a so the timestamp advance is /// deterministic instead of relying on a wall-clock Task.Delay exceeding /// resolution. /// /// A task that represents the asynchronous operation. [Fact] public async Task ReadLoop_WhenHeartbeatArrives_UpdatesLastHeartbeatAndWorkerProcess() { ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-18T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture)); await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient(pipePair, timeProvider: clock); await CompleteHandshakeAsync(client, pipePair); DateTimeOffset previousHeartbeat = client.LastHeartbeatAt; clock.Advance(TimeSpan.FromSeconds(1)); await pipePair.WriteAsync(CreateHeartbeatEnvelope(workerProcessId: 9876)); await WaitUntilAsync( () => client.ProcessId == 9876 && client.LastHeartbeatAt > previousHeartbeat, TestTimeout); Assert.Equal(WorkerClientState.Ready, client.State); Assert.Equal(previousHeartbeat + TimeSpan.FromSeconds(1), client.LastHeartbeatAt); } /// /// Verifies that the heartbeat monitor faults the client when the heartbeat expires. /// Uses an injected so the grace comparison is deterministic /// instead of depending on real wall-clock advance; the monitor's /// timer stays on the real clock and /// observes the manually-advanced grace on its next tick. /// /// A task that represents the asynchronous operation. [Fact] public async Task HeartbeatMonitor_WhenHeartbeatExpires_FaultsClient() { ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-20T12:00:00Z", System.Globalization.CultureInfo.InvariantCulture)); await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient( pipePair, new WorkerClientOptions { HeartbeatGrace = TimeSpan.FromMilliseconds(80), HeartbeatCheckInterval = TimeSpan.FromMilliseconds(20), EventChannelCapacity = 8, }, timeProvider: clock); await CompleteHandshakeAsync(client, pipePair); clock.Advance(TimeSpan.FromSeconds(2)); await WaitUntilAsync( () => client.State == WorkerClientState.Faulted, TestTimeout); Assert.Equal(WorkerClientState.Faulted, client.State); } /// /// While a command is in flight on the /// gateway↔worker pipe and the oldest pending command is younger /// than , the /// heartbeat watchdog must NOT fault on heartbeat-expired alone — the /// gap is more likely caused by pipe-write contention than by a hung /// worker. /// /// A task that represents the asynchronous operation. [Fact] public async Task HeartbeatMonitor_WhenCommandInFlightWithinCeiling_DoesNotFaultOnExpiredHeartbeat() { ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-20T13:00:00Z", System.Globalization.CultureInfo.InvariantCulture)); await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient( pipePair, new WorkerClientOptions { HeartbeatGrace = TimeSpan.FromMilliseconds(80), HeartbeatCheckInterval = TimeSpan.FromMilliseconds(20), EventChannelCapacity = 8, HeartbeatStuckCeiling = TimeSpan.FromSeconds(30), }, timeProvider: clock); await CompleteHandshakeAsync(client, pipePair); // Begin a command that the test never replies to — keeps the // PendingCommand alive in `_pendingCommands` for the duration. Task pendingInvoke = client.InvokeAsync( CreateCommand(MxCommandKind.Ping), TestTimeout, CancellationToken.None); WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCommand, commandEnvelope.BodyCase); // Advance well past HeartbeatGrace but well within HeartbeatStuckCeiling. clock.Advance(TimeSpan.FromSeconds(2)); // Give the heartbeat monitor a few real check-intervals to observe the gap. await Task.Delay(TimeSpan.FromMilliseconds(150)); Assert.Equal(WorkerClientState.Ready, client.State); Assert.False(pendingInvoke.IsCompleted); } /// /// Once the oldest pending command exceeds /// , the /// heartbeat watchdog fires anyway — a truly stuck COM call shouldn't /// keep the watchdog suppressed indefinitely. /// /// A task that represents the asynchronous operation. [Fact] public async Task HeartbeatMonitor_WhenPendingCommandExceedsStuckCeiling_FaultsClient() { ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-05-20T13:00:00Z", System.Globalization.CultureInfo.InvariantCulture)); await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient( pipePair, new WorkerClientOptions { HeartbeatGrace = TimeSpan.FromMilliseconds(80), HeartbeatCheckInterval = TimeSpan.FromMilliseconds(20), EventChannelCapacity = 8, HeartbeatStuckCeiling = TimeSpan.FromMilliseconds(200), }, timeProvider: clock); await CompleteHandshakeAsync(client, pipePair); Task pendingInvoke = client.InvokeAsync( CreateCommand(MxCommandKind.Ping), TestTimeout, CancellationToken.None); await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); // Advance the clock past HeartbeatStuckCeiling. The worker pipe's // PendingCommand.StartTimestamp uses TimeProvider.GetTimestamp(), so the // ManualTimeProvider's GetElapsedTime sees the advanced gap. clock.Advance(TimeSpan.FromSeconds(2)); await WaitUntilAsync( () => client.State == WorkerClientState.Faulted, TestTimeout); Assert.Equal(WorkerClientState.Faulted, client.State); } /// /// A transient burst that exceeds /// must be /// absorbed for up to /// (the channel is configured for BoundedChannelFullMode.Wait); /// only when the wait elapses without progress is the worker faulted, /// and the diagnostic must name the channel capacity, depth, and /// actionable remediation. /// /// A task that represents the asynchronous operation. [Fact] public async Task EnqueueWorkerEvent_WhenChannelFullPastTimeout_FaultsWithRichDiagnostic() { await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient( pipePair, new WorkerClientOptions { EventChannelCapacity = 4, EventChannelFullModeTimeout = TimeSpan.FromMilliseconds(100), HeartbeatGrace = TimeSpan.FromSeconds(30), HeartbeatCheckInterval = TimeSpan.FromSeconds(1), }); await CompleteHandshakeAsync(client, pipePair); // Fill the 4-slot channel and write exactly one more to force the // overflow path. The gateway never opens a StreamEvents consumer, so // the events stay buffered. Exactly five events are written: the // worker client faults while reading the fifth, after which its read // loop stops — a sixth event would never be drained and its pipe // write would block forever on a full OS pipe buffer. for (ulong sequence = 1; sequence <= 5; sequence++) { await pipePair.WriteAsync( CreateEventEnvelope(sequence: sequence, MxEventFamily.OnDataChange)); } await WaitUntilAsync( () => client.State == WorkerClientState.Faulted, TestTimeout); Assert.Equal(WorkerClientState.Faulted, client.State); // Reading the events channel after fault throws the propagated // WorkerClientException carrying the rich diagnostic message. The // drain is bounded by TestTimeout so a regression that leaves the // channel uncompleted fails the test instead of hanging it. using CancellationTokenSource drainTimeout = new(TestTimeout); WorkerClientException fault = await Assert.ThrowsAsync(async () => { await foreach (WorkerEvent _ in client.ReadEventsAsync(drainTimeout.Token)) { } }); Assert.Contains("Worker event channel rejected", fault.Message); Assert.Contains("of 4 capacity", fault.Message); Assert.Contains("StreamEvents", fault.Message); Assert.Contains("MxGateway:Events:QueueCapacity", fault.Message); } /// /// The staging channel between the read loop and the event writer is bounded at /// 2 × , so a consumer that drains /// slower than the worker produces faults the client at the bound instead of growing /// gateway memory silently. The full-mode timeout here is far longer than the test could /// ever wait, which proves the fault came from the staging bound and not from the timed /// write in EnqueueWorkerEventAsync. A command reply interleaved before the fault /// must still complete: the read loop never blocks behind events (the read-loop guarantee). /// /// A task that represents the asynchronous operation. [Fact] public async Task StagingChannelOverflowFaultsWorkerWithoutWaitingForFullModeTimeout() { const int capacity = 4; const int stagingBound = 2 * capacity; using GatewayMetrics metrics = new(); await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient( pipePair, new WorkerClientOptions { EventChannelCapacity = capacity, // Five minutes: the timed-write fault physically cannot be the trigger. EventChannelFullModeTimeout = TimeSpan.FromMinutes(5), HeartbeatGrace = TimeSpan.FromSeconds(30), HeartbeatCheckInterval = TimeSpan.FromSeconds(30), }, metrics: metrics); await CompleteHandshakeAsync(client, pipePair); // The staging channel alone holds 2 × capacity, and the event writer only ever removes // from it, so this batch cannot fault however the writer happens to be scheduled. Waiting // on the gauge (rather than a delay) proves the read loop consumed every one of them. ulong sequence = 1; for (; sequence <= (ulong)stagingBound; sequence++) { await pipePair.WriteAsync( CreateEventEnvelope(sequence, MxEventFamily.OnDataChange)); } await WaitUntilAsync( () => metrics.GetSnapshot().WorkerEventQueueDepth == stagingBound, TestTimeout); Assert.Equal(WorkerClientState.Ready, client.State); // The event path is backed up with no consumer attached, yet a command still round-trips. Task invokeTask = client.InvokeAsync( CreateCommand(MxCommandKind.Ping), TestTimeout, CancellationToken.None); WorkerEnvelope commandEnvelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); await pipePair.WriteAsync( CreateCommandReplyEnvelope(commandEnvelope.CorrelationId, MxCommandKind.Ping)); WorkerCommandReply reply = await invokeTask.WaitAsync(TestTimeout); Assert.Equal(MxCommandKind.Ping, reply.Reply.Kind); Assert.Equal(WorkerClientState.Ready, client.State); // The absolute ceiling is capacity (consumer channel) + 1 (in flight in the blocked event // writer) + 2 × capacity (staging). Push comfortably past it. The events are tiny, so the // ones the stopped read loop never drains stay in the OS pipe buffer instead of blocking. for (int extra = 0; extra < 3 * capacity; extra++, sequence++) { await pipePair.WriteAsync( CreateEventEnvelope(sequence, MxEventFamily.OnDataChange)); } await WaitUntilAsync(() => client.State == WorkerClientState.Faulted, TestTimeout); Assert.Equal(WorkerClientState.Faulted, client.State); using CancellationTokenSource drainTimeout = new(TestTimeout); WorkerClientException fault = await Assert.ThrowsAsync(async () => { await foreach (WorkerEvent _ in client.ReadEventsAsync(drainTimeout.Token)) { } }); Assert.Equal(WorkerClientErrorCode.ProtocolViolation, fault.ErrorCode); Assert.Contains("staging", fault.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains($"{2 * capacity}", fault.Message, StringComparison.Ordinal); Assert.Contains("StreamEvents", fault.Message, StringComparison.Ordinal); Assert.Contains("MxGateway:Events:QueueCapacity", fault.Message, StringComparison.Ordinal); // The timed-write diagnostic must not be what fired. Assert.DoesNotContain("Worker event channel rejected", fault.Message, StringComparison.Ordinal); } /// /// The worker event queue-depth gauge counts staged *and* queued events, so a /// backlog held in the staging channel is visible rather than invisible. Depth is /// incremented at staging and decremented when the consumer reads, so the single counter /// reports total undelivered events and returns to zero once drained. /// /// A task that represents the asynchronous operation. [Fact] public async Task WorkerEventQueueDepthGaugeCountsStagedEvents() { const int capacity = 4; const int eventCount = 8; using GatewayMetrics metrics = new(); await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient( pipePair, new WorkerClientOptions { EventChannelCapacity = capacity, EventChannelFullModeTimeout = TimeSpan.FromMinutes(5), HeartbeatGrace = TimeSpan.FromSeconds(30), HeartbeatCheckInterval = TimeSpan.FromSeconds(30), }, metrics: metrics); await CompleteHandshakeAsync(client, pipePair); // Above EventChannelCapacity but below the 2× staging bound: no consumer, no fault. for (ulong sequence = 1; sequence <= eventCount; sequence++) { await pipePair.WriteAsync( CreateEventEnvelope(sequence, MxEventFamily.OnDataChange)); } await WaitUntilAsync( () => metrics.GetSnapshot().WorkerEventQueueDepth == eventCount, TestTimeout); Assert.Equal(eventCount, metrics.GetSnapshot().WorkerEventQueueDepth); Assert.Equal(WorkerClientState.Ready, client.State); using CancellationTokenSource drainTimeout = new(TestTimeout); await using IAsyncEnumerator events = client .ReadEventsAsync(drainTimeout.Token) .GetAsyncEnumerator(drainTimeout.Token); for (int read = 0; read < eventCount; read++) { Assert.True(await events.MoveNextAsync()); } await WaitUntilAsync( () => metrics.GetSnapshot().WorkerEventQueueDepth == 0, TestTimeout); Assert.Equal(0, metrics.GetSnapshot().WorkerEventQueueDepth); } /// /// A faulted client can remain retained for diagnostics, but its abandoned event /// backlog must stop contributing to the gateway-wide queue-depth gauge as soon as the /// client enters a terminal state rather than waiting for disposal. /// /// A task that represents the asynchronous operation. [Fact] public async Task WorkerEventQueueDepthGauge_WhenClientFaults_StopsCountingRetainedClient() { using GatewayMetrics metrics = new(); await using PipePair pipePair = await PipePair.CreateAsync(); await using WorkerClient client = CreateClient( pipePair, new WorkerClientOptions { EventChannelCapacity = 4, EventChannelFullModeTimeout = TimeSpan.FromMinutes(5), HeartbeatGrace = TimeSpan.FromSeconds(30), HeartbeatCheckInterval = TimeSpan.FromSeconds(30), }, metrics: metrics); await CompleteHandshakeAsync(client, pipePair); await pipePair.WriteAsync(CreateEventEnvelope(sequence: 1, MxEventFamily.OnDataChange)); await WaitUntilAsync( () => metrics.GetSnapshot().WorkerEventQueueDepth == 1, TestTimeout); await pipePair.WriteAsync(CreateWorkerFaultEnvelope("scripted retained-client fault")); await WaitUntilAsync(() => client.State == WorkerClientState.Faulted, TestTimeout); Assert.Equal(WorkerClientState.Faulted, client.State); Assert.Equal(0, metrics.GetSnapshot().WorkerEventQueueDepth); } private static WorkerClient CreateClient( PipePair pipePair, WorkerClientOptions? options = null, GatewayMetrics? metrics = null, WorkerProcessHandle? processHandle = null, TimeProvider? timeProvider = null, int maxMessageBytes = WorkerFrameProtocolOptions.DefaultMaxMessageBytes) { WorkerFrameProtocolOptions frameOptions = new( SessionId, GatewayContractInfo.WorkerProtocolVersion, maxMessageBytes); WorkerClientConnection connection = new( SessionId, Nonce, pipePair.GatewayStream, frameOptions, processHandle); return new WorkerClient(connection, options, metrics, timeProvider); } private static WorkerProcessHandle CreateProcessHandle(FakeWorkerProcess process) { return new WorkerProcessHandle( process, new WorkerProcessCommandLine("ZB.MOM.WW.MxGateway.Worker.exe", []), DateTimeOffset.UtcNow); } private static async Task CompleteHandshakeAsync( WorkerClient client, PipePair pipePair) { Task startTask = client.StartAsync(CancellationToken.None); WorkerEnvelope gatewayHello = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); Assert.Equal(WorkerEnvelope.BodyOneofCase.GatewayHello, gatewayHello.BodyCase); Assert.Equal(Nonce, gatewayHello.GatewayHello.Nonce); Assert.Equal(GatewayContractInfo.WorkerProtocolVersion, gatewayHello.GatewayHello.SupportedProtocolVersion); await pipePair.WriteAsync(CreateWorkerHelloEnvelope()); await pipePair.WriteAsync(CreateWorkerReadyEnvelope()); await startTask.WaitAsync(TestTimeout); } private static WorkerCommand CreateCommand(MxCommandKind kind) { return new WorkerCommand { Command = new MxCommand { Kind = kind, }, }; } private static WorkerEnvelope CreateWorkerHelloEnvelope() { return CreateWorkerEnvelope( correlationId: string.Empty, sequence: 1, envelope => envelope.WorkerHello = new WorkerHello { ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, Nonce = Nonce, WorkerProcessId = WorkerProcessId, WorkerVersion = "fake-worker", }); } private static WorkerEnvelope CreateWorkerReadyEnvelope() { return CreateWorkerEnvelope( correlationId: string.Empty, sequence: 2, envelope => envelope.WorkerReady = new WorkerReady { WorkerProcessId = WorkerProcessId, MxaccessProgid = "LMXProxy.LMXProxyServer.1", MxaccessClsid = "{C30B52F5-2CB5-4760-AF0A-3A344A7EB5DC}", }); } private static WorkerEnvelope CreateCommandReplyEnvelope( string correlationId, MxCommandKind kind) { return CreateWorkerEnvelope( correlationId, sequence: 10, envelope => envelope.WorkerCommandReply = new WorkerCommandReply { Reply = new MxCommandReply { SessionId = SessionId, CorrelationId = correlationId, Kind = kind, }, }); } private static WorkerEnvelope CreateEventEnvelope( ulong sequence, MxEventFamily family) { return CreateWorkerEnvelope( correlationId: string.Empty, sequence, envelope => envelope.WorkerEvent = new WorkerEvent { Event = new MxEvent { SessionId = SessionId, Family = family, WorkerSequence = sequence, }, }); } private static WorkerEnvelope CreateWorkerFaultEnvelope(string diagnosticMessage) { return CreateWorkerEnvelope( correlationId: string.Empty, sequence: 30, envelope => envelope.WorkerFault = new WorkerFault { Category = WorkerFaultCategory.MxaccessCommandFailed, DiagnosticMessage = diagnosticMessage, ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.WorkerUnavailable, Message = diagnosticMessage, }, }); } private static WorkerEnvelope CreateHeartbeatEnvelope(int workerProcessId) { return CreateWorkerEnvelope( correlationId: string.Empty, sequence: 20, envelope => envelope.WorkerHeartbeat = new WorkerHeartbeat { WorkerProcessId = workerProcessId, State = WorkerState.Ready, LastStaActivityTimestamp = Timestamp.FromDateTimeOffset(DateTimeOffset.UtcNow), PendingCommandCount = 0, OutboundEventQueueDepth = 0, }); } private static WorkerEnvelope CreateWorkerEnvelope( string correlationId, ulong sequence, Action setBody) { WorkerEnvelope envelope = new() { ProtocolVersion = GatewayContractInfo.WorkerProtocolVersion, SessionId = SessionId, Sequence = sequence, CorrelationId = correlationId, }; setBody(envelope); return envelope; } /// /// Reads gateway envelopes until a WorkerCommand arrives, skipping the WorkerCancel /// a command timeout emits for . Anything else on the pipe /// fails the test rather than being skipped: the point of the skip is to tolerate exactly the one /// known interleaving, not to make the assertion blind to unexpected gateway traffic. /// /// The connected pipe pair whose worker side is read. /// Correlation id of the timed-out command whose cancel is expected. /// The next command envelope written by the gateway. private static async Task ReadNextCommandAsync( PipePair pipePair, string canceledCorrelationId) { while (true) { WorkerEnvelope envelope = await pipePair.WorkerReader.ReadAsync().AsTask().WaitAsync(TestTimeout); if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerCommand) { return envelope; } Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCancel, envelope.BodyCase); Assert.Equal(canceledCorrelationId, envelope.CorrelationId); } } private static async Task WaitUntilAsync( Func predicate, TimeSpan timeout) { using CancellationTokenSource cancellationTokenSource = new(timeout); while (!predicate()) { await Task.Delay(TimeSpan.FromMilliseconds(10), cancellationTokenSource.Token); } } private sealed class PipePair : IAsyncDisposable { private readonly NamedPipeClientStream _workerStream; private bool _workerSideDisposed; private PipePair( NamedPipeServerStream gatewayStream, NamedPipeClientStream workerStream) { GatewayStream = gatewayStream; _workerStream = workerStream; WorkerReader = new WorkerFrameReader(_workerStream, new WorkerFrameProtocolOptions(SessionId)); WorkerWriter = new WorkerFrameWriter(_workerStream, new WorkerFrameProtocolOptions(SessionId)); } /// The gateway side of the named pipe connection. public NamedPipeServerStream GatewayStream { get; } /// Frame reader for worker messages. public WorkerFrameReader WorkerReader { get; } /// Frame writer for worker messages. public WorkerFrameWriter WorkerWriter { get; } /// /// Writes one envelope from the fake worker side, bounded by . /// /// /// Every test-side write goes through here rather than directly so /// that a write which cannot complete fails this test instead of hanging it. An unbounded /// write is not a local problem: a test method that never returns leaves xUnit's assembly /// runner awaiting it forever, so ITestAssemblyFinished is never raised and the whole /// testhost process never exits — the run reports no failure at all, just a wedge. /// /// The envelope to write. /// A task that represents the asynchronous operation. public async Task WriteAsync(WorkerEnvelope envelope) { using CancellationTokenSource writeTimeout = new(TestTimeout); try { await WorkerWriter.WriteAsync(envelope, writeTimeout.Token); } catch (OperationCanceledException) when (writeTimeout.IsCancellationRequested) { Assert.Fail( $"The fake worker's pipe write did not complete within {TestTimeout}. The gateway " + "side has stopped reading and the pipe buffer is full."); } } /// Creates a connected pipe pair for testing. /// The connected . public static async Task CreateAsync() { string pipeName = $"mxgw-wc-{Guid.NewGuid():N}"; NamedPipeServerStream gatewayStream = TestNamedPipe.CreateServer(pipeName); NamedPipeClientStream workerStream = new( ".", pipeName, PipeDirection.InOut, PipeOptions.Asynchronous); Task waitForConnectionTask = gatewayStream.WaitForConnectionAsync(); await workerStream.ConnectAsync(); await waitForConnectionTask; return new PipePair(gatewayStream, workerStream); } /// Disposes the worker side of the pipe. /// A task that represents the asynchronous operation. public async ValueTask DisposeWorkerSideAsync() { if (_workerSideDisposed) { return; } await _workerStream.DisposeAsync(); _workerSideDisposed = true; } /// Disposes the duplex stream. /// A task that represents the asynchronous operation. public async ValueTask DisposeAsync() { await DisposeWorkerSideAsync(); await GatewayStream.DisposeAsync(); } } }