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; /// /// Regression tests for arch-review 2026-08-19 P6-6. Connect() awaits /// StartStreamAsync — which can take up to 30s — and then publishes the result into /// _session. If the circuit was torn down inside that window, Dispose() had /// already run, found _session still null and stopped nothing, so the just-created /// session stayed registered in the singleton . 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. /// public class DebugViewConnectDisposeRaceTests : BunitContext { /// /// A whose start can be held open on demand, so the /// dispose-during-start window is deterministic rather than timing-dependent. /// private sealed class GatedDebugStreamService : DebugStreamService { private readonly TaskCompletionSource _gate = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly TaskCompletionSource _entered = new(TaskCreationOptions.RunContinuationsAsynchronously); private readonly List _stopped = new(); public GatedDebugStreamService( CommunicationService comms, IServiceProvider provider, SiteStreamGrpcClientFactory factory) : base(comms, provider, factory, NullLogger.Instance) { } public string SessionId => "session-under-test"; /// Completes once has been entered. public Task Entered => _entered.Task; /// Session ids passed to , in call order. public IReadOnlyList Stopped { get { lock (_stopped) { return _stopped.ToList(); } } } public void ReleaseStart() => _gate.TrySetResult(); public override async Task StartStreamAsync( int instanceId, Action onEvent, Action onTerminated, CancellationToken ct = default) { _entered.TrySetResult(); await _gate.Task; return new DebugStreamSession( SessionId, new DebugViewSnapshot( "Inst-1", Array.Empty(), Array.Empty(), 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 GetAuthenticationStateAsync() => Task.FromResult(_state); } private (IRenderedComponent Cut, GatedDebugStreamService Stream) RenderPage() { JSInterop.Mode = JSRuntimeMode.Loose; var repo = Substitute.For(); var siteRepo = Substitute.For(); siteRepo.GetAllSitesAsync().Returns(new List()); Services.AddSingleton(repo); Services.AddSingleton(siteRepo); var comms = new CommunicationService( Options.Create(new CommunicationOptions()), NullLogger.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(stream); var identity = new ClaimsIdentity( new[] { new Claim(ClaimTypes.Name, "deployer") }, "TestCookie"); var stubAuth = new StubAuthStateProvider( new AuthenticationState(new ClaimsPrincipal(identity))); Services.AddSingleton(stubAuth); Services.AddScoped(_ => new SiteScopeService(stubAuth)); return (Render(), 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); /// /// Starts Connect() 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. /// private static async Task StartConnectAsync(IRenderedComponent 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); } }