fix(communication): cancel-only CTS replacement, deferred channel disposal, single-endpoint debug stream (arch-review 2026-08-19 P3-1 + P3-2 + P3-3)
This commit is contained in:
+68
@@ -66,6 +66,74 @@ public class SiteStreamGrpcClientFactoryDisposeTests
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test client whose disposal always throws — stands in for a client whose
|
||||
/// <c>ReleaseResources</c> hit an <see cref="ObjectDisposedException"/> on a subscription
|
||||
/// token source (arch-review 2026-08-19 P3-1).
|
||||
/// </summary>
|
||||
private sealed class ThrowingClient : SiteStreamGrpcClient
|
||||
{
|
||||
public override void Dispose() => throw new ObjectDisposedException(nameof(CancellationTokenSource));
|
||||
|
||||
public override ValueTask DisposeAsync() =>
|
||||
throw new ObjectDisposedException(nameof(CancellationTokenSource));
|
||||
}
|
||||
|
||||
/// <summary>Hands out one throwing client followed by tracking clients.</summary>
|
||||
private sealed class OneThrowingClientFactory : SiteStreamGrpcClientFactory
|
||||
{
|
||||
private readonly ConcurrentBag<TrackingClient> _created = new();
|
||||
private int _next;
|
||||
|
||||
public OneThrowingClientFactory() : base(NullLoggerFactory.Instance) { }
|
||||
|
||||
public IReadOnlyCollection<TrackingClient> Created => _created.ToList();
|
||||
|
||||
protected override SiteStreamGrpcClient CreateClient(string siteIdentifier, string grpcEndpoint)
|
||||
{
|
||||
if (Interlocked.Increment(ref _next) == 1)
|
||||
{
|
||||
return new ThrowingClient();
|
||||
}
|
||||
|
||||
var client = new TrackingClient();
|
||||
_created.Add(client);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisposeAsync_OneClientThrowing_StillDisposesEveryOtherClient()
|
||||
{
|
||||
// The disposal loop used to abort on the first throwing client, leaving every LATER
|
||||
// client — and its gRPC channel — undisposed for the life of the process.
|
||||
var factory = new OneThrowingClientFactory();
|
||||
factory.GetOrCreate("site-a", "http://localhost:5100"); // throwing
|
||||
factory.GetOrCreate("site-b", "http://localhost:5200");
|
||||
factory.GetOrCreate("site-c", "http://localhost:5300");
|
||||
|
||||
await factory.DisposeAsync();
|
||||
|
||||
Assert.Equal(2, factory.Created.Count);
|
||||
Assert.All(factory.Created, c =>
|
||||
Assert.True(c.AsyncDisposeCalled, "a throwing client must not strand its peers undisposed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_OneClientThrowing_StillDisposesEveryOtherClient()
|
||||
{
|
||||
var factory = new OneThrowingClientFactory();
|
||||
factory.GetOrCreate("site-a", "http://localhost:5100"); // throwing
|
||||
factory.GetOrCreate("site-b", "http://localhost:5200");
|
||||
factory.GetOrCreate("site-c", "http://localhost:5300");
|
||||
|
||||
factory.Dispose();
|
||||
|
||||
Assert.Equal(2, factory.Created.Count);
|
||||
Assert.All(factory.Created, c =>
|
||||
Assert.True(c.SyncDisposeCalled, "a throwing client must not strand its peers undisposed"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Dispose_DoesNotDeadlock_UnderSingleThreadedSynchronizationContext()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user