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