Files
ScadaBridge/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Deployment/DebugViewConnectDisposeRaceTests.cs
T
Joseph Doherty 561582747e 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.
2026-08-19 14:53:01 -04:00

212 lines
8.9 KiB
C#

using System.Reflection;
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.DebugView;
using ZB.MOM.WW.ScadaBridge.Commons.Messages.Streaming;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Communication.Grpc;
using DebugViewPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Deployment.DebugView;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Deployment;
/// <summary>
/// Regression tests for arch-review 2026-08-19 P6-6. <c>Connect()</c> awaits
/// <c>StartStreamAsync</c> — which can take up to 30s — and then publishes the result into
/// <c>_session</c>. If the circuit was torn down inside that window, <c>Dispose()</c> had
/// already run, found <c>_session</c> still null and stopped nothing, so the just-created
/// session stayed registered in the singleton <see cref="DebugStreamService"/>. Its
/// keepalive renews registered sessions forever (the orphan reaper only collects
/// UNregistered ones), so the site subscription, the bridge actor and the dead circuit's
/// object graph were pinned until process restart. The fix publishes the session and
/// re-checks disposal under one lock, so exactly one side tears the session down.
/// </summary>
public class DebugViewConnectDisposeRaceTests : BunitContext
{
/// <summary>
/// A <see cref="DebugStreamService"/> whose start can be held open on demand, so the
/// dispose-during-start window is deterministic rather than timing-dependent.
/// </summary>
private sealed class GatedDebugStreamService : DebugStreamService
{
private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly List<string> _stopped = new();
public GatedDebugStreamService(
CommunicationService comms, IServiceProvider provider, SiteStreamGrpcClientFactory factory)
: base(comms, provider, factory, NullLogger<DebugStreamService>.Instance)
{
}
public string SessionId => "session-under-test";
/// <summary>Completes once <see cref="StartStreamAsync"/> has been entered.</summary>
public Task Entered => _entered.Task;
/// <summary>Session ids passed to <see cref="StopStream"/>, in call order.</summary>
public IReadOnlyList<string> Stopped
{
get { lock (_stopped) { return _stopped.ToList(); } }
}
public void ReleaseStart() => _gate.TrySetResult();
public override async Task<DebugStreamSession> StartStreamAsync(
int instanceId, Action<object> onEvent, Action onTerminated, CancellationToken ct = default)
{
_entered.TrySetResult();
await _gate.Task;
return new DebugStreamSession(
SessionId,
new DebugViewSnapshot(
"Inst-1",
Array.Empty<AttributeValueChanged>(),
Array.Empty<AlarmStateChanged>(),
DateTimeOffset.UtcNow));
}
public override void StopStream(string sessionId)
{
lock (_stopped) { _stopped.Add(sessionId); }
}
}
private sealed class StubAuthStateProvider : AuthenticationStateProvider
{
private readonly AuthenticationState _state;
public StubAuthStateProvider(AuthenticationState state) => _state = state;
public override Task<AuthenticationState> GetAuthenticationStateAsync()
=> Task.FromResult(_state);
}
private (IRenderedComponent<DebugViewPage> Cut, GatedDebugStreamService Stream) RenderPage()
{
JSInterop.Mode = JSRuntimeMode.Loose;
var repo = Substitute.For<ITemplateEngineRepository>();
var siteRepo = Substitute.For<ISiteRepository>();
siteRepo.GetAllSitesAsync().Returns(new List<Site>());
Services.AddSingleton(repo);
Services.AddSingleton(siteRepo);
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
Services.AddSingleton(comms);
// An empty throwaway provider — the gated stub never calls into the base start
// path, so nothing is resolved from it.
var stream = new GatedDebugStreamService(
comms,
new ServiceCollection().BuildServiceProvider(),
new SiteStreamGrpcClientFactory(NullLoggerFactory.Instance));
Services.AddSingleton<DebugStreamService>(stream);
var identity = new ClaimsIdentity(
new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie");
var stubAuth = new StubAuthStateProvider(
new AuthenticationState(new ClaimsPrincipal(identity)));
Services.AddSingleton<AuthenticationStateProvider>(stubAuth);
Services.AddScoped(_ => new SiteScopeService(stubAuth));
return (Render<DebugViewPage>(), stream);
}
private static void SetField(DebugViewPage page, string name, object value) =>
typeof(DebugViewPage).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!
.SetValue(page, value);
private static object? GetField(DebugViewPage page, string name) =>
typeof(DebugViewPage).GetField(name, BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(page);
/// <summary>
/// Starts <c>Connect()</c> on the renderer's dispatcher (where it really runs) and hands
/// back its task without awaiting it, so the test can act inside the start window.
/// </summary>
private static async Task<Task> StartConnectAsync(IRenderedComponent<DebugViewPage> cut)
{
var connect = typeof(DebugViewPage).GetMethod(
"Connect", BindingFlags.Instance | BindingFlags.NonPublic)!;
Task? task = null;
await cut.InvokeAsync(() => { task = (Task)connect.Invoke(cut.Instance, null)!; });
return task!;
}
[Fact]
public async Task Connect_DisposedMidStart_StopsTheJustCreatedSession()
{
var (cut, stream) = RenderPage();
SetField(cut.Instance, "_selectedSiteId", 1);
SetField(cut.Instance, "_selectedInstanceId", 7);
var connect = await StartConnectAsync(cut);
await stream.Entered.WaitAsync(TimeSpan.FromSeconds(5));
// The user navigates away while the start is still in flight. Dispose runs off the
// renderer's thread, exactly as a circuit teardown can.
cut.Instance.Dispose();
stream.ReleaseStart();
await connect.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Equal(new[] { stream.SessionId }, stream.Stopped);
Assert.Null(GetField(cut.Instance, "_session"));
}
[Fact]
public async Task Connect_ThenDispose_StopsTheSessionExactlyOnce()
{
var (cut, stream) = RenderPage();
SetField(cut.Instance, "_selectedSiteId", 1);
SetField(cut.Instance, "_selectedInstanceId", 7);
// Normal path: the start completes while the component is very much alive.
stream.ReleaseStart();
var connect = await StartConnectAsync(cut);
await connect.WaitAsync(TimeSpan.FromSeconds(10));
Assert.Empty(stream.Stopped);
Assert.NotNull(GetField(cut.Instance, "_session"));
cut.Instance.Dispose();
Assert.Equal(new[] { stream.SessionId }, stream.Stopped);
// A second Dispose (bUnit tears the component down again at test end) must not
// stop an already-released session a second time.
cut.Instance.Dispose();
Assert.Equal(new[] { stream.SessionId }, stream.Stopped);
}
[Fact]
public async Task Disconnect_AfterConnect_StopsTheSessionOnceAndClearsTheSlot()
{
var (cut, stream) = RenderPage();
SetField(cut.Instance, "_selectedSiteId", 1);
SetField(cut.Instance, "_selectedInstanceId", 7);
stream.ReleaseStart();
var connect = await StartConnectAsync(cut);
await connect.WaitAsync(TimeSpan.FromSeconds(10));
var disconnect = typeof(DebugViewPage).GetMethod(
"Disconnect", BindingFlags.Instance | BindingFlags.NonPublic)!;
await cut.InvokeAsync(() => (Task)disconnect.Invoke(cut.Instance, null)!);
Assert.Equal(new[] { stream.SessionId }, stream.Stopped);
Assert.Null(GetField(cut.Instance, "_session"));
// Dispose after an explicit disconnect has nothing left to stop.
cut.Instance.Dispose();
Assert.Equal(new[] { stream.SessionId }, stream.Stopped);
}
}