Merge branch 'fix/gwc-26-27-alarm-attach'
ci / nightly-windev (push) Has been skipped
ci / windows-x86 (push) Successful in 1m17s
ci / java (push) Successful in 2m4s
ci / portable (push) Successful in 7m8s

# Conflicts:
#	archreview/2026-07-12/remediation/00-tracking.md
#	archreview/2026-07-12/remediation/10-gateway-core.md
This commit is contained in:
Joseph Doherty
2026-08-07 06:02:17 -04:00
19 changed files with 738 additions and 130 deletions
@@ -352,11 +352,6 @@ public sealed class GatewaySessionDashboardMirrorTests
string sessionId,
CancellationToken cancellationToken) => session.ReadEventsAsync(cancellationToken);
/// <inheritdoc />
public IAsyncEnumerable<MxEvent> ReadAlarmEventsAsync(
string sessionId,
CancellationToken cancellationToken) => throw new NotSupportedException();
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
@@ -668,6 +668,81 @@ public sealed class GatewaySessionTests
Assert.Equal(SessionState.Ready, session.State);
}
/// <summary>
/// GWC-27: <see cref="GatewaySession.AttachInternalEventSubscriber"/> must refuse to
/// attach before the session is Ready. Without the gate the attach would construct and
/// start the distributor against a not-yet-Ready worker; the pump source throws
/// <c>SessionNotReady</c>, every subscriber is completed with that error, and the
/// distributor latches — leaving a session that reaches Ready with permanently dead
/// event streaming. The second half of the test is the load-bearing one: after the
/// failed attach the session still streams live events, proving the distributor was
/// never created or started.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AttachInternalEventSubscriberBeforeReadyThrowsAndDoesNotPoisonDistributor()
{
FakeWorkerClient workerClient = new();
workerClient.Events.Add(new WorkerEvent
{
Event = new MxEvent { Family = MxEventFamily.OnDataChange, WorkerSequence = 1, OnDataChange = new OnDataChangeEvent() },
});
workerClient.Events.Add(new WorkerEvent
{
Event = new MxEvent { Family = MxEventFamily.OnDataChange, WorkerSequence = 2, OnDataChange = new OnDataChangeEvent() },
});
// Constructed but neither worker-attached nor Ready — the premature-attach case.
await using GatewaySession session = CreateSession();
SessionManagerException exception = Assert.Throws<SessionManagerException>(
() => session.AttachInternalEventSubscriber());
Assert.Equal(SessionManagerErrorCode.SessionNotReady, exception.ErrorCode);
// Drive the session to Ready and stream: the failed attach must not have poisoned
// (or even created) the distributor, so a normal subscriber still receives events.
session.AttachWorkerClient(workerClient);
session.MarkReady();
using IEventSubscriberLease lease = session.AttachEventSubscriber(maxSubscribers: 1);
List<MxEvent> received = [];
using CancellationTokenSource readCts = new(TimeSpan.FromSeconds(5));
await foreach (MxEvent mxEvent in lease.Reader.ReadAllAsync(readCts.Token))
{
received.Add(mxEvent);
if (received.Count == 2)
{
break;
}
}
Assert.Equal([1UL, 2UL], received.Select(mxEvent => mxEvent.WorkerSequence).ToArray());
}
private static GatewaySession CreateSession()
{
return new GatewaySession(
sessionId: "session-test-internal-attach",
backendName: "mxaccess",
pipeName: "mxaccess-gateway-1-session-test-internal-attach",
nonce: "nonce",
clientIdentity: "client-1",
ownerKeyId: null,
clientSessionName: "test-session",
clientCorrelationId: "client-correlation-1",
commandTimeout: TimeSpan.FromSeconds(5),
startupTimeout: TimeSpan.FromSeconds(5),
shutdownTimeout: TimeSpan.FromSeconds(5),
leaseDuration: TimeSpan.FromMinutes(30),
openedAt: DateTimeOffset.UtcNow,
eventStreaming: new SessionEventStreaming(
new MxAccessGrpcMapper(),
new EventOptions { QueueCapacity = 8 },
NullLogger<SessionEventDistributor>.Instance,
TimeProvider.System,
new GatewayMetrics()));
}
private static GatewaySession CreateReadySessionWithDetachGrace(
IWorkerClient workerClient,
TimeProvider timeProvider,
@@ -855,6 +930,9 @@ public sealed class GatewaySessionTests
/// <summary>Gets the count of dispose invocations.</summary>
public int DisposeCount { get; private set; }
/// <summary>Events <see cref="ReadEventsAsync"/> yields, in order, before completing. Empty by default.</summary>
public List<WorkerEvent> Events { get; } = [];
/// <inheritdoc />
public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
@@ -869,7 +947,11 @@ public sealed class GatewaySessionTests
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
foreach (WorkerEvent workerEvent in Events)
{
cancellationToken.ThrowIfCancellationRequested();
yield return workerEvent;
}
}
/// <inheritdoc />