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:
Joseph Doherty
2026-08-19 14:38:33 -04:00
parent fd4cb30273
commit 412406e7c5
8 changed files with 566 additions and 50 deletions
@@ -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()
{