1b3169273d
Review 2026-10-21 G-4, G-7, G-11, G-10. - G-4: PerPlatformProbeWatcher publishes its probe handles into the driver's SubscriptionRegistry under the reserved ProbeSubscriptionId (-1), so the EventPump's fan-out finally reaches the watcher — every ScanState event used to be dropped as "stale after unsubscribe" and per-platform host status never updated. ResubscribeAllAsync delegates the reserved entry to the watcher's new ResubscribeAsync, so the probe survives a reconnect; StopAsync removes the entry. Probe traffic is routed by the reserved handle and never reaches the public OnDataChange. The watcher is also built on the seam path (subscriber only), which is what makes the wiring testable without a gateway. - G-7: GalaxyDiscoverer.DiscoverAsync returns the hierarchy it fetched and the driver selects its $WinPlatform/$AppEngine probe targets from that same sweep instead of asking the gateway for the whole galaxy twice. - G-11: OnPumpDataChange stamps LastSuccessfulRead from inbound stream data, throttled (1 s default), so the comms-loss watchdog has honest liveness evidence under a Degraded supervisor. - G-10: an EventPump dispatch-loop exit that is not a cancellation now reports a stream fault so the supervisor recreates the pump, instead of leaving a deaf pump reporting Healthy. Refs #735 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
113 lines
5.4 KiB
C#
113 lines
5.4 KiB
C#
using System.Runtime.CompilerServices;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Health;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Runtime;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Tests.Health;
|
|
|
|
/// <summary>
|
|
/// Review 2026-10-21 G-4: the watcher's probe handles must live in the driver's
|
|
/// <see cref="SubscriptionRegistry"/> under the reserved id, follow the platform set, be
|
|
/// rebound on <see cref="PerPlatformProbeWatcher.ResubscribeAsync"/>, and leave on stop.
|
|
/// </summary>
|
|
public sealed class PerPlatformProbeWatcherRegistryTests
|
|
{
|
|
private sealed class FakeSubscriber : IGalaxySubscriber
|
|
{
|
|
private int _next;
|
|
public List<int[]> Unsubscribes { get; } = [];
|
|
|
|
public Task<IReadOnlyList<SubscribeResult>> SubscribeBulkAsync(
|
|
IReadOnlyList<string> fullReferences, int bufferedUpdateIntervalMs, CancellationToken cancellationToken)
|
|
=> Task.FromResult<IReadOnlyList<SubscribeResult>>(
|
|
[.. fullReferences.Select(r => new SubscribeResult { TagAddress = r, ItemHandle = ++_next, WasSuccessful = true })]);
|
|
|
|
public Task UnsubscribeBulkAsync(IReadOnlyList<int> itemHandles, CancellationToken cancellationToken)
|
|
{
|
|
Unsubscribes.Add([.. itemHandles]);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync([EnumeratorCancellation] CancellationToken cancellationToken)
|
|
{
|
|
await Task.Delay(Timeout.Infinite, cancellationToken);
|
|
yield break;
|
|
}
|
|
}
|
|
|
|
private static (long Id, string Ref)[] Resolve(SubscriptionRegistry registry, int handle) =>
|
|
[.. registry.ResolveSubscribers(handle)];
|
|
|
|
/// <summary>Synced platforms resolve through the registry under the reserved id; removed ones stop resolving.</summary>
|
|
[Fact]
|
|
public async Task Sync_registers_probe_handles_under_the_reserved_id_and_tracks_removals()
|
|
{
|
|
var registry = new SubscriptionRegistry();
|
|
using var watcher = new PerPlatformProbeWatcher(new FakeSubscriber(), new HostStatusAggregator(), registry);
|
|
|
|
await watcher.SyncPlatformsAsync(["A", "B"], CancellationToken.None);
|
|
|
|
registry.Contains(SubscriptionRegistry.ProbeSubscriptionId).ShouldBeTrue();
|
|
var bindings = watcher.CurrentBindings;
|
|
bindings.Count.ShouldBe(2);
|
|
foreach (var b in bindings)
|
|
{
|
|
Resolve(registry, b.ItemHandle).ShouldBe([(SubscriptionRegistry.ProbeSubscriptionId, b.FullReference)]);
|
|
}
|
|
|
|
var bHandle = bindings.Single(b => b.FullReference == "B.ScanState").ItemHandle;
|
|
await watcher.SyncPlatformsAsync(["A"], CancellationToken.None);
|
|
Resolve(registry, bHandle).ShouldBeEmpty("a removed platform's handle must leave the fan-out map");
|
|
var aHandle = bindings.Single(b => b.FullReference == "A.ScanState").ItemHandle;
|
|
Resolve(registry, aHandle).ShouldHaveSingleItem("positive control: the kept platform still resolves");
|
|
}
|
|
|
|
/// <summary>Resubscribe replaces the handles and rebinds: old handles stop resolving, new ones resolve.</summary>
|
|
[Fact]
|
|
public async Task Resubscribe_rebinds_the_registry_to_the_fresh_handles()
|
|
{
|
|
var registry = new SubscriptionRegistry();
|
|
using var watcher = new PerPlatformProbeWatcher(new FakeSubscriber(), new HostStatusAggregator(), registry);
|
|
await watcher.SyncPlatformsAsync(["A"], CancellationToken.None);
|
|
var oldHandle = watcher.CurrentBindings.Single().ItemHandle;
|
|
|
|
(await watcher.ResubscribeAsync(CancellationToken.None)).ShouldBe(1);
|
|
|
|
var newHandle = watcher.CurrentBindings.Single().ItemHandle;
|
|
newHandle.ShouldNotBe(oldHandle);
|
|
Resolve(registry, oldHandle).ShouldBeEmpty();
|
|
Resolve(registry, newHandle).ShouldBe([(SubscriptionRegistry.ProbeSubscriptionId, "A.ScanState")]);
|
|
registry.TrackedSubscriptionCount.ShouldBe(1);
|
|
}
|
|
|
|
/// <summary>StopAsync unsubscribes the live handles and removes the reserved entry.</summary>
|
|
[Fact]
|
|
public async Task StopAsync_unsubscribes_and_removes_the_reserved_entry()
|
|
{
|
|
var registry = new SubscriptionRegistry();
|
|
var subscriber = new FakeSubscriber();
|
|
var watcher = new PerPlatformProbeWatcher(subscriber, new HostStatusAggregator(), registry);
|
|
await watcher.SyncPlatformsAsync(["A", "B"], CancellationToken.None);
|
|
var handles = watcher.CurrentBindings.Select(b => b.ItemHandle).OrderBy(h => h).ToArray();
|
|
|
|
await watcher.StopAsync(CancellationToken.None);
|
|
|
|
subscriber.Unsubscribes.ShouldHaveSingleItem().OrderBy(h => h).ShouldBe(handles);
|
|
registry.Contains(SubscriptionRegistry.ProbeSubscriptionId).ShouldBeFalse();
|
|
registry.TrackedItemHandleCount.ShouldBe(0);
|
|
await Should.NotThrowAsync(() => watcher.StopAsync(CancellationToken.None)); // idempotent
|
|
}
|
|
|
|
/// <summary>A watcher without a registry (the decoding-table unit tests) still works and registers nothing.</summary>
|
|
[Fact]
|
|
public async Task Without_a_registry_the_watcher_keeps_its_handles_private()
|
|
{
|
|
using var watcher = new PerPlatformProbeWatcher(new FakeSubscriber(), new HostStatusAggregator());
|
|
await watcher.SyncPlatformsAsync(["A"], CancellationToken.None);
|
|
watcher.CurrentBindings.ShouldHaveSingleItem();
|
|
(await watcher.ResubscribeAsync(CancellationToken.None)).ShouldBe(1);
|
|
}
|
|
}
|