Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/EventQueueOverflowAclWireTests.cs
T
Joseph Doherty 4bb9c453a3 test(opcua): event-queue overflow with the per-folder/Server-object ACL active (#838 item 2)
EventQueueOverflowAclWireTests drives >4096 condition events at a folder notifier
item and a Server-object (i=2253) item with Security:Acl enforcement on, a
non-publishing client, and a second session reading a node-manager node throughout:

- denied (EqA-owned scripted) events interleaved with allowed native ones, under one
  Lock hold and per-projection: no deadlock, reads stay bounded, deferral peak stays
  within 2 x (MaxDeferredEventsPerCondition + 1), delivery <= item QueueSize, oldest
  dropped / newest kept in report order, one EventQueueOverflowEventType published ahead
  of the survivors (SDK DiscardOldest, Part 4 5.13.1.5), zero denied events delivered;
- a grant revoked (state.Apply -> revalidation, re-entering Lock) while a deferred tail
  waits behind a full folder channel: neither the tail nor later events reach the
  revoked session's folder or Server-object item; a still-granted session on the same
  channel receives all of it.

Falsified: with enforcement off (and with the revocation removed) all three cases fail.
No production defect found.

Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
2026-11-11 10:35:12 -05:00

350 lines
18 KiB
C#

using System.Diagnostics;
using System.Globalization;
using Opc.Ua;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Core.Authorization;
namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests;
/// <summary>
/// Gitea #838 item 2 (residual risk of #826): the event-delivery path holds the node manager's <c>Lock</c> while
/// queuing, and the SDK's bounded queues (the 4096-slot per-notifier <c>MonitoredNode2</c> channel and the per-item
/// event queue) overflow under it. <see cref="FolderEventFloodWireTests"/> pins the deadlock fix for a folder item
/// with one allowed condition; these tests drive the same overflow with ACL filtering doing real work — a denied
/// condition interleaved with an allowed one, Server-object and folder items, a non-publishing client, a second
/// session reading throughout, and a grant revoked while a deferred backlog waits — and assert:
/// <list type="bullet">
/// <item>no deadlock, and another session's Read of a node-manager node stays bounded;</item>
/// <item>memory stays within the documented caps (deferral ≤ 1024 + 1 per condition; item queue ≤ QueueSize);</item>
/// <item>the item-queue overflow is the SDK-documented one (Part 4 5.13.1.5, DiscardOldest = true): oldest
/// dropped, newest kept, one <c>EventQueueOverflowEventType</c> event published AHEAD of the survivors;</item>
/// <item>no ACL-denied event reaches an item during the overflow, including deferred events whose grant was
/// revoked while they waited.</item>
/// </list>
/// </summary>
public sealed class EventQueueOverflowAclWireTests
{
private const string EqA = ServerEventRig.EqA;
private const string EqB = ServerEventRig.EqB;
// Native events per flood: past the SDK's 4096-slot per-notifier channel, so the native condition defers.
private const int PerCondition = 6000;
/// <param name="singleHold">
/// <see langword="true"/>: the whole flood under ONE hold of <c>Lock</c> (the deadlock shape — the folder
/// channel's consumer needs <c>Lock</c> for its first verdict, so the native tail defers). <see langword="false"/>:
/// each projection takes <c>Lock</c> itself (the driver/engine shape), so a concurrent Read interleaves with it.
/// </param>
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task An_overflowing_flood_with_denied_events_interleaved_stays_live_bounded_and_never_leaks(bool singleHold)
{
var ct = TestContext.Current.CancellationToken;
var rig = await ServerEventRig.StartAsync("Overflow838Acl", encrypted: false, ct);
var hung = false;
ISession? keyed = null, anonymous = null;
try
{
rig.KeyRoles = [];
rig.MaterialiseNative(); // wired into EqA and EqB
rig.MaterialiseScripted(); // owned by EqA
// The key sees EqB only: the native alarm (through EqB) is allowed, the EqA-owned scripted alarm is DENIED.
// Anonymous sees EqA: both alarms — the positive control that denied events really were being reported.
rig.Enforce(
true, [ServerEventRig.Key()],
KeyEquipmentGrant(EqB), ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly));
keyed = await rig.ConnectAsync(anonymous: false, ct);
anonymous = await rig.ConnectAsync(anonymous: true, ct);
var keyEvents = new RecordedEvents();
var keySub = await RecordedEvents.SubscribeAsync(keyed, keyEvents, ct);
var onB = RecordedEvents.AddEventItem(keySub, ServerEventRig.UnsNodeId(keyed, EqB));
var keyServer = RecordedEvents.AddEventItem(keySub, ObjectIds.Server);
await keySub.ApplyChangesAsync(ct);
onB.Status.Error.ShouldBeNull();
keyServer.Status.Error.ShouldBeNull();
// The slow consumer: nothing is published, so the server-side item queues are the only sink.
await keySub.SetPublishingModeAsync(false, ct);
var anonEvents = new RecordedEvents();
var anonSub = await RecordedEvents.SubscribeAsync(anonymous, anonEvents, ct);
var anonServer = RecordedEvents.AddEventItem(anonSub, ObjectIds.Server);
await anonSub.ApplyChangesAsync(ct);
anonServer.Status.Error.ShouldBeNull();
// Sample the deferral depth throughout (the memory the node manager itself holds for the overflow).
using var sampling = CancellationTokenSource.CreateLinkedTokenSource(ct);
var peakDeferred = 0;
var sampler = Task.Run(async () =>
{
while (!sampling.IsCancellationRequested)
{
var now = rig.Nm.DeferredEventReports;
if (now > Volatile.Read(ref peakDeferred)) Volatile.Write(ref peakDeferred, now);
await Task.Delay(1, CancellationToken.None);
}
}, CancellationToken.None);
// Another session reads a node-manager node (the EqA folder's DisplayName: Read takes Lock) all through the flood.
var eqA = ServerEventRig.UnsNodeId(anonymous, EqA);
var readLatencies = new List<TimeSpan>();
var flooding = true;
var reader = Task.Run(async () =>
{
while (Volatile.Read(ref flooding))
{
readLatencies.Add(await TimedReadAsync(anonymous, eqA, ct));
await Task.Delay(20, ct);
}
}, ct);
var flood = Task.Factory.StartNew(() =>
{
void Burst()
{
for (var i = 0; i < PerCondition; i++)
{
rig.Project(native: true, State($"n{i}"));
rig.Project(native: false, State($"s{i}"));
}
}
if (singleHold)
{
lock (rig.Nm.Lock) Burst();
}
else
{
Burst();
}
}, ct, TaskCreationOptions.LongRunning, TaskScheduler.Default);
try
{
await flood.WaitAsync(TimeSpan.FromSeconds(60), ct);
}
catch (TimeoutException)
{
hung = true;
throw new ShouldAssertException("the overflowing flood stalled under the node manager's Lock (deadlock)");
}
Volatile.Write(ref flooding, false);
await reader.WaitAsync(TimeSpan.FromSeconds(60), ct);
await RecordedEvents.WaitUntilAsync(() => rig.Nm.DeferredEventReports == 0);
await sampling.CancelAsync();
await sampler;
// Liveness: every read issued during the flood came back. While one caller holds Lock across the whole flood
// a read waits for that hold (the caller's choice, not a wedge); otherwise each read is short.
readLatencies.ShouldNotBeEmpty();
if (!singleHold)
readLatencies.Max().ShouldBeLessThan(TimeSpan.FromSeconds(5), "a Read must not queue behind the whole flood");
(await TimedReadAsync(anonymous, eqA, ct)).ShouldBeLessThan(TimeSpan.FromSeconds(2), "the node manager is free afterwards");
// Memory: the node manager's own overflow store stayed within its per-condition budget (queued + in flight)…
Volatile.Read(ref peakDeferred).ShouldBeLessThanOrEqualTo(
2 * (OtOpcUaNodeManager.MaxDeferredEventsPerCondition + 1), "deferral is bounded per condition");
if (singleHold) rig.Nm.DiscardedEventReports.ShouldBeGreaterThan(0, "the native tail overflowed and coalesced");
// …let the SDK channel finish draining into the (still unpublished) item queues, then publish.
await Task.Delay(TimeSpan.FromSeconds(2), ct);
await keySub.SetPublishingModeAsync(true, ct);
var lastNative = $"n{PerCondition - 1}";
await RecordedEvents.WaitUntilAsync(() => keyEvents.Has(onB, lastNative) && keyEvents.Has(keyServer, lastNative));
await Task.Delay(TimeSpan.FromMilliseconds(500), ct);
foreach (var item in new[] { onB, keyServer })
{
var received = keyEvents.Of(item);
// ACL: not one event of the denied (EqA-owned) scripted alarm, overflow or not. (Under a single hold the
// deferred native tail drains last and evicts every synchronously-queued event from the 1000-slot queue, so
// there this check cannot see a leak; the "after-s" leg below is the falsifier for that mode — verified by
// running with enforcement off: chunked fails here, single-hold fails at "after-s".)
received.Where(f => RecordedEvents.Message(f)?.StartsWith('s') == true)
.ShouldBeEmpty($"{item.DisplayName}: an ACL-denied event leaked during the overflow");
// SDK overflow (DiscardOldest): bounded by the item queue, oldest dropped, newest kept, in report order…
var natives = Indices(received, 'n');
natives.Count.ShouldBeGreaterThan(0);
natives.Count.ShouldBeLessThanOrEqualTo((int)item.Status.QueueSize, "bounded by the item's queue, not the flood");
natives.ShouldBe(natives.Order().ToList(), "survivors leave in report order");
natives[^1].ShouldBe(PerCondition - 1, "the newest event survives");
natives[0].ShouldBeGreaterThan(0, "the oldest events were the ones dropped");
// …and the loss is announced with an EventQueueOverflowEventType event placed ahead of the survivors.
var overflowAt = IndexOfType(received, ObjectTypeIds.EventQueueOverflowEventType);
overflowAt.ShouldBeGreaterThanOrEqualTo(0, $"{item.DisplayName}: no EventQueueOverflowEvent was published");
overflowAt.ShouldBeLessThan(
received.ToList().FindIndex(f => RecordedEvents.Message(f)?.StartsWith('n') == true),
"the overflow event precedes the surviving events (DiscardOldest)");
}
// The server is live and still deciding per event afterwards — and the positive control: the alarm denied to
// the key reaches the session allowed to see it. (Not asserted on the flood itself: the anonymous item's own
// 1000-slot queue may evict any particular flood event before it publishes, by the same DiscardOldest rule.)
rig.Project(native: false, State("after-s"));
rig.Project(native: true, State("after-n"));
await RecordedEvents.WaitUntilAsync(() =>
keyEvents.Has(onB, "after-n") && keyEvents.Has(keyServer, "after-n") && anonEvents.Has(anonServer, "after-s"));
await Task.Delay(TimeSpan.FromMilliseconds(300), ct);
keyEvents.Has(keyServer, "after-s").ShouldBeFalse();
}
finally
{
// A hung rig cannot be disposed (its Lock is held by a blocked thread); leak it rather than hang the run.
if (!hung)
{
keyed?.Dispose();
anonymous?.Dispose();
await rig.DisposeAsync();
}
}
}
/// <summary>
/// A grant revoked while a deferred backlog waits behind a full notifier channel: the deferred events are decided
/// at delivery against the CURRENT generation, so none of them — nor any event reported after the revocation —
/// reaches the revoked session's folder or Server-object item, while a still-granted session sharing the same
/// folder channel receives the whole tail (positive control).
/// </summary>
[Fact]
public async Task A_grant_revoked_while_overflowed_events_are_deferred_stops_them_all()
{
var ct = TestContext.Current.CancellationToken;
var rig = await ServerEventRig.StartAsync("Overflow838Revoke", encrypted: false, ct);
var hung = false;
ISession? keyed = null, anonymous = null;
try
{
rig.KeyRoles = [];
rig.MaterialiseNative();
var anonGrant = ServerEventRig.EquipmentGrant(EqA, NodePermissions.ReadOnly);
var state = rig.Enforce(true, [ServerEventRig.Key()], KeyEquipmentGrant(EqA), anonGrant);
keyed = await rig.ConnectAsync(anonymous: false, ct);
anonymous = await rig.ConnectAsync(anonymous: true, ct);
var keyEvents = new RecordedEvents();
var keySub = await RecordedEvents.SubscribeAsync(keyed, keyEvents, ct);
var keyA = RecordedEvents.AddEventItem(keySub, ServerEventRig.UnsNodeId(keyed, EqA));
var keyServer = RecordedEvents.AddEventItem(keySub, ObjectIds.Server);
await keySub.ApplyChangesAsync(ct);
keyA.Status.Error.ShouldBeNull();
keyServer.Status.Error.ShouldBeNull();
await keySub.SetPublishingModeAsync(false, ct);
var anonEvents = new RecordedEvents();
var anonSub = await RecordedEvents.SubscribeAsync(anonymous, anonEvents, ct);
var anonA = RecordedEvents.AddEventItem(anonSub, ServerEventRig.UnsNodeId(anonymous, EqA));
var anonServer = RecordedEvents.AddEventItem(anonSub, ObjectIds.Server);
await anonSub.ApplyChangesAsync(ct);
anonA.Status.Error.ShouldBeNull();
var flood = Task.Factory.StartNew(() =>
{
lock (rig.Nm.Lock)
{
for (var i = 0; i < PerCondition; i++) rig.Project(native: true, State($"n{i}"));
rig.Nm.DeferredEventReports.ShouldBeGreaterThan(0, "the flood must actually have deferred events");
// The deploy that revokes the key's grant lands while the tail waits (its Changed → revalidation runs
// here, re-entering Lock), then one more event is reported.
state.Apply(
ServerEventRig.Cluster, "rev-2", [ServerEventRig.Key()], [anonGrant], ServerEventRig.ScopePaths(),
ServerEventRig.EquipmentOwnerPaths(), ServerEventRig.ScriptedAlarmOwners(),
ServerEventRig.FolderScopes(), ServerEventRig.TagReferences());
rig.Project(native: true, State("after-revoke"));
}
}, ct, TaskCreationOptions.LongRunning, TaskScheduler.Default);
try
{
await flood.WaitAsync(TimeSpan.FromSeconds(60), ct);
}
catch (TimeoutException)
{
hung = true;
throw new ShouldAssertException("revoking a grant under Lock while events were deferred stalled (deadlock)");
}
await RecordedEvents.WaitUntilAsync(() => rig.Nm.DeferredEventReports == 0);
await Task.Delay(TimeSpan.FromSeconds(2), ct);
await keySub.SetPublishingModeAsync(true, ct);
// Positive control: the still-granted session on the SAME folder channel gets the whole deferred tail.
var lastNative = $"n{PerCondition - 1}";
await RecordedEvents.WaitUntilAsync(() =>
anonEvents.Has(anonA, lastNative) && anonEvents.Has(anonA, "after-revoke")
&& anonEvents.Has(anonServer, lastNative) && anonEvents.Has(anonServer, "after-revoke"));
await Task.Delay(TimeSpan.FromSeconds(2), ct);
foreach (var item in new[] { keyA, keyServer })
{
// Events queued to the item while the grant held may still be published (they were authorized when
// delivered); the deferred tail and everything after the revocation must not be.
keyEvents.Has(item, lastNative).ShouldBeFalse($"{item.DisplayName}: a deferred event leaked past the revocation");
keyEvents.Has(item, "after-revoke").ShouldBeFalse($"{item.DisplayName}: an event after the revocation leaked");
var natives = Indices(keyEvents.Of(item), 'n');
if (natives.Count > 0) natives.Max().ShouldBeLessThan(PerCondition - 1);
}
(await TimedReadAsync(anonymous, ServerEventRig.UnsNodeId(anonymous, EqA), ct))
.ShouldBeLessThan(TimeSpan.FromSeconds(2), "the node manager is free afterwards");
}
finally
{
if (!hung)
{
keyed?.Dispose();
anonymous?.Dispose();
await rig.DisposeAsync();
}
}
}
private static async Task<TimeSpan> TimedReadAsync(ISession session, NodeId node, CancellationToken ct)
{
var watch = Stopwatch.StartNew();
var response = await session.ReadAsync(null, 0, TimestampsToReturn.Neither, new ReadValueId[]
{
new ReadValueId { NodeId = node, AttributeId = Attributes.DisplayName },
}, ct);
watch.Stop();
var result = response.Results.ToList().ShouldHaveSingleItem();
StatusCode.IsGood(result.StatusCode).ShouldBeTrue($"Read of {node} returned {result.StatusCode}");
return watch.Elapsed;
}
private static List<int> Indices(IReadOnlyList<ArrayOf<Variant>> received, char prefix) =>
received
.Select(RecordedEvents.Message)
.Where(m => m is { Length: > 1 } && m[0] == prefix && char.IsAsciiDigit(m[1]))
.Select(m => int.Parse(m![1..], CultureInfo.InvariantCulture))
.ToList();
private static int IndexOfType(IReadOnlyList<ArrayOf<Variant>> received, NodeId type) =>
received.ToList().FindIndex(f => f[RecordedEvents.EventTypeField].GetNodeId() == type);
private static AlarmConditionSnapshot State(string message) =>
new(true, false, false, true, AlarmShelvingKind.Unshelved, 700, message);
private static NodeAcl KeyEquipmentGrant(string equipment) => new()
{
NodeAclId = $"acl-key-{equipment}",
ClusterId = ServerEventRig.Cluster,
Principal = ServerEventRig.KeyPrincipal,
ScopeKind = NodeAclScopeKind.Equipment,
ScopeId = $"eq-{equipment}",
PermissionFlags = NodePermissions.ReadOnly,
};
}