From ab41362a5c936eff3c4d816939ab357ab7764cf8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 20 Aug 2026 09:32:01 -0400 Subject: [PATCH] fix(IPC-35): surface a handshake-phase WorkerFault with its category and diagnostic The worker reports startup failures as structured WorkerFault frames -- MxAccessCreationException carries the COM HRESULT/ProgID context and a WorkerFaultCategory -- so the gateway can tell "MXAccess is not installed / not registered / access denied" apart from a pipe-level protocol problem. ReadHandshakeEnvelopeAsync did not look inside: any body other than the expected one became a ProtocolViolation naming only the body type, and that text is what SessionManager puts in the operator log and the client's OpenFailed detail. The information was lost exactly in the window where it matters most -- first session open on a freshly deployed host, where the answer is almost always an MXAccess installation or permission problem. Special-case BodyCase == WorkerFault and throw WorkerClientException( WorkerFaulted, CreateWorkerFaultMessage(...)) -- the same formatter the steady-state fault path already uses, so both windows read alike. Every other unexpected body still gets ProtocolViolation. Test: StartAsync_WhenWorkerFaultsDuringHandshake_SurfacesFaultCategoryAndDiagnostic has the fake worker answer the hello and then write a WorkerFault {MxaccessCreationFailed} in place of WorkerReady; the existing FakeWorkerHarness.EmitFaultAsync covered it, so the harness needed no change. --- .../Workers/WorkerClient.cs | 15 +++++++ .../Gateway/Workers/WorkerClientTests.cs | 39 +++++++++++++++++++ 2 files changed, 54 insertions(+) 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