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
@@ -138,7 +138,18 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
/// Registers a subscription's CancellationTokenSource for a correlation ID.
/// If an entry already exists for that correlation ID (a reconnect race where two
/// <see cref="SubscribeAsync"/> calls briefly share an ID), the prior CTS is
/// cancelled and disposed so it cannot leak. Internal for testability.
/// <b>cancelled only</b> — never disposed from here. Internal for testability.
/// <para>
/// <b>Cancel-only is the contract</b> (arch-review 2026-08-19 P3-1, the client half of the
/// row-47 class): the prior handler's <see cref="ConsumeStreamAsync"/> is still running and
/// still reads <c>cts.Token</c> (opening the call, <c>ReadAllAsync</c>, the
/// <c>IsCancellationRequested</c> checks), so disposing it from the REPLACING path throws
/// <see cref="ObjectDisposedException"/> inside the prior stream — and, worse, out of the
/// <c>prior.Cancel()</c> call itself into the NEW <c>SubscribeAsync</c>, where it reads as a
/// fault of the current generation and burns a reconnect. Disposal belongs to the OWNING
/// path: <see cref="SubscribeAsync"/>/<see cref="SubscribeSiteAsync"/> dispose the CTS they
/// created once their stream loop has fully unwound.
/// </para>
/// </summary>
/// <param name="correlationId">Unique identifier for the subscription.</param>
/// <param name="cts">CancellationTokenSource for managing the subscription lifecycle.</param>
@@ -146,12 +157,30 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
{
if (_subscriptions.TryGetValue(correlationId, out var prior) && !ReferenceEquals(prior, cts))
{
prior.Cancel();
prior.Dispose();
CancelQuietly(prior);
}
_subscriptions[correlationId] = cts;
}
/// <summary>
/// Cancels a subscription token source, swallowing <see cref="ObjectDisposedException"/>.
/// A CTS whose owning stream loop has already unwound and disposed it is simply a
/// subscription that no longer needs cancelling; the caller must never see that race as a
/// failure (arch-review 2026-08-19 P3-1).
/// </summary>
/// <param name="cts">The token source to cancel.</param>
private static void CancelQuietly(CancellationTokenSource cts)
{
try
{
cts.Cancel();
}
catch (ObjectDisposedException)
{
// Already disposed by its owning stream loop — nothing left to cancel.
}
}
/// <summary>
/// Removes the subscription entry for a correlation ID only if the stored CTS is
/// exactly the one supplied. A racing replacement stream may already own the slot,
@@ -213,13 +242,23 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
onEvent(domainEvent);
}
await ConsumeStreamAsync(
correlationId,
cts,
() => _client.SubscribeInstance(request, cancellationToken: cts.Token),
frame => ForEachEvent(frame, Deliver),
onError,
onCompleted);
// This call OWNS `cts` — it created it, so it disposes it once the stream loop has
// fully unwound (P3-1). No other path may dispose it while this one is still reading
// cts.Token; a replacing subscription cancels only.
try
{
await ConsumeStreamAsync(
correlationId,
cts,
() => _client.SubscribeInstance(request, cancellationToken: cts.Token),
frame => ForEachEvent(frame, Deliver),
onError,
onCompleted);
}
finally
{
cts.Dispose();
}
}
/// <summary>
@@ -288,14 +327,22 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
onAlarmEvent(alarm);
}
await ConsumeStreamAsync(
correlationId,
cts,
() => _client.SubscribeSite(request, cancellationToken: cts.Token),
frame => ForEachEvent(frame, Deliver),
onError,
onCompleted,
onConnected);
// Owns `cts` — see SubscribeAsync.
try
{
await ConsumeStreamAsync(
correlationId,
cts,
() => _client.SubscribeSite(request, cancellationToken: cts.Token),
frame => ForEachEvent(frame, Deliver),
onError,
onCompleted,
onConnected);
}
finally
{
cts.Dispose();
}
}
/// <summary>
@@ -449,15 +496,17 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
}
/// <summary>
/// Cancels an active subscription by correlation ID.
/// Cancels an active subscription by correlation ID. Cancel-only: the subscription's own
/// <see cref="SubscribeAsync"/>/<see cref="SubscribeSiteAsync"/> call disposes the token
/// source once its stream loop unwinds (P3-1). Cancelling an entry whose owner already
/// disposed it — or unsubscribing after the client was disposed — is a silent no-op.
/// </summary>
/// <param name="correlationId">Unique identifier of the subscription to cancel.</param>
public virtual void Unsubscribe(string correlationId)
{
if (_subscriptions.TryRemove(correlationId, out var cts))
{
cts.Cancel();
cts.Dispose();
CancelQuietly(cts);
}
}
@@ -601,17 +650,23 @@ public class SiteStreamGrpcClient : IAsyncDisposable, IDisposable
};
/// <summary>
/// Releases all subscription CancellationTokenSources and the underlying
/// gRPC channel. All teardown here is synchronous (CTS disposal and
/// <see cref="GrpcChannel.Dispose"/>), so a synchronous <see cref="Dispose"/>
/// can release everything without sync-over-async blocking.
/// Cancels every live subscription and releases the underlying gRPC channel. All teardown
/// here is synchronous (CTS cancellation and <see cref="GrpcChannel.Dispose"/>), so a
/// synchronous <see cref="Dispose"/> can release everything without sync-over-async blocking.
/// <para>
/// Cancel-only, per-entry guarded (P3-1). The token sources are owned by their
/// <see cref="SubscribeAsync"/>/<see cref="SubscribeSiteAsync"/> calls, which dispose them
/// as their loops unwind in response to this cancellation; disposing them here raced those
/// loops, and one <see cref="ObjectDisposedException"/> aborted the whole foreach — which in
/// turn aborted <see cref="SiteStreamGrpcClientFactory.DisposeAsync"/>'s client loop and left
/// every later client (and its channel) undisposed.
/// </para>
/// </summary>
private void ReleaseResources()
{
foreach (var cts in _subscriptions.Values)
{
cts.Cancel();
cts.Dispose();
CancelQuietly(cts);
}
_subscriptions.Clear();