fix(twincat): publish BadNodeIdUnknown for unresolvable native-subscribe references (U12)

The native subscribe path silently skipped a reference it could not resolve
(unknown RawPath, or a tag routed to an unconfigured device), leaving the node
on whatever status the server seeded. It now publishes one BadNodeIdUnknown
snapshot, matching the poll path.

Refs #739
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 13:57:01 -04:00
parent 8a4cf8b823
commit de21a54949
2 changed files with 73 additions and 2 deletions
@@ -666,8 +666,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{
foreach (var reference in fullReferences)
{
if (!_resolver.TryResolve(reference, out var def)) continue;
if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
// An unresolvable reference (unknown RawPath, or a tag routed to an unconfigured device)
// publishes BadNodeIdUnknown once — the poll path's status — instead of leaving the node on
// whatever status the server seeded it with.
if (!_resolver.TryResolve(reference, out var def)
|| !_devices.TryGetValue(def.DeviceHostAddress, out var device))
{
RaiseDataChange(handle, reference, new DataValueSnapshot(
null, TwinCATStatusMapper.BadNodeIdUnknown, null, DateTime.UtcNow));
continue;
}
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
@@ -724,6 +732,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
return handle;
}
/// <summary>Raises one data change for a native subscription.</summary>
private void RaiseDataChange(ISubscriptionHandle handle, string reference, DataValueSnapshot snapshot) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, snapshot));
/// <summary>
/// Registers one native notification and records its replayable intent on the device — both
/// under the device <see cref="DeviceState.ConnectGate"/> so the register can't race a
@@ -0,0 +1,59 @@
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);
}
}