f966efcfad
C1 (critical): B6 computes the plan off the pinned thread while B1 made writes non-creating, so a value/status/alarm write for a node the in-flight plan adds was skipped, and change-driven drivers never republish it (BadWaitingForInitialData indefinitely; on a full rebuild every re-subscribe value landed on discarded nodes). OpcUaPublishActor now applies writes immediately AND remembers the latest per (realm, id) while a plan is in flight or queued (value+status compose; alarm state/quality coalesce to the latest, which the node's delta gate makes inert on an existing condition), replays it after each apply before answering, and drops it once the pipeline is idle. Bounded (250k keys by default; beyond it new keys are not held, logged once). I2: the data-plane-authority parse runs in its own try inside the plan task; a throw is logged and never degrades the address-space apply. I3: the SDK's DeleteMonitoredItems releases an item's component-cache reference on a Good delete; a tombstoned item's reference was already released, so the delete took one from the re-created node's entry. Released items are remembered and the orphan delete takes one back first. Event items never held a reference (EventManager + SubscribeToEvents), so the three event-item releases are gone. I4: direct tests for the HistoryUpdate override's per-request updater check and the HistoryReadEvents override's source guard (both reached by restoring the bit after a reset - the B7 race window); fixed the false remark. Minors: the MaterialiseAlarmCondition kind-swap tombstones the dropped instance; a bytes-less rebuild arriving mid-plan queues behind it (replies in order); a PlanReady/PlanFailed from a previous actor incarnation is ignored and its requester answered degraded; the tie-cluster end bound of an uncapped read fits against the backend ceiling (defence in depth - guarded by a sweep test); the lock-time benchmark stays in the default run, headroom noted. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
288 lines
15 KiB
C#
288 lines
15 KiB
C#
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua;
|
|
using Opc.Ua.Client;
|
|
using Shouldly;
|
|
using Xunit;
|
|
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
|
|
|
|
/// <summary>
|
|
/// Review 01 L2, over the wire against the real SDK: a rebuild (or a surgical remove + re-add) that
|
|
/// recreates a NodeId must not leave the SDK's per-NodeId <c>MonitoredNode2</c> — and its component-cache
|
|
/// entry — pointing at the DEAD <c>NodeState</c>. Before the fix, ANY new item on that NodeId, from any
|
|
/// session, bound to the dead node while an older item on it still existed: created <c>Good</c>, fed its
|
|
/// stale initial value, then never again.
|
|
/// <para>
|
|
/// Each test keeps the OLD item alive while creating the NEW one — that is exactly the trap — and
|
|
/// proves the positive path (the new item receives a value written AFTER its creation) before asserting
|
|
/// the old item's <c>BadNodeIdUnknown</c>. Deleting the old item afterwards must succeed too.
|
|
/// </para>
|
|
/// </summary>
|
|
public sealed class StaleMonitoredNodeWireTests
|
|
{
|
|
private const string ServerUri = "urn:OtOpcUa.StaleMonitoredNode";
|
|
private const string Device = "Plant/Modbus/dev1";
|
|
private const string Tag = "Plant/Modbus/dev1/Speed";
|
|
|
|
/// <summary>Rebuild + re-create the same NodeId: the old item is told, a new item on the same NodeId receives values.</summary>
|
|
[Fact]
|
|
public async Task A_new_item_after_a_rebuild_binds_to_the_recreated_node()
|
|
{
|
|
await RunAsync("rebuild", nm =>
|
|
{
|
|
nm.RebuildAddressSpace();
|
|
nm.CompleteAddressSpaceApply();
|
|
Materialise(nm);
|
|
});
|
|
}
|
|
|
|
/// <summary>Surgical remove + re-add of the same NodeId: same contract as the rebuild.</summary>
|
|
[Fact]
|
|
public async Task A_new_item_after_a_surgical_remove_and_readd_binds_to_the_recreated_node()
|
|
{
|
|
await RunAsync("surgical", nm =>
|
|
{
|
|
nm.RemoveVariableNode(Tag, AddressSpaceRealm.Raw).ShouldBeTrue();
|
|
nm.EnsureVariable(Tag, Device, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// The same trap from a SECOND session: a UaExpert operator connecting after a deploy while ScadaBridge's
|
|
/// original item is still alive.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_second_session_binds_to_the_recreated_node_while_the_first_sessions_item_lives()
|
|
{
|
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-2s-{Guid.NewGuid():N}");
|
|
var ct = TestContext.Current.CancellationToken;
|
|
try
|
|
{
|
|
var (server, host, port) = await BootServerAsync(pkiRoot, ServerUri + ".TwoSessions", ct);
|
|
await using var _ = host;
|
|
var nm = server.NodeManager!;
|
|
Materialise(nm);
|
|
nm.WriteValue(Tag, 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
|
|
using var sessionA = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
|
var nodeId = new NodeId(Tag, (ushort)sessionA.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
|
var (subA, oldItem, oldValues, gate) = await SubscribeAsync(sessionA, nodeId, ct);
|
|
await WaitUntilAsync(() => { lock (gate) return oldValues.Count >= 1; }, TimeSpan.FromSeconds(5));
|
|
|
|
nm.RebuildAddressSpace();
|
|
nm.CompleteAddressSpaceApply();
|
|
Materialise(nm);
|
|
nm.WriteValue(Tag, 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
|
|
// Session B arrives AFTER the rebuild, while A's item is still in A's subscription.
|
|
using var sessionB = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
|
var (subB, newItem, newValues, gateB) = await SubscribeAsync(sessionB, nodeId, ct);
|
|
await WaitUntilAsync(() => { lock (gateB) return newValues.Count >= 1; }, TimeSpan.FromSeconds(5));
|
|
|
|
nm.WriteValue(Tag, 3, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
await WaitUntilAsync(() => { lock (gateB) return newValues.Any(v => Equals(v.Value, 3)); }, TimeSpan.FromSeconds(5),
|
|
"the second session's item must receive a value written after its creation");
|
|
|
|
lock (gate) oldValues.ShouldContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown, "the first session's item was told its node went away");
|
|
lock (gateB) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown);
|
|
|
|
subA.RemoveItem(oldItem);
|
|
await subA.ApplyChangesAsync(ct);
|
|
subB.RemoveItem(newItem);
|
|
await subB.ApplyChangesAsync(ct);
|
|
}
|
|
finally
|
|
{
|
|
SafeDelete(pkiRoot);
|
|
}
|
|
}
|
|
|
|
private static async Task RunAsync(string name, Action<OtOpcUaNodeManager> recreate)
|
|
{
|
|
var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-{name}-{Guid.NewGuid():N}");
|
|
var ct = TestContext.Current.CancellationToken;
|
|
try
|
|
{
|
|
var (server, host, port) = await BootServerAsync(pkiRoot, $"{ServerUri}.{name}", ct);
|
|
await using var _ = host;
|
|
var nm = server.NodeManager!;
|
|
Materialise(nm);
|
|
nm.WriteValue(Tag, 41, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
|
|
using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct);
|
|
var nodeId = new NodeId(Tag, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri));
|
|
var (subscription, oldItem, oldValues, gate) = await SubscribeAsync(session, nodeId, ct);
|
|
await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => Equals(v.Value, 41)); }, TimeSpan.FromSeconds(5));
|
|
nm.IsNodeMonitored(nodeId).ShouldBeTrue("non-vacuity: the SDK really holds a fan-out for the node");
|
|
|
|
// The node goes away and comes back under the same NodeId; a fresh value lands on the NEW node.
|
|
recreate(nm);
|
|
nm.WriteValue(Tag, 42, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
|
|
// A NEW item on the same NodeId, in the same subscription, with the old one still present.
|
|
var newValues = new List<DataValue>();
|
|
var newItem = new MonitoredItem(subscription.DefaultItem)
|
|
{
|
|
StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10,
|
|
};
|
|
newItem.Notification += (item, _) => { foreach (var v in item.DequeueValues()) lock (gate) newValues.Add(v); };
|
|
subscription.AddItem(newItem);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
newItem.Status.Error.ShouldBeNull();
|
|
|
|
// Positive control: the initial value is the NEW node's, and a value written afterwards arrives.
|
|
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5),
|
|
"the new item's initial read must come from the re-created node");
|
|
nm.WriteValue(Tag, 43, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 43)); }, TimeSpan.FromSeconds(5),
|
|
"the new item must receive a value written after its creation");
|
|
lock (gate) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown);
|
|
|
|
// Review fix I3: the cache entry the new item holds is ITS reference alone — the old item's was released
|
|
// by the removal.
|
|
CacheRefCount(nm, nodeId).ShouldBe(1, "one live item, one component-cache reference");
|
|
|
|
// The old item was told, and the dead node's fan-out is gone.
|
|
await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); }, TimeSpan.FromSeconds(5),
|
|
"the item on the removed node must be told BadNodeIdUnknown");
|
|
nm.IsNodeMonitored(nodeId).ShouldBeTrue("the NEW item's fan-out exists for the re-created node");
|
|
|
|
// The old item's delete completes — its node is gone, not its right to be deleted.
|
|
subscription.RemoveItem(oldItem);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
(oldItem.Status.Error is null || ServiceResult.IsGood(oldItem.Status.Error)).ShouldBeTrue(
|
|
$"deleting the orphaned item must succeed, got {oldItem.Status.Error}");
|
|
|
|
// The SDK releases a deleted item's cache reference on a Good delete; the old item's was released by the
|
|
// removal already, so a second release would evict the NEW item's live entry (review fix I3).
|
|
CacheRefCount(nm, nodeId).ShouldBe(1, "deleting the orphaned item must not take the new item's reference");
|
|
|
|
// And the new item is unaffected by that delete.
|
|
nm.WriteValue(Tag, 44, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw);
|
|
await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 44)); }, TimeSpan.FromSeconds(5));
|
|
|
|
// Positive control: deleting the live item gives its reference back.
|
|
subscription.RemoveItem(newItem);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
CacheRefCount(nm, nodeId).ShouldBe(0);
|
|
}
|
|
finally
|
|
{
|
|
SafeDelete(pkiRoot);
|
|
}
|
|
}
|
|
|
|
/// <summary>The SDK's component-cache reference count for <paramref name="nodeId"/> (private SDK state, read by
|
|
/// reflection: <c>CustomNodeManager2.m_componentCache</c> → <c>CacheEntry.RefCount</c>); 0 when no entry.</summary>
|
|
private static int CacheRefCount(OtOpcUaNodeManager nm, NodeId nodeId)
|
|
{
|
|
const System.Reflection.BindingFlags Any =
|
|
System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic;
|
|
var field = typeof(Opc.Ua.Server.CustomNodeManager2).GetField("m_componentCache", Any)
|
|
.ShouldNotBeNull("the SDK's component cache field (SDK internals moved?)");
|
|
lock (nm.Lock)
|
|
{
|
|
if (field.GetValue(nm) is not System.Collections.IDictionary cache || !cache.Contains(nodeId)) return 0;
|
|
var entry = cache[nodeId]!;
|
|
var member = (object?)entry.GetType().GetField("RefCount", Any) ?? entry.GetType().GetProperty("RefCount", Any);
|
|
return member switch
|
|
{
|
|
System.Reflection.FieldInfo f => (int)f.GetValue(entry)!,
|
|
System.Reflection.PropertyInfo p => (int)p.GetValue(entry)!,
|
|
_ => throw new InvalidOperationException("CacheEntry.RefCount not found"),
|
|
};
|
|
}
|
|
}
|
|
|
|
private static void Materialise(OtOpcUaNodeManager nm)
|
|
{
|
|
nm.EnsureFolder(Device, null, "dev1", AddressSpaceRealm.Raw);
|
|
nm.EnsureVariable(Tag, Device, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw);
|
|
}
|
|
|
|
private static async Task<(Subscription Subscription, MonitoredItem Item, List<DataValue> Values, object Gate)> SubscribeAsync(
|
|
ISession session, NodeId nodeId, CancellationToken ct)
|
|
{
|
|
var values = new List<DataValue>();
|
|
var gate = new object();
|
|
var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 };
|
|
session.AddSubscription(subscription);
|
|
await subscription.CreateAsync(ct);
|
|
var item = new MonitoredItem(subscription.DefaultItem)
|
|
{
|
|
StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10,
|
|
};
|
|
item.Notification += (i, _) => { foreach (var v in i.DequeueValues()) lock (gate) values.Add(v); };
|
|
subscription.AddItem(item);
|
|
await subscription.ApplyChangesAsync(ct);
|
|
return (subscription, item, values, gate);
|
|
}
|
|
|
|
private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host, int Port)> BootServerAsync(
|
|
string pkiRoot, string appUri, CancellationToken ct)
|
|
{
|
|
var boundPort = 0;
|
|
var started = await OpcUaHostStart.StartAsync(
|
|
port =>
|
|
{
|
|
boundPort = port;
|
|
return new OpcUaApplicationHost(
|
|
new OpcUaApplicationHostOptions
|
|
{
|
|
ApplicationName = appUri,
|
|
ApplicationUri = appUri,
|
|
OpcUaPort = port,
|
|
PublicHostname = "127.0.0.1",
|
|
PkiStoreRoot = pkiRoot,
|
|
EnabledSecurityProfiles = new List<OpcUaSecurityProfile> { OpcUaSecurityProfile.None },
|
|
AutoAcceptUntrustedClientCertificates = true,
|
|
},
|
|
NullLogger<OpcUaApplicationHost>.Instance);
|
|
},
|
|
() => new OtOpcUaSdkServer(), ct);
|
|
return (started.Server, started.Host, boundPort);
|
|
}
|
|
|
|
private static async Task<ISession> OpenSessionAsync(string endpointUrl, CancellationToken ct)
|
|
{
|
|
var appConfig = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = "OtOpcUa.StaleMonitoredNodeClient",
|
|
ApplicationUri = $"urn:OtOpcUa.StaleMonitoredNodeClient.{Guid.NewGuid():N}",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()),
|
|
ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 },
|
|
};
|
|
await appConfig.ValidateAsync(ApplicationType.Client, ct);
|
|
appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true;
|
|
|
|
var endpoint = await CoreClientUtils.SelectEndpointAsync(
|
|
appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct);
|
|
var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig));
|
|
|
|
return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync(
|
|
appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false,
|
|
sessionName: "StaleMonitoredNodeWireTests", sessionTimeout: 60_000,
|
|
identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct);
|
|
}
|
|
|
|
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout, string? because = null)
|
|
{
|
|
var deadline = DateTime.UtcNow + timeout;
|
|
while (DateTime.UtcNow < deadline)
|
|
{
|
|
if (condition()) return;
|
|
await Task.Delay(50);
|
|
}
|
|
condition().ShouldBeTrue(because ?? "condition not met within timeout");
|
|
}
|
|
|
|
private static void SafeDelete(string dir)
|
|
{
|
|
if (!Directory.Exists(dir)) return;
|
|
try { Directory.Delete(dir, recursive: true); }
|
|
catch { /* best-effort */ }
|
|
}
|
|
}
|