fix: resolve code-review findings (locally verified)
Server-054/055/056, Contracts-020/021/022, Tests-036/038/039, IntegrationTests-030/031/032 (+033 deferred to live rig), Client.Dotnet-026/028/029 (+027 won't-fix), Client.Go-030..034, Client.Python-032..036, Client.Rust-033..038. Key fix: SessionEventDistributor orphaned a subscriber that registered after the pump completed but before disposal (Server-056) -> register paths now complete late registrants under _lifecycleLock; regression test added. The racy dashboard-mirror gRPC test made deterministic (Tests-039). Verified green locally: gateway Tests targeted classes (GatewaySession, SessionEventDistributor, GatewayOptionsValidator, ProtobufContractRoundTrip, GatewaySessionDashboardMirror) + dotnet/go/python/rust client suites.
This commit is contained in:
@@ -702,16 +702,71 @@ public sealed class SessionEventDistributorTests
|
||||
private static async Task DrainUntilFaultAsync(ChannelReader<MxEvent> reader)
|
||||
{
|
||||
// Drains any buffered events, then surfaces the channel's completion fault (if any)
|
||||
// by awaiting the final read past the buffered tail.
|
||||
// by awaiting the final WaitToReadAsync past the buffered tail.
|
||||
// If WaitToReadAsync returns false (graceful completion rather than a fault),
|
||||
// await Completion to surface any fault stored there, then Assert.Fail so the
|
||||
// helper does not spin forever on a channel that completes without an exception.
|
||||
while (true)
|
||||
{
|
||||
await reader.WaitToReadAsync().AsTask().WaitAsync(ReadTimeout);
|
||||
bool hasMore = await reader.WaitToReadAsync().AsTask().WaitAsync(ReadTimeout);
|
||||
if (!hasMore)
|
||||
{
|
||||
// Graceful completion — propagate any stored exception, then fail.
|
||||
await reader.Completion;
|
||||
Assert.Fail("DrainUntilFaultAsync: channel completed gracefully (no fault).");
|
||||
return;
|
||||
}
|
||||
|
||||
while (reader.TryRead(out _))
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Regression: a subscriber that registers in the window AFTER the pump has completed
|
||||
/// (its event source finished) but BEFORE the distributor is disposed must have its
|
||||
/// channel completed immediately, not left open forever. The pump has already run its
|
||||
/// final <c>CompleteAllSubscribers</c> sweep and exited, so without the
|
||||
/// register-after-completion guard the late subscriber's reader hangs indefinitely.
|
||||
/// This was observed as an order-dependent hang in
|
||||
/// <c>GatewaySessionDashboardMirrorTests</c>, where a gRPC subscriber attached after a
|
||||
/// fast-completing worker stream had already drained.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Register_AfterSourceCompletes_CompletesLateSubscriberInsteadOfHanging()
|
||||
{
|
||||
Channel<MxEvent> source = Channel.CreateUnbounded<MxEvent>();
|
||||
await using SessionEventDistributor distributor = CreateDistributor(source.Reader);
|
||||
await distributor.StartAsync(CancellationToken.None);
|
||||
|
||||
// An early subscriber lets us observe when the pump's final completion sweep has run.
|
||||
using IEventSubscriberLease early = distributor.Register();
|
||||
|
||||
// Complete the source: the pump drains it, runs CompleteAllSubscribers, and exits.
|
||||
source.Writer.Complete();
|
||||
|
||||
// Draining the early subscriber to completion proves the pump finished its sweep — so
|
||||
// a subscriber registering now is unambiguously in the register-after-completion window.
|
||||
using (CancellationTokenSource earlyCts = new(ReadTimeout))
|
||||
{
|
||||
await foreach (MxEvent _ in early.Reader.ReadAllAsync(earlyCts.Token))
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
// Register AFTER the pump has completed. The channel must be completed immediately; the
|
||||
// bounded read below must end rather than hang (the ReadTimeout converts a regression
|
||||
// into a fast OperationCanceledException failure instead of an indefinite hang).
|
||||
using IEventSubscriberLease late = distributor.Register();
|
||||
using CancellationTokenSource lateCts = new(ReadTimeout);
|
||||
await foreach (MxEvent _ in late.Reader.ReadAllAsync(lateCts.Token))
|
||||
{
|
||||
}
|
||||
|
||||
Assert.False(lateCts.IsCancellationRequested);
|
||||
}
|
||||
|
||||
private static SessionEventDistributor CreateDistributor(ChannelReader<MxEvent> source)
|
||||
=> CreateDistributor(source, replayBufferCapacity: 1024, replayRetentionSeconds: 300);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user