diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor index 4c7849fd..d4a1d6a3 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Hosts.razor @@ -17,6 +17,7 @@ @using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers @inject ActorSystem ActorSystem @inject IDriverStatusSnapshotStore DriverStore +@inject IDriverResilienceStatusStore ResilienceStore @inject IDbContextFactory DbFactory @inject Microsoft.Extensions.Logging.ILogger Logger @implements IAsyncDisposable @@ -428,6 +429,16 @@ else _instances = await db.DriverInstances.AsNoTracking() .Select(d => new HostsDriverInstanceInfo(d.DriverInstanceId, d.ClusterId, d.Name, d.DriverType, d.Enabled)) .ToListAsync(_cts.Token); + + // Evict snapshots of driver instances that are no longer configured (deleted, or re-created + // under a new id). The stores have no other removal path, so without this a removed driver + // stays on this page as a frozen row until the admin node restarts. Only a SUCCESSFUL load + // reaches here; the failure path below blanks _instances and must never sweep the stores. + var configured = _instances + .Select(i => i.DriverInstanceId) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + DriverStore.RetainOnly(configured); + ResilienceStore.RetainOnly(configured); } catch (Exception ex) when (ex is OperationCanceledException or ObjectDisposedException) { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs index 16c7271f..f415bd88 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/AlertSignalRBridge.cs @@ -57,15 +57,16 @@ public sealed class AlertSignalRBridge : ReceiveActor private async Task ForwardAsync(AlarmTransitionEvent msg) { // In-process fan-out first — this is what the Blazor Server Alerts page reads. The hub push - // is kept for any out-of-process (e.g. WASM) SignalR client. - _broadcaster.Publish(msg); + // is kept for any out-of-process (e.g. WASM) SignalR client. Both sit inside the try so a + // broadcast failure cannot restart the bridge (and drop its DPS subscription). try { + _broadcaster.Publish(msg); await _hub.Clients.All.SendAsync(AlertHub.MethodName, msg); } catch (Exception ex) { - _log.Warning(ex, "AlertSignalRBridge: SignalR push failed for {AlarmId}", msg.AlarmId); + _log.Warning(ex, "AlertSignalRBridge: forward failed for {AlarmId}", msg.AlarmId); } } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverResilienceStatusBridge.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverResilienceStatusBridge.cs index 8b488327..25cb09e0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverResilienceStatusBridge.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/DriverResilienceStatusBridge.cs @@ -1,5 +1,6 @@ using Akka.Actor; using Akka.Cluster.Tools.PublishSubscribe; +using Akka.Event; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; @@ -17,6 +18,7 @@ public sealed class DriverResilienceStatusBridge : ReceiveActor public const string TopicName = DriverResilienceStatusChanged.TopicName; private readonly IDriverResilienceStatusStore _store; + private readonly ILoggingAdapter _log = Context.GetLogger(); /// Creates actor props for a . /// Resilience status store updated on every forwarded snapshot. @@ -29,7 +31,12 @@ public sealed class DriverResilienceStatusBridge : ReceiveActor public DriverResilienceStatusBridge(IDriverResilienceStatusStore store) { _store = store; - Receive(msg => _store.Upsert(msg)); + Receive(msg => + { + // A store failure must not restart the bridge (a restart re-subscribes DPS and drops messages). + try { _store.Upsert(msg); } + catch (Exception ex) { _log.Warning(ex, "DriverResilienceStatusBridge: upsert failed for {0}", msg.DriverInstanceId); } + }); Receive(_ => { /* DPS confirmation */ }); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverResilienceStatusStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverResilienceStatusStore.cs index 0e503824..631b31be 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverResilienceStatusStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverResilienceStatusStore.cs @@ -27,6 +27,17 @@ public interface IDriverResilienceStatusStore /// A read-only collection of the last-known resilience snapshot for each pair. IReadOnlyCollection GetAll(); + /// + /// Evicts every snapshot whose driver instance is not in . + /// The stores have no other removal path, so a deleted (or re-created under a new id) driver would + /// otherwise keep its last snapshot until the process restarts. The /hosts page calls this + /// after each successful config load; a failed load must never call it with an empty set. + /// + /// The configured driver-instance ids to keep. The set's own + /// comparer decides matching. + /// The number of entries removed. + int RetainOnly(IReadOnlySet configuredInstanceIds); + /// /// Raised after every with the just-stored snapshot. Lets the Blazor Server /// driver status panel receive live updates by reading this singleton directly. Handlers run on diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs index b37b7bd4..8cfbd2aa 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IDriverStatusSnapshotStore.cs @@ -25,6 +25,17 @@ public interface IDriverStatusSnapshotStore /// A read-only collection of the last-known health snapshot for each driver instance. IReadOnlyCollection GetAll(); + /// + /// Evicts every snapshot whose driver instance is not in . + /// The stores have no other removal path, so a deleted (or re-created under a new id) driver would + /// otherwise keep its last snapshot until the process restarts. The /hosts page calls this + /// after each successful config load; a failed load must never call it with an empty set. + /// + /// The configured driver-instance ids to keep. The set's own + /// comparer decides matching. + /// The number of entries removed. + int RetainOnly(IReadOnlySet configuredInstanceIds); + /// /// Raised after every with the just-stored snapshot. Lets in-process /// consumers (the Blazor Server DriverStatusPanel) receive live updates by reading diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IInProcessBroadcaster.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IInProcessBroadcaster.cs index 1e9a42ac..3a29265a 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IInProcessBroadcaster.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/IInProcessBroadcaster.cs @@ -1,3 +1,5 @@ +using Microsoft.Extensions.Logging; + namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; /// @@ -59,6 +61,11 @@ public sealed class InProcessBroadcaster : IInProcessBroadcaster // render thread, so access must be serialised. private readonly object _connectionLock = new(); private bool _isConnected; + private readonly ILogger>? _logger; + + /// Creates the broadcaster. + /// Optional logger for a subscriber that throws during a raise. + public InProcessBroadcaster(ILogger>? logger = null) => _logger = logger; /// Raised once per with the published item, on the caller's thread. public event Action? Received; @@ -67,8 +74,10 @@ public sealed class InProcessBroadcaster : IInProcessBroadcaster public event Action? ConnectionStateChanged; /// - // Capture-then-invoke (via ?.) so a concurrent unsubscribe can't null the delegate mid-raise. - public void Publish(T item) => Received?.Invoke(item); + // Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise; one subscriber + // at a time so a throwing one cannot starve the rest or escape onto the bridge actor. + public void Publish(T item) => + SafeMulticast.Invoke(Received, item, ex => _logger?.LogDebug(ex, "In-process broadcast subscriber threw")); /// public bool IsConnected @@ -99,6 +108,7 @@ public sealed class InProcessBroadcaster : IInProcessBroadcaster handler = ConnectionStateChanged; } - handler?.Invoke(connected); + SafeMulticast.Invoke(handler, connected, + ex => _logger?.LogDebug(ex, "In-process connection-state subscriber threw")); } } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverResilienceStatusStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverResilienceStatusStore.cs index 6283eb5d..36b64bec 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverResilienceStatusStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverResilienceStatusStore.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; @@ -10,6 +11,12 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; public sealed class InMemoryDriverResilienceStatusStore : IDriverResilienceStatusStore { private readonly ConcurrentDictionary<(string Instance, string Host), DriverResilienceStatusChanged> _byPair = new(); + private readonly ILogger? _logger; + + /// Creates the store. + /// Optional logger for a subscriber that throws during a raise. + public InMemoryDriverResilienceStatusStore(ILogger? logger = null) + => _logger = logger; /// Raised after every with the just-stored snapshot. public event Action? SnapshotChanged; @@ -18,8 +25,22 @@ public sealed class InMemoryDriverResilienceStatusStore : IDriverResilienceStatu public void Upsert(DriverResilienceStatusChanged snapshot) { _byPair[(snapshot.DriverInstanceId, snapshot.HostName)] = snapshot; - // Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise. - SnapshotChanged?.Invoke(snapshot); + // Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise; one + // subscriber at a time so a throwing one cannot starve the rest (or escape onto the bridge actor). + SafeMulticast.Invoke(SnapshotChanged, snapshot, + ex => _logger?.LogDebug(ex, "Driver resilience subscriber threw for {DriverInstanceId}", snapshot.DriverInstanceId)); + } + + /// + public int RetainOnly(IReadOnlySet configuredInstanceIds) + { + var removed = 0; + foreach (var key in _byPair.Keys) + { + if (!configuredInstanceIds.Contains(key.Instance) && _byPair.TryRemove(key, out _)) removed++; + } + + return removed; } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs index 3a74e6be..21089cef 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/InMemoryDriverStatusSnapshotStore.cs @@ -1,4 +1,5 @@ using System.Collections.Concurrent; +using Microsoft.Extensions.Logging; using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; @@ -10,6 +11,12 @@ namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotStore { private readonly ConcurrentDictionary _byInstance = new(); + private readonly ILogger? _logger; + + /// Creates the store. + /// Optional logger for a subscriber that throws during a raise. + public InMemoryDriverStatusSnapshotStore(ILogger? logger = null) + => _logger = logger; /// Raised whenever a driver status snapshot is stored or updated. public event Action? SnapshotChanged; @@ -18,8 +25,22 @@ public sealed class InMemoryDriverStatusSnapshotStore : IDriverStatusSnapshotSto public void Upsert(DriverHealthChanged snapshot) { _byInstance[snapshot.DriverInstanceId] = snapshot; - // Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise. - SnapshotChanged?.Invoke(snapshot); + // Capture-then-invoke so a concurrent unsubscribe can't null the delegate mid-raise; one + // subscriber at a time so a throwing one cannot starve the rest (or escape onto the bridge actor). + SafeMulticast.Invoke(SnapshotChanged, snapshot, + ex => _logger?.LogDebug(ex, "Driver status subscriber threw for {DriverInstanceId}", snapshot.DriverInstanceId)); + } + + /// + public int RetainOnly(IReadOnlySet configuredInstanceIds) + { + var removed = 0; + foreach (var id in _byInstance.Keys) + { + if (!configuredInstanceIds.Contains(id) && _byInstance.TryRemove(id, out _)) removed++; + } + + return removed; } /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/SafeMulticast.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/SafeMulticast.cs new file mode 100644 index 00000000..610f5d4c --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/SafeMulticast.cs @@ -0,0 +1,39 @@ +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; + +/// +/// Raises a multicast delegate one subscriber at a time, so a subscriber that throws cannot stop +/// delivery to the subscribers after it. The in-process stores and broadcasters raise their events on +/// the bridge actor's thread; a plain handler?.Invoke(x) stops at the first throwing handler (for +/// example a Blazor circuit whose renderer is mid-teardown), so the remaining panels miss the update and +/// the exception escapes into the bridge actor, which restarts it. +/// +internal static class SafeMulticast +{ + /// Invokes every subscriber of with , isolating failures. + /// The event payload type. + /// The captured multicast delegate; null means no subscribers. + /// The payload passed to each subscriber. + /// Optional sink for a subscriber's exception; it must not throw. + /// The number of subscribers that threw. + public static int Invoke(Action? handler, T arg, Action? onError = null) + { + if (handler is null) return 0; + + var failures = 0; + foreach (var subscriber in handler.GetInvocationList()) + { + try + { + ((Action)subscriber)(arg); + } + catch (Exception ex) + { + failures++; + try { onError?.Invoke(ex); } + catch { /* the error sink must never take delivery down with it */ } + } + } + + return failures; + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs index 650465ba..ac849446 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Hubs/ScriptLogSignalRBridge.cs @@ -52,10 +52,11 @@ public sealed class ScriptLogSignalRBridge : ReceiveActor private async Task ForwardAsync(ScriptLogEntry msg) { // In-process fan-out first — this is what the Blazor Server Script log page reads. The hub - // push is kept for any out-of-process (e.g. WASM) SignalR client. - _broadcaster.Publish(msg); + // push is kept for any out-of-process (e.g. WASM) SignalR client. Both sit inside the try so a + // broadcast failure cannot restart the bridge (and drop its DPS subscription). try { + _broadcaster.Publish(msg); await _hub.Clients.All.SendAsync(ScriptLogHub.MethodName, msg); } catch (Exception ex) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AdminUiSource.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AdminUiSource.cs new file mode 100644 index 00000000..e78c5785 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AdminUiSource.cs @@ -0,0 +1,25 @@ +using Shouldly; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests; + +/// +/// Reads an AdminUI source file for a source-scan pin. The repo has no bUnit, so Razor wiring that a +/// unit test cannot render is pinned by scanning the markup; it proves the wiring exists, not that it +/// renders correctly (the docker-dev live pass covers that). +/// +internal static class AdminUiSource +{ + /// Reads a file under src/Server/ZB.MOM.WW.OtOpcUa.AdminUI. + /// Path segments below the AdminUI project directory. + /// The file text. + public static string Read(params string[] relativeSegments) + { + var dir = new DirectoryInfo(AppContext.BaseDirectory); + while (dir is not null && !File.Exists(Path.Combine(dir.FullName, "ZB.MOM.WW.OtOpcUa.slnx"))) + dir = dir.Parent; + dir.ShouldNotBeNull("could not locate the repository root (no ZB.MOM.WW.OtOpcUa.slnx above the test binary)"); + + var parts = new[] { dir!.FullName, "src", "Server", "ZB.MOM.WW.OtOpcUa.AdminUI" }.Concat(relativeSegments).ToArray(); + return File.ReadAllText(Path.Combine(parts)); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/StoreEvictionAndIsolationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/StoreEvictionAndIsolationTests.cs new file mode 100644 index 00000000..4cc857b3 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Hubs/StoreEvictionAndIsolationTests.cs @@ -0,0 +1,139 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Hubs; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Drivers; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Hubs; + +/// +/// Review R04 L1 + L5: the in-process stores evict driver instances that are no longer configured +/// (so /hosts stops rendering ghost rows), and a throwing subscriber no longer aborts the +/// multicast for the subscribers after it (nor escapes onto the bridge actor). +/// +public sealed class StoreEvictionAndIsolationTests +{ + private static DriverHealthChanged Health(string instance) => + new("MAIN", instance, "Healthy", null, null, 0, new DateTime(2026, 10, 22, 0, 0, 0, DateTimeKind.Utc)); + + private static DriverResilienceStatusChanged Res(string instance, string host) => + new(instance, host, false, 0, 0, null, + new DateTime(2026, 10, 22, 0, 0, 0, DateTimeKind.Utc), + new DateTime(2026, 10, 22, 0, 0, 0, DateTimeKind.Utc)); + + [Fact] + public void HealthStore_RetainOnly_evicts_unconfigured_instances_and_keeps_configured_ones() + { + var store = new InMemoryDriverStatusSnapshotStore(); + store.Upsert(Health("drv-live")); + store.Upsert(Health("drv-deleted")); + + var removed = store.RetainOnly(new HashSet(["drv-live"], StringComparer.OrdinalIgnoreCase)); + + removed.ShouldBe(1); + store.TryGet("drv-deleted", out _).ShouldBeFalse(); + // Positive control: the configured instance survives the sweep. + store.TryGet("drv-live", out _).ShouldBeTrue(); + store.GetAll().Select(s => s.DriverInstanceId).ShouldBe(["drv-live"]); + } + + [Fact] + public void HealthStore_RetainOnly_matches_ids_case_insensitively() + { + var store = new InMemoryDriverStatusSnapshotStore(); + store.Upsert(Health("DRV-Live")); + + store.RetainOnly(new HashSet(["drv-live"], StringComparer.OrdinalIgnoreCase)).ShouldBe(0); + store.TryGet("DRV-Live", out _).ShouldBeTrue(); + } + + [Fact] + public void ResilienceStore_RetainOnly_evicts_every_host_of_an_unconfigured_instance() + { + var store = new InMemoryDriverResilienceStatusStore(); + store.Upsert(Res("drv-live", "h1")); + store.Upsert(Res("drv-gone", "h1")); + store.Upsert(Res("drv-gone", "h2")); + + var removed = store.RetainOnly(new HashSet(["drv-live"], StringComparer.OrdinalIgnoreCase)); + + removed.ShouldBe(2); + store.GetForInstance("drv-gone").ShouldBeEmpty(); + store.GetForInstance("drv-live").Count.ShouldBe(1); + } + + [Fact] + public void HealthStore_throwing_subscriber_does_not_stop_later_subscribers() + { + var store = new InMemoryDriverStatusSnapshotStore(); + var received = new List(); + store.SnapshotChanged += _ => throw new InvalidOperationException("renderer torn down"); + store.SnapshotChanged += s => received.Add(s.DriverInstanceId); + + Should.NotThrow(() => store.Upsert(Health("drv-1"))); + + received.ShouldBe(["drv-1"]); + store.TryGet("drv-1", out _).ShouldBeTrue(); + } + + [Fact] + public void ResilienceStore_throwing_subscriber_does_not_stop_later_subscribers() + { + var store = new InMemoryDriverResilienceStatusStore(); + var received = 0; + store.SnapshotChanged += _ => throw new InvalidOperationException("boom"); + store.SnapshotChanged += _ => received++; + + Should.NotThrow(() => store.Upsert(Res("drv-1", "h1"))); + + received.ShouldBe(1); + } + + [Fact] + public void Broadcaster_throwing_subscriber_does_not_stop_later_subscribers() + { + var broadcaster = new InProcessBroadcaster(); + var received = new List(); + broadcaster.Received += _ => throw new InvalidOperationException("boom"); + broadcaster.Received += received.Add; + + Should.NotThrow(() => broadcaster.Publish("evt")); + + received.ShouldBe(["evt"]); + } + + [Fact] + public void Broadcaster_throwing_connection_subscriber_does_not_stop_later_subscribers() + { + var broadcaster = new InProcessBroadcaster(); + var raised = new List(); + broadcaster.ConnectionStateChanged += _ => throw new InvalidOperationException("boom"); + broadcaster.ConnectionStateChanged += raised.Add; + + Should.NotThrow(() => broadcaster.SetConnected(true)); + + raised.ShouldBe([true]); + broadcaster.IsConnected.ShouldBeTrue(); + } +} + +/// +/// Source pin (no bUnit): /hosts sweeps both stores after a successful config load, and the +/// sweep sits before the failure catch, so a failed load (which blanks the config) never evicts. +/// +[Trait("Category", "Unit")] +public sealed class HostsEvictionWiringTests +{ + [Fact] + public void Hosts_page_evicts_both_stores_inside_the_successful_load_only() + { + var src = AdminUiSource.Read("Components", "Pages", "Hosts.razor"); + + var load = src.IndexOf("private async Task LoadConfigAsync()", StringComparison.Ordinal); + load.ShouldBeGreaterThan(0); + var firstCatch = src.IndexOf("catch (Exception ex) when (ex is OperationCanceledException", load, StringComparison.Ordinal); + firstCatch.ShouldBeGreaterThan(load); + + src.IndexOf("DriverStore.RetainOnly(", load, StringComparison.Ordinal).ShouldBeInRange(load, firstCatch); + src.IndexOf("ResilienceStore.RetainOnly(", load, StringComparison.Ordinal).ShouldBeInRange(load, firstCatch); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialDeadlineTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialDeadlineTests.cs index d19506ca..ecb952dc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialDeadlineTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialDeadlineTests.cs @@ -141,6 +141,7 @@ public sealed class TelemetryDialDeadlineTests : TestKit public void Upsert(DriverHealthChanged snapshot) { Upserts.Enqueue(snapshot); SnapshotChanged?.Invoke(snapshot); } public bool TryGet(string driverInstanceId, out DriverHealthChanged snapshot) { snapshot = null!; return false; } public IReadOnlyCollection GetAll() => Upserts.ToArray(); + public int RetainOnly(IReadOnlySet configuredInstanceIds) => 0; } private sealed class NullResilienceStore : IDriverResilienceStatusStore @@ -149,5 +150,6 @@ public sealed class TelemetryDialDeadlineTests : TestKit public void Upsert(DriverResilienceStatusChanged snapshot) => SnapshotChanged?.Invoke(snapshot); public IReadOnlyList GetForInstance(string driverInstanceId) => Array.Empty(); public IReadOnlyCollection GetAll() => Array.Empty(); + public int RetainOnly(IReadOnlySet configuredInstanceIds) => 0; } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialSupervisorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialSupervisorTests.cs index 4de5f16d..b0bf0b9a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialSupervisorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Telemetry/TelemetryDialSupervisorTests.cs @@ -438,6 +438,7 @@ public sealed class TelemetryDialSupervisorTests : TestKit } public IReadOnlyCollection GetAll() => Upserts.ToArray(); + public int RetainOnly(IReadOnlySet configuredInstanceIds) => 0; } private sealed class RecordingResilienceStore : IDriverResilienceStatusStore @@ -456,6 +457,7 @@ public sealed class TelemetryDialSupervisorTests : TestKit Upserts.ToArray(); public IReadOnlyCollection GetAll() => Upserts.ToArray(); + public int RetainOnly(IReadOnlySet configuredInstanceIds) => 0; } private sealed class InMemoryFactory(string name) : IDbContextFactory