17f4bf6d33
Native subscribe was all-or-nothing: any registration failure other than a type refusal disposed every registration and rethrew, so one typo'd symbol or one unreachable PLC in a multi-device driver served zero data, churned register/delete every reconcile tick, and left health Healthy. Now each registration fails on its own: a refusal (the device answered: symbol not found, PLC not ready, type mismatch) publishes its status with no health write; any other per-tag failure publishes BadCommunicationError and degrades; a connect failure marks the device unreachable for the rest of the call and stores its tags as dead intents. Every failed tag is kept as a dead intent that the probe-tick retry and the reconnect replay re-register. The ADS client now classifies a failed symbol resolve / AddDeviceNotification by status instead of throwing InvalidOperationException for all of them. Cancellation still tears the partial subscription down. Refs #739 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
226 lines
12 KiB
C#
226 lines
12 KiB
C#
using System.Collections.Concurrent;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
|
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
|
|
|
/// <summary>
|
|
/// Native-subscribe isolation (#739): an unresolvable reference publishes <c>BadNodeIdUnknown</c>
|
|
/// (U12) instead of keeping whatever status the server seeded, and one failing registration does not
|
|
/// take down the rest of the subscription (H11).
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class TwinCATNativeIsolationTests
|
|
{
|
|
private const string Host = "ads://5.23.91.23.1.1:851";
|
|
|
|
private static TwinCATDriver NewNativeDriver(FakeTwinCATClientFactory factory, params TwinCATTagDefinition[] tags) =>
|
|
new(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions(Host)],
|
|
RawTags = TwinCATRawTags.Entries(tags),
|
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
|
UseNativeNotifications = true,
|
|
}, "drv-1", factory);
|
|
|
|
/// <summary>
|
|
/// U12: an unknown reference and a tag routed to an unconfigured device each publish one
|
|
/// <c>BadNodeIdUnknown</c> snapshot; the resolvable tag still registers and delivers Good (positive control).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Native_subscribe_publishes_BadNodeIdUnknown_for_unresolvable_references()
|
|
{
|
|
var factory = new FakeTwinCATClientFactory();
|
|
var drv = NewNativeDriver(factory,
|
|
new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt),
|
|
new TwinCATTagDefinition("Orphan", "ads://9.9.9.9.1.1:851", "MAIN.Orphan", TwinCATDataType.DInt));
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
|
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
|
|
|
_ = await drv.SubscribeAsync(["Ghost", "Speed", "Orphan"], TimeSpan.FromMilliseconds(100),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
var ghost = events.Single(e => e.FullReference == "Ghost");
|
|
ghost.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
|
ghost.Snapshot.Value.ShouldBeNull();
|
|
events.Single(e => e.FullReference == "Orphan").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
|
|
|
// Positive control: the resolvable tag registered and its push arrives Good.
|
|
factory.Clients.ShouldHaveSingleItem().Notifications.ShouldHaveSingleItem().SymbolPath.ShouldBe("MAIN.Speed");
|
|
factory.Clients[0].FireNotification("MAIN.Speed", 42);
|
|
var good = events.Single(e => e.FullReference == "Speed");
|
|
good.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
|
good.Snapshot.Value.ShouldBe(42);
|
|
}
|
|
|
|
/// <summary>
|
|
/// H11: one tag whose AddDeviceNotification round-trip fails (not a refusal) goes Bad on its own and
|
|
/// the subscribe call SUCCEEDS — before, the exception disposed every registration and rethrew, so one
|
|
/// bad tag served zero data for the whole driver. The good tags keep delivering (positive control).
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task One_failing_registration_does_not_tear_down_the_subscription()
|
|
{
|
|
var fake = new FakeTwinCATClient();
|
|
fake.AddNotificationErrors["MAIN.B"] = new InvalidOperationException("AddDeviceNotificationEx blew up");
|
|
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
|
|
var drv = NewNativeDriver(factory,
|
|
new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
|
|
new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt),
|
|
new TwinCATTagDefinition("C", Host, "MAIN.C", TwinCATDataType.DInt));
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
|
|
|
var handle = await drv.SubscribeAsync(["A", "B", "C"], TimeSpan.FromMilliseconds(100),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
fake.Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A", "MAIN.C"], ignoreOrder: true);
|
|
events.ShouldHaveSingleItem().FullReference.ShouldBe("B");
|
|
events.Single().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
|
|
|
|
fake.FireNotification("MAIN.C", 3);
|
|
events.Last().FullReference.ShouldBe("C");
|
|
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
|
|
|
// The failed tag is kept as a dead intent and unsubscribe still removes everything.
|
|
drv.GetDeviceState(Host)!.NativeRegistrations.Count.ShouldBe(3);
|
|
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
|
drv.GetDeviceState(Host)!.NativeRegistrations.ShouldBeEmpty();
|
|
fake.Notifications.ShouldBeEmpty();
|
|
}
|
|
|
|
/// <summary>
|
|
/// H11: a refusal (symbol not found — the device answered) isolates the tag with the refusal's own
|
|
/// status and leaves driver health alone; the good tag delivers.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_refused_symbol_is_isolated_without_a_health_write()
|
|
{
|
|
var fake = new FakeTwinCATClient();
|
|
fake.AddNotificationErrors["MAIN.Typo"] = new TwinCATNotificationRefusedException(
|
|
"MAIN.Typo", "resolving the symbol failed", TwinCATStatusMapper.BadNodeIdUnknown);
|
|
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
|
|
var drv = NewNativeDriver(factory,
|
|
new TwinCATTagDefinition("Typo", Host, "MAIN.Typo", TwinCATDataType.DInt),
|
|
new TwinCATTagDefinition("Good", Host, "MAIN.Good", TwinCATDataType.DInt));
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
|
|
|
_ = await drv.SubscribeAsync(["Typo", "Good"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
|
|
|
|
events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
fake.FireNotification("MAIN.Good", 1);
|
|
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
|
}
|
|
|
|
/// <summary>
|
|
/// H11: in a two-device driver an unreachable device only blacks out ITS tags (Bad + dead intents,
|
|
/// health Degraded); the reachable device's tags register and deliver.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task An_unreachable_device_blacks_out_only_its_own_tags()
|
|
{
|
|
const string hostB = "ads://5.23.91.24.1.1:851";
|
|
var build = 0;
|
|
var factory = new FakeTwinCATClientFactory
|
|
{
|
|
// Device A's client is created first and works; device B's connect throws.
|
|
Customise = () => new FakeTwinCATClient { ThrowOnConnect = ++build == 2 },
|
|
};
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions(Host), new TwinCATDeviceOptions(hostB)],
|
|
RawTags = TwinCATRawTags.Entries(
|
|
[
|
|
new TwinCATTagDefinition("A1", Host, "MAIN.A1", TwinCATDataType.DInt),
|
|
new TwinCATTagDefinition("B1", hostB, "MAIN.B1", TwinCATDataType.DInt),
|
|
new TwinCATTagDefinition("B2", hostB, "MAIN.B2", TwinCATDataType.DInt),
|
|
new TwinCATTagDefinition("A2", Host, "MAIN.A2", TwinCATDataType.DInt),
|
|
]),
|
|
Probe = new TwinCATProbeOptions { Enabled = false },
|
|
UseNativeNotifications = true,
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
|
|
|
_ = await drv.SubscribeAsync(["A1", "B1", "B2", "A2"], TimeSpan.FromMilliseconds(100),
|
|
TestContext.Current.CancellationToken);
|
|
|
|
factory.Clients.Count.ShouldBe(2); // B2 did not attempt a second connect
|
|
factory.Clients[0].Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A1", "MAIN.A2"], ignoreOrder: true);
|
|
events.Select(e => e.FullReference).ShouldBe(["B1", "B2"], ignoreOrder: true);
|
|
events.ShouldAllBe(e => e.Snapshot.StatusCode == TwinCATStatusMapper.BadCommunicationError);
|
|
drv.GetDeviceState(hostB)!.NativeRegistrations.Count.ShouldBe(2);
|
|
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
|
|
|
|
factory.Clients[0].FireNotification("MAIN.A2", 22);
|
|
events.Last().FullReference.ShouldBe("A2");
|
|
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
|
}
|
|
|
|
/// <summary>
|
|
/// H11: the dead intent left by a failed registration is re-registered by the probe-tick retry, and
|
|
/// its first push then arrives Good — the subscription heals without a re-subscribe.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_failed_registration_is_recovered_by_the_probe_tick_retry()
|
|
{
|
|
var fake = new FakeTwinCATClient();
|
|
fake.AddNotificationErrors["MAIN.B"] = new InvalidOperationException("transient");
|
|
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
|
|
var drv = new TwinCATDriver(new TwinCATDriverOptions
|
|
{
|
|
Devices = [new TwinCATDeviceOptions(Host)],
|
|
RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt)]),
|
|
Probe = new TwinCATProbeOptions
|
|
{
|
|
Enabled = true,
|
|
Interval = TimeSpan.FromMilliseconds(40),
|
|
Timeout = TimeSpan.FromMilliseconds(100),
|
|
},
|
|
UseNativeNotifications = true,
|
|
}, "drv-1", factory);
|
|
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
|
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
|
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
|
|
|
_ = await drv.SubscribeAsync(["B"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
|
|
fake.AddNotificationErrors.Clear();
|
|
|
|
await WaitForAsync(() => fake.Notifications.Count == 1, TimeSpan.FromSeconds(3));
|
|
fake.Notifications.Count.ShouldBe(1);
|
|
fake.FireNotification("MAIN.B", 5);
|
|
events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
|
|
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
|
}
|
|
|
|
/// <summary>A comms-class registration status stays an exception; any other status is a per-tag refusal.</summary>
|
|
[Fact]
|
|
public void Registration_failures_are_classified_by_status()
|
|
{
|
|
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadNodeIdUnknown)
|
|
.ShouldBeOfType<TwinCATNotificationRefusedException>().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
|
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadOutOfService)
|
|
.ShouldBeOfType<TwinCATNotificationRefusedException>();
|
|
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadCommunicationError)
|
|
.ShouldBeOfType<InvalidOperationException>();
|
|
AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadTimeout)
|
|
.ShouldBeOfType<InvalidOperationException>();
|
|
}
|
|
|
|
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (!condition() && DateTime.UtcNow < deadline)
|
|
await Task.Delay(20);
|
|
}
|
|
}
|