fix(centralui): stop debug-stream sessions abandoned mid-start — dispose-vs-connect race (arch-review 2026-08-19 P6-6)

A debug-stream session created after the consumer had already gone away was
leaked permanently. Both consumers awaited StartStreamAsync (up to 30s) and only
then published the session; a circuit teardown / client disconnect inside that
window ran the release path first, found nothing registered and stopped nothing.
The session stayed registered in the singleton DebugStreamService, whose
keepalive renews registered sessions forever (the 5-minute orphan reaper only
collects UNregistered ones), pinning the site gRPC subscription, the
DebugStreamBridgeActor and the dead consumer's object graph until process
restart.

Both call sites now publish the session and re-check liveness under a single
monitor, so exactly one side tears it down and neither can miss it:

- DebugView.Connect/Dispose/Disconnect share a _sessionLock; Dispose sets
  _disposed and takes the session in one step, Connect publishes only if not
  disposed and otherwise stops the session it just created. Dispose can run off
  the renderer's thread, so the Blazor sync context is not relied on. Disconnect
  and the onTerminated callback go through the same take-once helper, which also
  makes a repeated Dispose stop-once instead of stop-again.
- DebugStreamHub uses the per-connection Items monitor: OnDisconnectedAsync marks
  the connection gone before releasing, SubscribeInstance publishes only if
  neither that flag nor ConnectionAborted is set, and UnsubscribeInstance takes
  the id atomically.

DebugStreamService.StartStreamAsync/StopStream become virtual purely as a test
seam (no behaviour change, keepalive/orphan contract untouched) so the race can
be driven deterministically by a gated stub.

Tests: DebugViewConnectDisposeRaceTests (3) + DebugStreamHubSubscribeRaceTests
(3). Verified fail-unfixed: with the two source fixes stashed, both
dispose-mid-start tests report zero StopStream calls and the DebugView
double-dispose test reports two.
This commit is contained in:
Joseph Doherty
2026-08-19 14:52:22 -04:00
parent 5cbc143b01
commit 561582747e
5 changed files with 517 additions and 13 deletions
@@ -81,7 +81,12 @@ public class DebugStreamService : IDisposable
/// <param name="onTerminated">Callback invoked when the stream terminates.</param>
/// <param name="ct">A cancellation token that can be used to cancel the operation.</param>
/// <returns>A debug stream session with the initial snapshot.</returns>
public async Task<DebugStreamSession> StartStreamAsync(
/// <remarks>
/// Virtual purely as a test seam: the consumers' dispose-vs-start race (P6-6) can only
/// be driven deterministically by a stub that holds the start open. Behaviour and the
/// keepalive/orphan contract are unchanged.
/// </remarks>
public virtual async Task<DebugStreamSession> StartStreamAsync(
int instanceId,
Action<object> onEvent,
Action onTerminated,
@@ -209,7 +214,8 @@ public class DebugStreamService : IDisposable
/// Stops an active debug stream session.
/// </summary>
/// <param name="sessionId">The session ID of the debug stream to stop.</param>
public void StopStream(string sessionId)
/// <remarks>Virtual purely as a test seam — see <see cref="StartStreamAsync"/>.</remarks>
public virtual void StopStream(string sessionId)
{
if (_sessions.TryRemove(sessionId, out var bridgeActor))
{