fix(dashboard): close StartDashboardMirror/DisposeAsync race; internal-overflow test + metric label
(1) GatewaySession.StartDashboardMirror: publish _dashboardMirrorLease and _dashboardMirrorTask
atomically under one _syncRoot section; if the session is already Closing/Closed/Faulted,
dispose the just-created lease and return without starting the mirror task so nothing is orphaned.
(2) WaitUntilAsync test helper: catch OperationCanceledException and call Assert.Fail with the
timeout duration and predicate source text instead of letting the exception propagate raw.
(3) New SessionEventDistributorTests.InternalSubscriberOverflow_HandlerSeesIsOnlySubscriberFalse:
verifies CountExternalSubscribers excludes the internal subscriber, so isOnlySubscriber==false
even when the internal subscriber is the only registered subscriber.
(4) SubscriberOverflowHandler delegate gains isInternal parameter; overflow metric label is
"dashboard-mirror" for internal subscribers and "grpc-event-stream" for external ones.
(5) DashboardEventBroadcaster.Publish: wrap SendAsync Task acquisition in try/catch so a
synchronous throw cannot escape the never-throw Publish interface contract.
This commit is contained in:
@@ -350,7 +350,7 @@ public sealed class SessionEventDistributorTests
|
||||
replayRetentionSeconds: 0,
|
||||
NullLogger<SessionEventDistributor>.Instance,
|
||||
TimeProvider.System,
|
||||
isOnlySubscriber =>
|
||||
(isOnlySubscriber, _) =>
|
||||
{
|
||||
Interlocked.Increment(ref overflowCalls);
|
||||
Volatile.Write(ref observedIsOnlySubscriberValue, isOnlySubscriber);
|
||||
@@ -415,7 +415,7 @@ public sealed class SessionEventDistributorTests
|
||||
replayRetentionSeconds: 0,
|
||||
NullLogger<SessionEventDistributor>.Instance,
|
||||
TimeProvider.System,
|
||||
isOnlySubscriber =>
|
||||
(isOnlySubscriber, _) =>
|
||||
{
|
||||
if (!isOnlySubscriber)
|
||||
{
|
||||
@@ -458,6 +458,68 @@ public sealed class SessionEventDistributorTests
|
||||
Assert.Equal(11ul, afterOverflow.WorkerSequence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task InternalSubscriberOverflow_HandlerSeesIsOnlySubscriberFalse_ProvingCountExcludesInternal()
|
||||
{
|
||||
// Issue 3: verifies that CountExternalSubscribers() excludes the internal dashboard
|
||||
// subscriber, so a FailFast policy would NOT fault the session even when the internal
|
||||
// subscriber is the ONLY registered subscriber. The overflow handler receives
|
||||
// isOnlySubscriber==false (not true) because the overflowing subscriber is internal
|
||||
// and is therefore excluded from the external-subscriber count.
|
||||
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
|
||||
int observedIsOnlySubscriberSet = 0;
|
||||
bool observedIsOnlySubscriberValue = false;
|
||||
bool observedIsInternalValue = false;
|
||||
await using SessionEventDistributor distributor = new(
|
||||
"session-internal-overflow",
|
||||
ct => source.Reader.ReadAllAsync(ct),
|
||||
subscriberQueueCapacity: 2,
|
||||
replayBufferCapacity: 0,
|
||||
replayRetentionSeconds: 0,
|
||||
NullLogger<SessionEventDistributor>.Instance,
|
||||
TimeProvider.System,
|
||||
(isOnlySubscriber, isInternal) =>
|
||||
{
|
||||
Volatile.Write(ref observedIsOnlySubscriberValue, isOnlySubscriber);
|
||||
Volatile.Write(ref observedIsInternalValue, isInternal);
|
||||
Volatile.Write(ref observedIsOnlySubscriberSet, 1);
|
||||
});
|
||||
await distributor.StartAsync(CancellationToken.None);
|
||||
|
||||
// Register ONLY an internal subscriber — no external subscriber is attached.
|
||||
using IEventSubscriberLease internalLease = distributor.Register(isInternal: true);
|
||||
|
||||
// Push enough events to overflow the capacity-2 internal subscriber channel.
|
||||
for (ulong sequence = 1; sequence <= 10; sequence++)
|
||||
{
|
||||
source.Writer.TryWrite(Event(sequence));
|
||||
}
|
||||
|
||||
// The internal subscriber is disconnected with the overflow fault.
|
||||
SessionManagerException fault = await Assert.ThrowsAsync<SessionManagerException>(
|
||||
async () => await DrainUntilFaultAsync(internalLease.Reader));
|
||||
Assert.Equal(SessionManagerErrorCode.EventQueueOverflow, fault.ErrorCode);
|
||||
|
||||
// Wait for the handler to fire (it runs on the pump thread).
|
||||
await Task.Run(async () =>
|
||||
{
|
||||
using CancellationTokenSource cts = new(ReadTimeout);
|
||||
while (Volatile.Read(ref observedIsOnlySubscriberSet) == 0)
|
||||
{
|
||||
await Task.Delay(10, cts.Token);
|
||||
}
|
||||
});
|
||||
|
||||
// isOnlySubscriber must be FALSE even though the internal subscriber was the ONLY
|
||||
// subscriber — CountExternalSubscribers excludes it, so a FailFast policy on the
|
||||
// external count would NOT fault the session.
|
||||
Assert.True(Volatile.Read(ref observedIsOnlySubscriberSet) == 1, "Overflow handler should have fired.");
|
||||
Assert.False(Volatile.Read(ref observedIsOnlySubscriberValue),
|
||||
"isOnlySubscriber must be false for an internal subscriber (CountExternalSubscribers excludes it).");
|
||||
Assert.True(Volatile.Read(ref observedIsInternalValue),
|
||||
"isInternal must be true for a subscriber registered with isInternal: true.");
|
||||
}
|
||||
|
||||
private static async Task DrainUntilFaultAsync(ChannelReader<MxEvent> reader)
|
||||
{
|
||||
// Drains any buffered events, then surfaces the channel's completion fault (if any)
|
||||
|
||||
Reference in New Issue
Block a user