Merge branch 'fix/gwc-26-27-alarm-attach'
# Conflicts: # archreview/2026-07-12/remediation/00-tracking.md # archreview/2026-07-12/remediation/10-gateway-core.md
This commit is contained in:
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user