fix(opcua): #855 review 2 — frozen replay skips non-live conditions; per-hop ACL resolution; drain never inline
- QueueFrozenReplay's verdict refuses a condition a deploy removed or replaced since the cut (Server-object and folder), so the cut path cannot replay a ghost or judge a replaced scripted alarm on its old folder. - DeliverToNotifierItems resolves the #826 decision's item-independent part (live condition, notifier check, scopes) once per hop; only the grant and SDK role checks stay per item. - A deferred delivery's drain starts on the pool, never inline under Lock. - Test: a condition removed between the cut and its replay (folder + Server object). Claude-Session: https://claude.ai/code/session_01GT67AD99zQQqpLCVm3p7xk
This commit is contained in:
@@ -1539,13 +1539,19 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
return;
|
||||
}
|
||||
|
||||
DeferredConditionReports queue;
|
||||
lock (_eventReportGate)
|
||||
{
|
||||
var queue = new DeferredConditionReports();
|
||||
queue = new DeferredConditionReports();
|
||||
_deferredReports[condition] = queue;
|
||||
CountDeferred(+1, condition);
|
||||
_ = DrainDeferredAsync(condition, queue, delivery.AsTask());
|
||||
}
|
||||
|
||||
// #855 review 2: started on the pool, never inline. The suspended delivery may complete between the check above and
|
||||
// here; an inline drain would then run (and end the episode, and clear cuts over every item gate) on this thread,
|
||||
// under Lock. The queue is registered first, so this condition's later reports wait behind it either way.
|
||||
var inFlight = delivery.AsTask();
|
||||
_ = Task.Run(() => DrainDeferredAsync(condition, queue, inFlight));
|
||||
}
|
||||
|
||||
/// <summary>Queues a later event behind a condition's deferred one, coalescing past the budget. Under the gate.</summary>
|
||||
@@ -1719,13 +1725,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
private void DeliverToNotifierItems(MonitoredNode2 monitored, IFilterTarget e)
|
||||
{
|
||||
var deferred = e is ConditionEventSnapshot { Deferred: true } d ? d : null;
|
||||
|
||||
// #855 review 2: the #826 decision's item-independent part — the live condition, whether this notifier is still a
|
||||
// legitimate notifier of it, and its visibility scopes — is resolved ONCE per hop, not per item (it is what
|
||||
// IsNotifierEventVisibleTo resolves for an item attached here). Only the grant check and the SDK's role check
|
||||
// remain per item.
|
||||
var enforced = AclEnforced;
|
||||
List<NodeId>? scopes = null;
|
||||
if (enforced)
|
||||
{
|
||||
if (EventConditionOf(e) is not { } named
|
||||
|| !_alarmConditions.TryGetValue(MapKey(named.NodeId), out var live)
|
||||
|| !IsLiveNotifierOf(live, monitored.Node))
|
||||
{
|
||||
return; // fail closed for every item on this notifier
|
||||
}
|
||||
|
||||
scopes = ConditionVisibilityScopes(live);
|
||||
}
|
||||
|
||||
foreach (var item in monitored.EventMonitoredItems.Values)
|
||||
{
|
||||
if (item is null) continue;
|
||||
try
|
||||
{
|
||||
if (IsAuditEvent(e) && !IsAuditDeliverableTo(item)) continue;
|
||||
if (ServiceResult.IsBad(((INodeManager3)this).ValidateEventRolePermissions(item, e))) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, e))) continue;
|
||||
if (scopes is not null && !IsConditionVisibleTo(item, scopes)) continue;
|
||||
QueueConditionEventToItem(item, e, deferred, DeferredFolderDeliveryProbe);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -6701,6 +6727,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
generation,
|
||||
(item, e) =>
|
||||
{
|
||||
// #855 review 2: never replay a condition a deploy has removed or replaced since the cut — its frozen
|
||||
// state would be a ghost (and, with the ACL on, a replaced scripted alarm would be judged on its old folder).
|
||||
if (ConditionOf(e) is { } frozen && !IsLiveCondition(frozen)) return false;
|
||||
if (enforced)
|
||||
{
|
||||
if (ConditionOf(e) is not { } condition) return false;
|
||||
@@ -6981,7 +7010,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2, INodeManager3
|
||||
|
||||
DirectRefreshProbe?.Invoke(true, Monitor.IsEntered(Lock));
|
||||
// #855 review: re-decided per entry right before queueing — a deploy may have revoked since the verdicts.
|
||||
QueueFrozenReplay(items, states, perItem, generation, (item, e) => IsDirectRefreshEventAllowed(item, e, enforced));
|
||||
QueueFrozenReplay(
|
||||
items, states, perItem, generation,
|
||||
(item, e) => (ConditionOf(e) is not AlarmConditionState frozen || IsLiveCondition(frozen))
|
||||
&& IsDirectRefreshEventAllowed(item, e, enforced));
|
||||
}
|
||||
finally
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user