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:
@@ -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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user