Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/ConditionEventWalkGuardTests.cs
T
Joseph Doherty 7482a88888 fix(opcua): deferred condition events never outlive their condition; per-condition bounded deferral; canonical lockout identity (#838, #844 review)
#838 review of 91e28e87:
1. Ghost events. The deferred report ran the SDK walk off Lock with no
   liveness check, so an event waiting behind a full channel when a deploy
   removed its condition still reached i=2253 subscribers after the NodeDeleted
   (a ghost Retain alarm for ScadaBridge) and folder items. Delivery now walks
   the SDK path itself (OnReportEvent, OnReportEventAsync, inverse notifiers,
   then the Parent as BaseInstanceState does) re-checking before EVERY hop that
   the condition is the live instance; OnConditionReportEvent checks the same,
   which also stops a non-live scripted condition being admitted through its
   old equipment folder (#808). A guard test pins that only NodeState and
   BaseInstanceState define the walk. New wire test removes the condition in
   the same Lock hold as a flood; the flag-off variant fails without the checks.
2. Back-pressure. Deferral is per condition (one condition's events stay
   ordered; others are not held up). Overflow policy: coalesce, newest wins --
   past 1024 waiting per condition or 65,536 total, the oldest not-yet-started
   event is discarded (a condition event carries full state). Warning on
   entering deferral (<=1/min), Information on draining, meters
   otopcua.opcua.condition_events.deferred / .discarded{reason}.
3. Verified by decompiling SDK 2.0.1: the notifier list is snapshotted under the
   node's own lock, MonitoredNode2.Dispose completes its writer (pending writes
   fail with ChannelClosedException, swallowed), and the consumer catches
   per-event exceptions -- no fix needed; documented.
4. ConditionRefresh does not flush deferrals; documented as a residual.

#844 review: DescribeCredential canonicalises the key id (trim, lowercase;
non-hex ids fold into one identity) and the username, since ConfigDbApiKeyStore
matches with SQL Server's case-insensitive, trailing-space-blind `=`.
2026-11-11 04:35:30 -05:00

42 lines
1.9 KiB
C#

using System.Reflection;
using Opc.Ua;
using Opc.Ua.Server;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
/// <summary>
/// Gitea #838 review: <c>OtOpcUaNodeManager.DeliverConditionEventAsync</c> re-implements the SDK's event walk
/// (<c>NodeState.ReportEventAsync</c>, plus <c>BaseInstanceState</c>'s forward to its Parent) so it can re-check the
/// condition's liveness before every hop. That copy is only faithful while no other node type changes the walk —
/// missing the Parent hop alone silently stopped condition events reaching their folders. This pins the set of
/// types that define <c>ReportEvent</c> / <c>ReportEventAsync</c> across the SDK and this server.
/// </summary>
public sealed class ConditionEventWalkGuardTests
{
[Fact]
public void Only_NodeState_and_BaseInstanceState_define_the_event_walk()
{
Assembly[] assemblies =
[
typeof(NodeState).Assembly, // Opc.Ua.Types
typeof(ApplicationConfiguration).Assembly, // Opc.Ua.Core
typeof(StandardServer).Assembly, // Opc.Ua.Server
typeof(OtOpcUaNodeManager).Assembly, // this server
];
var definers = assemblies.Distinct()
.SelectMany(a => a.GetTypes())
.Where(t => typeof(NodeState).IsAssignableFrom(t))
.Where(t => t.GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.DeclaredOnly)
.Any(m => m.Name is nameof(NodeState.ReportEvent) or nameof(NodeState.ReportEventAsync)))
.Select(t => t.FullName)
.Order()
.ToArray();
definers.ShouldBe(["Opc.Ua.BaseInstanceState", "Opc.Ua.NodeState"],
"a new override changes the event walk — mirror it in OtOpcUaNodeManager.DeliverConditionEventAsync");
}
}