diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor index 2d683e37..fc140b7b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/AlarmSummary.razor @@ -382,15 +382,20 @@ _liveSubscription = null; } + // N7: set BEFORE teardown so a live callback racing Dispose is dropped both + // before the InvokeAsync marshal and inside it (mirrors DebugView.razor). + private volatile bool _disposed; + // Raised on the aggregator's thread — marshal onto the circuit before touching state. private void OnLiveAlarmsChanged(int siteId) { + if (_disposed) return; _ = InvokeAsync(() => { // Drop stale callbacks for a site we've since navigated away from, and // let the poll drive until the aggregator has actually seeded (so we never // clobber a good poll snapshot with an empty pre-seed list). - if (_selectedSiteId != siteId || !LiveAlarmCache.IsLive(siteId)) + if (_disposed || _selectedSiteId != siteId || !LiveAlarmCache.IsLive(siteId)) { return; } @@ -525,6 +530,7 @@ public void Dispose() { + _disposed = true; StopTimer(); DisposeLiveSubscription(); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs index 03fc9679..1c117079 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/AlarmSummaryRenderTests.cs @@ -278,6 +278,30 @@ public class AlarmSummaryRenderTests : BunitContext }); } + // ── arch-review R2 N7: disposal guard on the live callback ───────────────── + + [Fact] + public void LiveCallbackRacingDispose_IsDropped_NoRebuildNoThrow() + { + var cut = RenderWithSiteSelected(); + _liveCache.PushAlarms(new List + { + new("Gamma", "G-alarm", AlarmState.Active, 300, DateTimeOffset.UtcNow), + }); + cut.WaitForAssertion(() => Assert.Equal("Gamma", FirstRowInstance(cut))); + + // Snapshot the callback BEFORE disposal — this models a delta thread that + // already read the delegate when Dispose ran (arch-review R2 N7). + var inFlight = _liveCache.CapturedCallback!; + cut.Instance.Dispose(); + + _liveCache.PushAlarms(new List()); // mutate the fake's snapshot + var ex = Record.Exception(inFlight); + Assert.Null(ex); + // The disposed page must not have re-applied the (now empty) live snapshot. + Assert.Equal("Gamma", FirstRowInstance(cut)); + } + /// /// Controllable in-memory fake of : records /// subscribe/dispose counts and lets a test push a live snapshot (which flips @@ -293,6 +317,7 @@ public class AlarmSummaryRenderTests : BunitContext public int SubscribeCount { get; private set; } public int DisposeCount { get; private set; } public int? LastSubscribedSite { get; private set; } + public Action? CapturedCallback => _onChanged; public IDisposable Subscribe(int siteId, Action onChanged) {