Retain fake worker fault diagnostics in test fixtures
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m20s
ci / java (push) Successful in 2m19s
ci / windows-x86 (pull_request) Successful in 1m20s
ci / nightly-windev (pull_request) Has been skipped
ci / java (pull_request) Successful in 2m23s
ci / portable (push) Successful in 9m10s
ci / portable (pull_request) Successful in 9m2s

This commit is contained in:
Joseph Doherty
2026-10-13 10:35:28 -04:00
parent d779daf6af
commit 83aa76c33d
5 changed files with 340 additions and 26 deletions
@@ -1,8 +1,10 @@
using System.Collections.Concurrent;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Time.Testing;
using Xunit.Abstractions;
using ZB.MOM.WW.MxGateway.Contracts;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Configuration;
@@ -36,7 +38,7 @@ namespace ZB.MOM.WW.MxGateway.Tests.Gateway;
/// per session and survives detach, so events emitted while no subscriber is attached are
/// retained for the reconnecting stream.
/// </remarks>
public sealed class GatewayEndToEndReconnectReplayTests
public sealed class GatewayEndToEndReconnectReplayTests(ITestOutputHelper output)
{
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(10);
private const int ServerHandle = 3001;
@@ -58,7 +60,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
GatedEventFakeWorkerProcessLauncher launcher = new();
// Capacity 16 retains every event emitted here (7 total), so nothing is evicted and a
// resume from a middle cursor never produces a gap.
await using ReconnectReplayGatewayServiceFixture fixture = new(launcher, replayBufferCapacity: 16);
await using ReconnectReplayGatewayServiceFixture fixture = new(launcher, replayBufferCapacity: 16, diagnosticOutput: output.WriteLine);
string sessionId = await OpenSessionAsync(fixture, "reconnect-no-gap");
@@ -157,7 +159,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
GatedEventFakeWorkerProcessLauncher launcher = new();
// Small capacity forces eviction: with 6 events emitted and a 3-slot ring, the first 3
// are evicted and only the newest 3 remain replayable.
await using ReconnectReplayGatewayServiceFixture fixture = new(launcher, replayBufferCapacity: capacity);
await using ReconnectReplayGatewayServiceFixture fixture = new(launcher, replayBufferCapacity: capacity, diagnosticOutput: output.WriteLine);
string sessionId = await OpenSessionAsync(fixture, "reconnect-gap");
@@ -256,7 +258,8 @@ public sealed class GatewayEndToEndReconnectReplayTests
launcher,
replayBufferCapacity: 16,
replayRetentionSeconds: retentionSeconds,
timeProvider: clock);
timeProvider: clock,
diagnosticOutput: output.WriteLine);
string sessionId = await OpenSessionAsync(fixture, "reconnect-aged-out");
@@ -340,6 +343,34 @@ public sealed class GatewayEndToEndReconnectReplayTests
$"Live event {delivered.WorkerSequence} must be newer than the highest previously seen {highestSeen}.");
}
/// <summary>Setup failures preserve gateway EOF and report the original fake worker fault safely.</summary>
/// <param name="faultCommand">The setup command where the fake worker fails.</param>
/// <returns>The asynchronous test operation.</returns>
[Theory]
[InlineData(MxCommandKind.Register)]
[InlineData(MxCommandKind.AddItem)]
public async Task Setup_WhenFakeWorkerFaults_ReportsFaultWithoutMaskingRpcFailure(MxCommandKind faultCommand)
{
ConcurrentQueue<string> output = new();
GatedEventFakeWorkerProcessLauncher launcher = new(faultCommand);
Exception? failure = await Record.ExceptionAsync(async () =>
{
await using ReconnectReplayGatewayServiceFixture fixture = new(
launcher, replayBufferCapacity: 16, diagnosticOutput: output.Enqueue);
string sessionId = await OpenSessionAsync(fixture, "injected-fault");
await WireUpAdviseAsync(fixture, sessionId);
});
RpcException rpc = Assert.IsType<RpcException>(failure);
Assert.Equal(StatusCode.Unavailable, rpc.StatusCode);
Assert.Contains("Worker pipe disconnected", rpc.Status.Detail);
Assert.Contains(output, line => line.Contains("source=fake-worker")
&& line.Contains("type=WorkerFrameProtocolException") && line.Contains("code=InvalidEnvelope")
&& line.Contains($"command={faultCommand}") && line.Contains("phase=command"));
Assert.Contains(output, line => line.Contains("source=gateway") && line.Contains("code=EndOfStream"));
Assert.DoesNotContain(output, line => line.Contains("secret-item") || line.Contains("Galaxy.Tag.Value"));
}
// ---- shared flow helpers ----
private static async Task<string> OpenSessionAsync(
@@ -490,6 +521,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
{
private readonly GatewayMetrics _metrics = new();
private readonly SessionRegistry _registry = new();
private readonly FakeWorkerDiagnostics _diagnostics;
/// <summary>Initializes a new instance of the <see cref="ReconnectReplayGatewayServiceFixture"/> class.</summary>
/// <param name="launcher">Fake worker process launcher backing the session manager.</param>
@@ -499,6 +531,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
/// duration of a fast test; the age-eviction test shortens it and drives
/// <paramref name="timeProvider"/>.
/// </param>
/// <param name="diagnosticOutput">Safe metadata output sink.</param>
/// <param name="timeProvider">
/// Clock handed to the session manager, which flows through to the session's event
/// distributor. Pass a fake to make age-eviction deterministic.
@@ -507,15 +540,18 @@ public sealed class GatewayEndToEndReconnectReplayTests
IWorkerProcessLauncher launcher,
int replayBufferCapacity,
double replayRetentionSeconds = 300,
TimeProvider? timeProvider = null)
TimeProvider? timeProvider = null,
Action<string>? diagnosticOutput = null)
{
_diagnostics = ((IDiagnosticFakeWorker)launcher).Diagnostics;
_diagnostics.SetOutput(diagnosticOutput);
IOptions<GatewayOptions> options = Options.Create(
CreateOptions(replayBufferCapacity, replayRetentionSeconds));
SessionWorkerClientFactory workerClientFactory = new(
launcher,
options,
_metrics,
NullLoggerFactory.Instance);
_diagnostics);
SessionManager sessionManager = new(
_registry,
workerClientFactory,
@@ -585,13 +621,20 @@ public sealed class GatewayEndToEndReconnectReplayTests
/// <summary>Disposes every session in the registry and releases the fixture's metrics.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
public async ValueTask DisposeAsync()
{
// Session disposal and worker observation share the existing test budget.
// The observer consumes a late cleanup fault without replacing the primary failure.
_diagnostics.Track(DisposeSessionsAsync(), "teardown");
await _diagnostics.DrainAsync(TestTimeout).ConfigureAwait(false);
_metrics.Dispose();
}
private async Task DisposeSessionsAsync()
{
foreach (GatewaySession session in _registry.Snapshot())
{
await session.DisposeAsync().ConfigureAwait(false);
}
_metrics.Dispose();
}
private static GatewayOptions CreateOptions(int replayBufferCapacity, double replayRetentionSeconds) =>
@@ -642,7 +685,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
/// <see cref="StopEmitting"/> before closing the session so the loop exits cleanly and can
/// process the shutdown envelope.
/// </summary>
private sealed class GatedEventFakeWorkerProcessLauncher : IWorkerProcessLauncher
private sealed class GatedEventFakeWorkerProcessLauncher(MxCommandKind? faultCommand = null) : IWorkerProcessLauncher, IDiagnosticFakeWorker
{
public const int ProcessId = 7730;
@@ -655,6 +698,9 @@ public sealed class GatewayEndToEndReconnectReplayTests
/// <summary>Gets the task representing the fake worker's running background loop.</summary>
public Task WorkerTask { get; private set; } = Task.CompletedTask;
/// <inheritdoc />
public FakeWorkerDiagnostics Diagnostics { get; } = new();
/// <summary>Releases the gate so the worker emits one event.</summary>
public void AllowNextEvent() => _emitGate.Release();
@@ -674,6 +720,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
CancellationToken cancellationToken = default)
{
WorkerTask = RunWorkerAsync(request, cancellationToken);
Diagnostics.Track(WorkerTask);
return Task.FromResult(new WorkerProcessHandle(
_process,
@@ -729,6 +776,7 @@ public sealed class GatewayEndToEndReconnectReplayTests
continue;
}
Diagnostics.Received(envelope);
if (envelope.BodyCase == WorkerEnvelope.BodyOneofCase.WorkerShutdown)
{
await harness.SendShutdownAckAsync(cancellationToken: cancellationToken).ConfigureAwait(false);
@@ -742,6 +790,11 @@ public sealed class GatewayEndToEndReconnectReplayTests
}
MxCommand command = envelope.WorkerCommand.Command;
if (command.Kind == faultCommand)
{
throw new WorkerFrameProtocolException(WorkerFrameProtocolErrorCode.InvalidEnvelope, "secret-item");
}
await harness.ReplyToCommandAsync(
envelope,
configureReply: reply => ConfigureCommandReply(reply, command.Kind),