diff --git a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs index 0044abc..41cf7bf 100644 --- a/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs +++ b/src/ZB.MOM.WW.MxGateway.Server/Workers/WorkerClient.cs @@ -950,6 +950,21 @@ public sealed class WorkerClient : IWorkerClient CancellationToken cancellationToken) { WorkerEnvelope envelope = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false); + + // IPC-35: a fault frame during the handshake is the worker reporting a structured startup + // failure — most often MXAccess COM creation (not installed, not registered, access denied) + // — not a protocol problem. Flattening it to ProtocolViolation discarded the category and + // diagnostic exactly where they matter most: first session open on a freshly deployed host, + // where the message reaches the operator log and the client's OpenFailed detail. Use the + // same formatter the steady-state fault path uses so both windows read alike. + if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerFault + && expectedBody != WorkerEnvelope.BodyOneofCase.WorkerFault) + { + throw new WorkerClientException( + WorkerClientErrorCode.WorkerFaulted, + CreateWorkerFaultMessage(envelope.WorkerFault)); + } + if (envelope.BodyCase != expectedBody) { throw new WorkerClientException( diff --git a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs index eaab7e0..ad3761b 100644 --- a/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs +++ b/src/ZB.MOM.WW.MxGateway.Tests/Gateway/Workers/WorkerClientTests.cs @@ -322,6 +322,45 @@ public sealed class WorkerClientTests } } + /// + /// 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 + /// (IPC-35). 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