Merge #587/#588: one condition event per method-driven transition (native: Ack/AddComment only); model-change + audit events gated per session on i=2253 when ACL is on
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -825,9 +825,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// the incoming snapshot onto it), then decide fire-vs-suppress by comparing the incoming
|
||||
// snapshot to that current state. We gate against the NODE's state, NOT a "last written"
|
||||
// cache, because an inbound client ack the SDK applied (OnAcknowledge returned Good →
|
||||
// SDK mutated AckedState + auto-fired its own event) NEVER passed through this method, so a
|
||||
// last-written cache would be stale and wrongly report a delta. By the time the engine
|
||||
// re-projects that ack here, the node already holds the acked state → no delta → suppress.
|
||||
// SDK mutated AckedState, then WireMethodTransitionReporting reported the one event for it —
|
||||
// Gitea #587) NEVER passed through this method, so a last-written cache would be stale and
|
||||
// wrongly report a delta. By the time the engine re-projects that ack here, the node already
|
||||
// holds the acked state → no delta → suppress.
|
||||
var current = ReadConditionDelta(condition);
|
||||
var incoming = ToConditionDelta(state, condition);
|
||||
bool fire = ShouldFireConditionEvent(current, incoming);
|
||||
@@ -881,8 +882,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// change (the delta-gate decided above, against the node's pre-projection state). A
|
||||
// genuine engine-driven transition (alarm goes active/clear, severity bucket shifts, an
|
||||
// engine-side ack, etc.) differs from the node's current state → fire. The re-projection
|
||||
// of a client ack the SDK already applied equals the node's current state → no delta →
|
||||
// suppress, so we don't double-emit (E2 from the SDK + E3 from here). ReportConditionEvent
|
||||
// of a client method call already applied (and already reported, by the method path —
|
||||
// WireMethodTransitionReporting) equals the node's current state → no delta → suppress, so
|
||||
// the transition is reported exactly once. ReportConditionEvent
|
||||
// stamps a fresh EventId, ClearChangeMasks, and ReportEvent — all still under this lock.
|
||||
if (fire)
|
||||
{
|
||||
@@ -970,16 +972,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// materialise) — no branch creation here.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Double-emit note (resolved by delta-gate).</b> An inbound client Acknowledge/Confirm
|
||||
/// goes through the SDK's own handler, which (after T18's gate returns Good) applies the acked
|
||||
/// state to the node and auto-fires its own condition event (E2) — directly on the node,
|
||||
/// BYPASSING <see cref="WriteAlarmCondition"/>. The engine then re-projects that same logical
|
||||
/// transition through <see cref="WriteAlarmCondition"/>, which would otherwise fire a second
|
||||
/// event (E3). <see cref="WriteAlarmCondition"/>'s delta-gate suppresses E3: it compares the
|
||||
/// incoming snapshot against the NODE's CURRENT state, and because the SDK has ALREADY
|
||||
/// pre-applied the inbound-ack state, the re-projection is a no-delta no-op (no fire). Genuine
|
||||
/// engine-driven transitions still differ from the node's current state, so they fire here as
|
||||
/// before.
|
||||
/// <b>Method-driven transitions (Gitea #587).</b> An inbound client Acknowledge / Confirm /
|
||||
/// AddComment / shelve / Enable / Disable goes through the SDK's own handler, which (after T18's
|
||||
/// gate returns Good) applies the new state to the node, BYPASSING <see cref="WriteAlarmCondition"/>.
|
||||
/// The SDK reports <b>no</b> event for it — its <c>ReportStateChange</c> is gated on
|
||||
/// <c>AutoReportStateChanges</c>, which stays false — so the method path calls this method itself,
|
||||
/// once, after the handler returns (<see cref="WireMethodTransitionReporting"/>). The engine then
|
||||
/// re-projects that same transition through <see cref="WriteAlarmCondition"/>, whose delta-gate
|
||||
/// compares against the NODE's CURRENT state, finds no change, and suppresses a second event. (An
|
||||
/// earlier comment here claimed the SDK "auto-fires its own event (E2)"; it never did, so a client
|
||||
/// ack produced no event at all.) Genuine engine-driven transitions still differ from the node's
|
||||
/// current state, so they fire here as before.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
/// <param name="alarm">The materialised condition whose new state has already been projected; must be non-null.</param>
|
||||
@@ -1046,11 +1049,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <c>ScriptedAlarmHostActor</c>'s inbound <c>AlarmCommand</c> handler — meaning
|
||||
/// <c>CommentAdded</c> ALWAYS originates from a client calling the condition's
|
||||
/// <c>AddComment</c> method. On that path T18's <c>OnAddComment</c> delegate returns
|
||||
/// <c>ServiceResult.Good</c>, so the OPC UA SDK itself applies the comment to the node
|
||||
/// and auto-fires the Part 9 comment event (E2) directly to subscribers — BEFORE the
|
||||
/// engine re-projects via <see cref="WriteAlarmCondition"/>. When that re-projection
|
||||
/// arrives here, the delta-gate sees no change in any compared field (the snapshot carries
|
||||
/// no comments list) and correctly suppresses a second event (E3). Force-firing for
|
||||
/// <c>ServiceResult.Good</c>, the OPC UA SDK applies the comment to the node, and the method
|
||||
/// path reports the one Part 9 comment event (<see cref="WireMethodTransitionReporting"/>,
|
||||
/// Gitea #587) — BEFORE the engine re-projects via <see cref="WriteAlarmCondition"/>. When that
|
||||
/// re-projection arrives here, the delta-gate sees no change in any compared field (the snapshot
|
||||
/// carries no comments list) and correctly suppresses a second event. Force-firing for
|
||||
/// <c>CommentAdded</c> would double-emit. There is no engine-internal or script-driven
|
||||
/// comment path, so suppression never drops a needed event.
|
||||
/// </para>
|
||||
@@ -1201,6 +1204,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
UnwireAlarmNotifiers(conditionKey);
|
||||
existing.Parent?.RemoveChild(existing);
|
||||
PredefinedNodes?.Remove(existing.NodeId);
|
||||
// The materialise tail raises only the new instance's unit (no full reconcile), so give the
|
||||
// dropped instance's back here rather than leave it tracked until the next full reconcile.
|
||||
if (_allEventsMonitoredConditions.Remove(existing))
|
||||
existing.SetAreEventsMonitored(SystemContext, false, includeChildren: true);
|
||||
}
|
||||
|
||||
// H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new
|
||||
@@ -1276,7 +1283,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
alarm.SetEnableState(SystemContext, true);
|
||||
alarm.SetActiveState(SystemContext, false);
|
||||
alarm.SetAcknowledgedState(SystemContext, true);
|
||||
// A native condition is never Unconfirmed (Gitea #587 review): it has no confirm workflow — Confirm is
|
||||
// refused and Acknowledge re-asserts Confirmed — so start it Confirmed, the value NativeAlarmProjector
|
||||
// supplies on every transition, rather than the SDK's default false until the first projection.
|
||||
if (isNative && alarm.ConfirmedState is not null) alarm.SetConfirmedState(SystemContext, true);
|
||||
alarm.SetSeverity(SystemContext, MapSeverity(severity));
|
||||
// Gitea #587 (found by its TimedShelve test): Create() leaves MaxTimeShelved at 0, and the SDK's
|
||||
// TimedShelve handler rejects any ShelvingTime above it — so every client TimedShelve failed with
|
||||
// BadShelvingTimeOutOfRange. The bound becomes the longest shelve the SDK's own shelving timer can
|
||||
// represent (int.MaxValue ms ≈ 24.8 days, the same ceiling SetShelvingState already saturated a 0 to).
|
||||
if (alarm.MaxTimeShelved is { } maxTimeShelved && maxTimeShelved.Value <= 0)
|
||||
maxTimeShelved.Value = int.MaxValue;
|
||||
alarm.Retain.Value = false; // inactive + acked ⇒ nothing to retain yet
|
||||
alarm.Message.Value = new LocalizedText(displayName);
|
||||
if (alarm.ConditionName is not null) alarm.ConditionName.Value = displayName;
|
||||
@@ -1286,24 +1303,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// routing; these delegates are the veto/permission seam the SDK invokes BEFORE applying the
|
||||
// state change. Each gates on the caller's AlarmAck role (fails closed) and, when allowed,
|
||||
// routes a mapped AlarmCommand to the engine via AlarmCommandRouter, then returns Good so the
|
||||
// SDK applies its node state + auto-fires its own event (E2).
|
||||
// T20: the engine re-projects that same logical transition through WriteAlarmCondition; its
|
||||
// delta-gate (compares against the node's current state, which the SDK already pre-applied)
|
||||
// sees no change and suppresses the would-be second event (E3) — so no double-emit.
|
||||
// SDK applies its node state. The SDK does NOT report an event for it (its ReportStateChange is
|
||||
// gated on AutoReportStateChanges, which stays false) — the ONE condition event per transition
|
||||
// is ours, raised after the SDK handler returns by WireMethodTransitionReporting (Gitea #587).
|
||||
// The engine's later re-projection of the same transition reaches WriteAlarmCondition's
|
||||
// delta-gate, which finds the node already in that state and suppresses a second event.
|
||||
// H6c — a NATIVE (driver-fed) condition's Acknowledge belongs to the backing driver, NOT the
|
||||
// scripted engine, so branch on native-ness (via the lock-guarded accessor) and route a native
|
||||
// ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. Confirm/AddComment/
|
||||
// Shelve stay on the scripted path even for native conditions (H6c scope is Acknowledge only).
|
||||
// ack to NativeAlarmAckRouter instead of the scripted AlarmCommandRouter. AddComment stays on the
|
||||
// scripted path even for native conditions (H6c scope is Acknowledge only).
|
||||
// Gitea #587 review: a NATIVE condition supports Acknowledge and AddComment only. Confirm and the
|
||||
// shelve methods return BadNotSupported (like Enable/Disable below) — there is no upstream path for
|
||||
// them, no pair replication of a local state, and the driver projection (Confirmed, Unshelved) would
|
||||
// silently undo one at the next transition.
|
||||
alarm.OnAcknowledge = (context, condition, _, comment) =>
|
||||
IsNativeAlarmNode(alarmNodeId, realm)
|
||||
? HandleNativeAlarmAck(context, condition, comment)
|
||||
: HandleAlarmCommand(context, condition, "Acknowledge", comment, unshelveAt: null);
|
||||
alarm.OnConfirm = (context, condition, _, comment) =>
|
||||
HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
|
||||
IsNativeAlarmNode(alarmNodeId, realm)
|
||||
? new ServiceResult(StatusCodes.BadNotSupported)
|
||||
: HandleAlarmCommand(context, condition, "Confirm", comment, unshelveAt: null);
|
||||
alarm.OnAddComment = (context, condition, _, comment) =>
|
||||
HandleAlarmCommand(context, condition, "AddComment", comment, unshelveAt: null);
|
||||
alarm.OnShelve = (context, condition, shelving, oneShot, shelvingTime) =>
|
||||
{
|
||||
if (IsNativeAlarmNode(alarmNodeId, realm)) return new ServiceResult(StatusCodes.BadNotSupported);
|
||||
|
||||
// SDK invocation shapes (verified against the decompiled AlarmConditionState):
|
||||
// OneShotShelve → (shelving:true, oneShot:true, 0.0) ⇒ OneShotShelve, no expiry
|
||||
// TimedShelve → (shelving:true, oneShot:false, ms) ⇒ TimedShelve, expiry = UtcNow + ms
|
||||
@@ -1348,6 +1374,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
return HandleAlarmCommand(context, condition, enabling ? "Enable" : "Disable", comment: null, unshelveAt: null);
|
||||
};
|
||||
|
||||
// Gitea #587: exactly one condition event per accepted method call, reported by us once the SDK's
|
||||
// own handler has applied the new state.
|
||||
WireMethodTransitionReporting(alarm, conditionKey);
|
||||
|
||||
// Gitea #586: the condition itself delivers its events to Server-object (all-events)
|
||||
// subscriptions, item by item through the ACL. A condition is never an event notifier, so no
|
||||
// MonitoredNode2 ever claims this single-slot delegate — every event the condition reports, ours
|
||||
@@ -1363,7 +1393,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
|
||||
AddPredefinedNode(SystemContext, alarm);
|
||||
_alarmConditions[conditionKey] = alarm;
|
||||
ReconcileAllEventsMonitoring();
|
||||
// Only the new condition can be missing its unit here, so raise just that one — a full reconcile
|
||||
// walks every condition, which made a deploy of N alarms O(N²) under Lock whenever any Server-object
|
||||
// item was live (always, in production). Removal, rebuild and (un)subscribe keep the full reconcile.
|
||||
RaiseAllEventsMonitoring(alarm);
|
||||
// H6a: record native (driver-fed) conditions so a later task can route their inbound
|
||||
// Acknowledge to the driver rather than the scripted engine.
|
||||
if (isNative) _nativeAlarmNodeIds.Add(conditionKey);
|
||||
@@ -1487,6 +1520,148 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
_alarmNotifierWiring.Remove(alarmKey);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Wraps each Part 9 method the SDK wired on <paramref name="alarm"/> (Acknowledge, Confirm, AddComment,
|
||||
/// Enable, Disable, OneShotShelve, TimedShelve, Unshelve) so an accepted call reports exactly one
|
||||
/// condition event carrying the post-call state (Gitea #587).
|
||||
/// </summary>
|
||||
/// <param name="alarm">The freshly created condition; its method children carry the SDK's handlers.</param>
|
||||
/// <param name="conditionKey">Its key in <c>_alarmConditions</c>.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// <b>Why not <c>AutoReportStateChanges</c>.</b> The SDK's own report from a method handler
|
||||
/// (<c>ConditionState.ReportStateChange</c>) is gated on <c>AutoReportStateChanges</c> — which this
|
||||
/// repo had never set, so a client Acknowledge produced no condition event at all, and the engine's
|
||||
/// re-projection found the node already acked and suppressed its own — and then on
|
||||
/// <c>EventsMonitored()</c>, the <c>AreEventsMonitored</c> counter. That counter is imprecise in the
|
||||
/// SDK: a folder subscriber's unit never reaches a condition materialised under the folder later, yet
|
||||
/// the folder's unsubscribe still lowers it, which can take away the Server-object unit while an
|
||||
/// <c>i=2253</c> item lives (#586 review). And the SDK's shelve handlers report <i>before</i> any
|
||||
/// shelving state is applied (they only call <c>OnShelve</c>), so their event would carry the old
|
||||
/// state and the engine's re-projection would then report a second one. Reporting ourselves, after
|
||||
/// the handler returns, depends on none of that: <see cref="ReportConditionEvent"/> walks the notifier
|
||||
/// graph unconditionally, and Server-object delivery goes through <see cref="OnConditionReportEvent"/>
|
||||
/// and the ACL like every other condition event.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Shelving is applied here.</b> The SDK's shelve methods never change <c>ShelvingState</c>
|
||||
/// themselves, so an accepted shelve call applies it — with the same <c>(shelved, oneShot, 0)</c>
|
||||
/// mapping <see cref="WriteAlarmCondition"/> projects — before reporting. The engine's re-projection of
|
||||
/// the shelve then finds no delta. Every other method's state the SDK has already applied.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Exactly once.</b> The whole call runs under <c>Lock</c>. The method handlers hand the command
|
||||
/// to the engine (a fire-and-forget Tell) before the SDK applies the state, so without the lock a fast
|
||||
/// re-projection could take <c>Lock</c> first, see a delta, and report, followed by this report — two
|
||||
/// events. Holding it makes the re-projection wait until the node holds the new state.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Native conditions support Acknowledge and AddComment only</b> (Confirm and the shelve methods
|
||||
/// are refused with <c>BadNotSupported</c> before they get here). Their Confirmed and Shelving stay
|
||||
/// owned by the driver projection (<c>NativeAlarmProjector</c>: always Confirmed, Unshelved). The SDK's
|
||||
/// Acknowledge sets <c>Confirmed = false</c> because the condition type supports Confirm, which would
|
||||
/// leave every native alarm Unconfirmed after its first OPC UA ack — ScadaBridge maps
|
||||
/// <c>ConfirmedState</c> straight into its alarm state — so a native ack re-asserts Confirmed and the
|
||||
/// projection's Retain rule (<c>Active || !Acked</c>) before the one report.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// <b>Only a real change is reported.</b> The condition's gate-relevant state is read before and after
|
||||
/// the SDK handler, and nothing is reported when it did not move. The SDK accepts acknowledging an
|
||||
/// acknowledged condition and sets <c>Confirmed = false</c> again; a re-ack therefore also restores the
|
||||
/// pre-call Confirmed and Retain, so a scripted node never drifts from its engine (which ignores a
|
||||
/// re-ack) and the call is silent. AddComment always reports: adding a comment is itself the change.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// A rejected call (bad <see cref="ServiceResult"/>, e.g. a denied gate, or a refused method on a native
|
||||
/// condition) reports nothing: there was no transition.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void WireMethodTransitionReporting(AlarmConditionState alarm, RealmKey conditionKey)
|
||||
{
|
||||
alarm.AutoReportStateChanges = false; // the SDK's own report stays off; ours replaces it (see remarks)
|
||||
|
||||
if (alarm.Acknowledge?.OnCall is { } acknowledge)
|
||||
{
|
||||
alarm.Acknowledge.OnCall = (context, m, objectId, eventId, comment) =>
|
||||
ReportAfterMethod(() => acknowledge(context, m, objectId, eventId, comment), apply: null, isAcknowledge: true);
|
||||
}
|
||||
|
||||
WrapComment(alarm.Confirm, alwaysReport: false);
|
||||
WrapComment(alarm.AddComment, alwaysReport: true);
|
||||
WrapGeneric(alarm.Enable, apply: null);
|
||||
WrapGeneric(alarm.Disable, apply: null);
|
||||
|
||||
if (alarm.ShelvingState is { } shelving)
|
||||
{
|
||||
WrapGeneric(shelving.OneShotShelve, () => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: true, shelvingTime: 0));
|
||||
WrapGeneric(shelving.Unshelve, () => alarm.SetShelvingState(SystemContext, shelved: false, oneShot: false, shelvingTime: 0));
|
||||
if (shelving.TimedShelve is { OnCall: { } timed } timedShelve)
|
||||
{
|
||||
// shelvingTime 0, as WriteAlarmCondition projects it: the scripted engine owns the expiry and
|
||||
// re-projects Unshelved when it passes (the SDK's own timer only routes an Unshelve back to it).
|
||||
timedShelve.OnCall = (context, method, objectId, shelvingTime) => ReportAfterMethod(
|
||||
() => timed(context, method, objectId, shelvingTime),
|
||||
() => alarm.SetShelvingState(SystemContext, shelved: true, oneShot: false, shelvingTime: 0));
|
||||
}
|
||||
}
|
||||
|
||||
void WrapComment(AddCommentMethodState? method, bool alwaysReport)
|
||||
{
|
||||
if (method?.OnCall is not { } sdk) return;
|
||||
method.OnCall = (context, m, objectId, eventId, comment) =>
|
||||
ReportAfterMethod(() => sdk(context, m, objectId, eventId, comment), apply: null, alwaysReport: alwaysReport);
|
||||
}
|
||||
|
||||
void WrapGeneric(MethodState? method, Action? apply)
|
||||
{
|
||||
if (method?.OnCallMethod is not { } sdk) return;
|
||||
method.OnCallMethod = (context, m, inputs, outputs) =>
|
||||
ReportAfterMethod(() => sdk(context, m, inputs, outputs), apply);
|
||||
}
|
||||
|
||||
ServiceResult ReportAfterMethod(
|
||||
Func<ServiceResult> sdkHandler, Action? apply, bool isAcknowledge = false, bool alwaysReport = false)
|
||||
{
|
||||
lock (Lock)
|
||||
{
|
||||
var before = ReadConditionDelta(alarm);
|
||||
var retainBefore = alarm.Retain.Value;
|
||||
|
||||
var result = sdkHandler();
|
||||
// A rebuild may have discarded this instance mid-call; its replacement is not what was called.
|
||||
if (!ServiceResult.IsGood(result)
|
||||
|| !_alarmConditions.TryGetValue(conditionKey, out var live)
|
||||
|| !ReferenceEquals(live, alarm))
|
||||
{
|
||||
return result;
|
||||
}
|
||||
|
||||
apply?.Invoke();
|
||||
|
||||
if (isAcknowledge && alarm.ConfirmedState is not null)
|
||||
{
|
||||
if (_nativeAlarmNodeIds.Contains(conditionKey))
|
||||
{
|
||||
// Native: no confirm workflow — keep what the driver projection says (see remarks).
|
||||
alarm.SetConfirmedState(SystemContext, true);
|
||||
alarm.Retain.Value = (alarm.ActiveState?.Id?.Value ?? false) || !(alarm.AckedState?.Id?.Value ?? true);
|
||||
}
|
||||
else if (before.Acknowledged)
|
||||
{
|
||||
// Re-ack: the SDK flipped Confirmed though nothing was acknowledged; put it back.
|
||||
alarm.SetConfirmedState(SystemContext, before.Confirmed);
|
||||
alarm.Retain.Value = retainBefore;
|
||||
}
|
||||
}
|
||||
|
||||
if (alwaysReport || ReadConditionDelta(alarm) != before)
|
||||
ReportConditionEvent(alarm, DateTime.UtcNow);
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>H6a — true if the condition materialised at <paramref name="alarmNodeId"/> is a NATIVE
|
||||
/// (driver-fed) alarm rather than a scripted one. A later task uses this to route a native condition's
|
||||
/// inbound Acknowledge to the driver instead of the scripted engine.</summary>
|
||||
@@ -1513,8 +1688,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <param name="operation">The Part 9 operation name (e.g. <c>Acknowledge</c>, <c>TimedShelve</c>).</param>
|
||||
/// <param name="comment">The call's comment text, or <c>null</c> when none was supplied.</param>
|
||||
/// <param name="unshelveAt">For <c>TimedShelve</c>, the computed UTC expiry; otherwise <c>null</c>.</param>
|
||||
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state + auto-fires its
|
||||
/// event); <c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
|
||||
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state, and the method path
|
||||
/// reports the one condition event — Gitea #587);<c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
|
||||
private ServiceResult HandleAlarmCommand(
|
||||
ISystemContext context, ConditionState condition, string operation, LocalizedText? comment, DateTime? unshelveAt)
|
||||
{
|
||||
@@ -1534,7 +1709,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// Non-blocking by contract (host wires a fire-and-forget mediator.Tell); safe to call under Lock.
|
||||
AlarmCommandRouter?.Invoke(cmd);
|
||||
|
||||
// Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
|
||||
// Good ⇒ the SDK applies the node-state change; WireMethodTransitionReporting then reports the one
|
||||
// condition event (the SDK reports none of its own — Gitea #587).
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
@@ -1552,7 +1728,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <param name="condition">The condition the Acknowledge targets; its <c>NodeId</c> identifier is the
|
||||
/// folder-scoped condition node id the driver-bound router resolves back to a driver ref.</param>
|
||||
/// <param name="comment">The acknowledge comment text, or <c>null</c> when none was supplied.</param>
|
||||
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state + auto-fires its event);
|
||||
/// <returns><c>ServiceResult.Good</c> when allowed (the SDK then applies state; the method path reports the event);
|
||||
/// <c>BadUserAccessDenied</c> when the gate vetoes (no route, no state mutation).</returns>
|
||||
private ServiceResult HandleNativeAlarmAck(ISystemContext context, ConditionState condition, LocalizedText? comment)
|
||||
{
|
||||
@@ -1571,7 +1747,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
Comment: comment?.Text,
|
||||
OperatorUser: AlarmOperatorName(identity!)));
|
||||
|
||||
// Good ⇒ the SDK applies the node-state change + auto-fires its own condition event.
|
||||
// Good ⇒ the SDK applies the node-state change; WireMethodTransitionReporting then reports the one
|
||||
// condition event (the SDK reports none of its own — Gitea #587).
|
||||
return ServiceResult.Good;
|
||||
}
|
||||
|
||||
@@ -2054,7 +2231,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
try
|
||||
{
|
||||
Server.ReportEvent(SystemContext, auditEvent);
|
||||
ReportToServerObject(auditEvent);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2417,7 +2594,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
try
|
||||
{
|
||||
Server.ReportEvent(SystemContext, BuildNodeShapeChangedEvent(variable));
|
||||
ReportToServerObject(BuildNodeShapeChangedEvent(variable));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2461,7 +2638,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
// Lock across it risks a lock-order inversion (mirrors ReportNodeShapeChangedEvent).
|
||||
try
|
||||
{
|
||||
Server.ReportEvent(SystemContext, e);
|
||||
ReportToServerObject(e);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -2647,7 +2824,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
{
|
||||
try
|
||||
{
|
||||
Server.ReportEvent(SystemContext, e);
|
||||
ReportToServerObject(e);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -3484,9 +3661,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// <c>SubscribeToEvents</c>, which also calls <c>SetAreEventsMonitored(true, includeChildren)</c>
|
||||
/// on the notifier — a counter that propagates to every condition beneath or wired to it. Not
|
||||
/// attaching the items lost that bookkeeping, and a condition reads it: <c>ConditionState</c>
|
||||
/// reports its own method-driven state changes (and their audit events) only while
|
||||
/// <c>EventsMonitored()</c> is true. A server whose only subscribers are Server-object items — the
|
||||
/// common machine-consumer shape — would otherwise report none of them.
|
||||
/// reports its method audit events only while <c>EventsMonitored()</c> is true. A server whose only
|
||||
/// subscribers are Server-object items — the common machine-consumer shape — would otherwise report
|
||||
/// none of them. (The method-driven <i>state-change</i> events no longer depend on this: the SDK
|
||||
/// reports none, and <see cref="WireMethodTransitionReporting"/> reports them unconditionally —
|
||||
/// Gitea #587. That also takes the SDK counter's known imprecision — a folder subscriber's unit never
|
||||
/// reaches a condition materialised under the folder later, yet its unsubscribe still lowers it — off
|
||||
/// the state-change path; it can now cost at most an audit event while auditing is on.)
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Held per <b>condition</b>, exactly one unit each, rather than per root notifier per item as the
|
||||
@@ -3507,6 +3688,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
: new HashSet<AlarmConditionState>(
|
||||
_alarmConditions.Values, System.Collections.Generic.ReferenceEqualityComparer.Instance);
|
||||
|
||||
AllEventsMonitoringConditionVisits += desired.Count + _allEventsMonitoredConditions.Count;
|
||||
|
||||
foreach (var condition in _allEventsMonitoredConditions.Where(c => !desired.Contains(c)).ToList())
|
||||
{
|
||||
condition.SetAreEventsMonitored(SystemContext, false, includeChildren: true);
|
||||
@@ -3521,6 +3704,32 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The incremental form of <see cref="ReconcileAllEventsMonitoring"/> for one newly materialised
|
||||
/// condition: raises its unit when a Server-object item exists, touching no other condition. Call under
|
||||
/// <c>Lock</c>.
|
||||
/// </summary>
|
||||
/// <param name="condition">The condition just added to <c>_alarmConditions</c>.</param>
|
||||
/// <remarks>
|
||||
/// Equivalent to the full reconcile on the materialise path, because materialising adds one condition
|
||||
/// and removes none (a same-key kind-swap gives the dropped instance's unit back itself). Kept separate
|
||||
/// so a deploy of N alarms costs N set operations rather than N²/2 (review of Gitea #586).
|
||||
/// </remarks>
|
||||
private void RaiseAllEventsMonitoring(AlarmConditionState condition)
|
||||
{
|
||||
AllEventsMonitoringConditionVisits++;
|
||||
if (!_allEventsItems.IsEmpty && _allEventsMonitoredConditions.Add(condition))
|
||||
condition.SetAreEventsMonitored(SystemContext, true, includeChildren: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// How many conditions the Server-object monitored bookkeeping has visited since start — every
|
||||
/// condition a full <see cref="ReconcileAllEventsMonitoring"/> walks, plus one per incremental
|
||||
/// <see cref="RaiseAllEventsMonitoring"/>. Test observability: bounds the bookkeeping cost of a deploy.
|
||||
/// </summary>
|
||||
/// <remarks>Mutated only under <c>Lock</c>.</remarks>
|
||||
internal long AllEventsMonitoringConditionVisits { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
@@ -3533,7 +3742,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Only condition events bubble through this manager's notifiers; the audit and model-change
|
||||
/// events it raises go to <c>Server.ReportEvent</c> directly and are untouched by this.
|
||||
/// events it raises never reach this, and go through <see cref="ReportToServerObject"/> instead
|
||||
/// (Gitea #588).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
protected override void OnReportEvent(ISystemContext context, NodeState node, IFilterTarget e)
|
||||
@@ -3608,6 +3818,130 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
|
||||
|| endpoint?.TransportProfileUri == Profiles.HttpsBinaryTransport;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports a server-wide event this manager raises — a <c>GeneralModelChangeEvent</c> or an audit event
|
||||
/// — to the Server object's (<c>i=2253</c>) subscribers, through the data-plane ACL when it is enforced
|
||||
/// (Gitea #588).
|
||||
/// </summary>
|
||||
/// <param name="e">The built event.</param>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// These events are not condition events, so they never pass a notifier of ours: they went straight
|
||||
/// to <c>Server.ReportEvent</c>, where the core node manager's Server object queues them to every
|
||||
/// all-events item with no ACL decision. A model-change event names the RawPath / UNS path of the
|
||||
/// node it is about, so a session holding no grant learned the names of nodes it could not browse;
|
||||
/// an audit write event would carry the written value to it too.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// With enforcement on, this manager delivers them itself, item by item, to the all-events items it
|
||||
/// already holds for #586 — the same audience <c>Server.ReportEvent</c> reached, since only Server-object
|
||||
/// items receive what is reported at the Server object. Each item receives only what its session may
|
||||
/// see (<see cref="ServerObjectEventFor"/>); everything else the SDK's Server object did — its audit
|
||||
/// guard, its role-permission check — is kept. With enforcement off the event goes to
|
||||
/// <c>Server.ReportEvent</c> exactly as before.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Called outside <c>Lock</c>, like the <c>Server.ReportEvent</c> call it replaces; the decision
|
||||
/// takes no node-manager lock.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
private void ReportToServerObject(BaseEventState e)
|
||||
{
|
||||
if (!AclEnforced)
|
||||
{
|
||||
Server.ReportEvent(SystemContext, e);
|
||||
return;
|
||||
}
|
||||
|
||||
// ServerInternalData.ReportEvent's own guard: no audit event leaves the server while it is not auditing.
|
||||
if (e is AuditEventState && !Server.Auditing) return;
|
||||
|
||||
foreach (var item in _allEventsItems.Keys)
|
||||
{
|
||||
if (ServerObjectEventFor(item, e) is not { } visible) continue;
|
||||
if (ServiceResult.IsBad(ValidateEventRolePermissions(item, visible))) continue;
|
||||
item.QueueEvent(visible);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// What of a server-wide event <paramref name="item"/>'s session may receive with enforcement on: the
|
||||
/// event itself, a copy narrowed to what it may see, or nothing (Gitea #588).
|
||||
/// </summary>
|
||||
/// <param name="item">A Server-object event item.</param>
|
||||
/// <param name="e">The event.</param>
|
||||
/// <returns>The event to queue, or <see langword="null"/> to withhold it.</returns>
|
||||
/// <remarks>
|
||||
/// <list type="bullet">
|
||||
/// <item>
|
||||
/// A <b>model-change</b> event keeps only the changes whose <c>Affected</c> node the session may
|
||||
/// <c>Browse</c> (<see cref="FilterModelChangeFor"/>) — knowing a node exists is exactly what
|
||||
/// Browse grants, ancestors of a granted node included.
|
||||
/// </item>
|
||||
/// <item>
|
||||
/// An <b>audit</b> event needs the SDK's own delivery guard (<see cref="IsAuditDeliverableTo"/>)
|
||||
/// <b>and</b> <c>Read</c> on its source node: an <c>AuditWriteUpdateEvent</c> carries the node's
|
||||
/// prior value and the attempted one, which is what <c>Read</c> protects.
|
||||
/// </item>
|
||||
/// <item>Anything else is withheld: this manager reports no other server-wide event type, so an
|
||||
/// unrecognised one fails closed rather than slipping through ungated.</item>
|
||||
/// </list>
|
||||
/// </remarks>
|
||||
private BaseEventState? ServerObjectEventFor(IEventMonitoredItem item, BaseEventState e)
|
||||
{
|
||||
var identity = item.EffectiveIdentity as RoleCarryingUserIdentity;
|
||||
return e switch
|
||||
{
|
||||
GeneralModelChangeEventState modelChange => FilterModelChangeFor(identity, modelChange),
|
||||
AuditEventState audit => IsAuditDeliverableTo(item)
|
||||
&& audit.SourceNode?.Value is { IsNullNodeId: false } source
|
||||
&& Permits(identity, OpcUaOperation.Read, source)
|
||||
? audit
|
||||
: null,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A model-change event narrowed to the changes whose affected node <paramref name="identity"/> may
|
||||
/// browse: the event itself when every change is visible, <see langword="null"/> when none is, otherwise
|
||||
/// a copy carrying only the visible ones (Gitea #588).
|
||||
/// </summary>
|
||||
/// <param name="identity">The session's identity.</param>
|
||||
/// <param name="e">The model-change event.</param>
|
||||
/// <returns>The event to deliver, or <see langword="null"/> to withhold it.</returns>
|
||||
/// <remarks>
|
||||
/// Every builder in this manager emits a single change, so in practice the event is delivered whole or
|
||||
/// not at all; the copy keeps a future multi-change event from leaking the hidden names, or from hiding
|
||||
/// the visible ones. The copy keeps the original's <c>EventId</c>, time and source, with a neutral
|
||||
/// <c>Message</c> (the builders' messages name the affected node). A change with no
|
||||
/// <c>Affected</c> node names nothing and is kept.
|
||||
/// </remarks>
|
||||
internal GeneralModelChangeEventState? FilterModelChangeFor(
|
||||
RoleCarryingUserIdentity? identity, GeneralModelChangeEventState e)
|
||||
{
|
||||
var changes = e.Changes?.Value;
|
||||
if (changes is not { Length: > 0 }) return e;
|
||||
|
||||
var visible = changes
|
||||
.Where(c => c.Affected is null || c.Affected.IsNullNodeId || Permits(identity, OpcUaOperation.Browse, c.Affected))
|
||||
.ToArray();
|
||||
if (visible.Length == changes.Length) return e;
|
||||
if (visible.Length == 0) return null;
|
||||
|
||||
// The original Message names the affected node(s), so the copy carries a neutral one.
|
||||
var copy = new GeneralModelChangeEventState(null);
|
||||
copy.Initialize(
|
||||
SystemContext, source: null, (EventSeverity)e.Severity.Value, new LocalizedText($"{visible.Length} node(s) changed"));
|
||||
copy.EventId.Value = e.EventId.Value;
|
||||
copy.Time.Value = e.Time.Value;
|
||||
copy.ReceiveTime.Value = e.ReceiveTime.Value;
|
||||
copy.SetChildValue(SystemContext, BrowseNames.SourceNode, e.SourceNode?.Value ?? ObjectIds.Server, false);
|
||||
copy.SetChildValue(SystemContext, BrowseNames.SourceName, e.SourceName?.Value ?? "Server", false);
|
||||
copy.SetChildValue(SystemContext, BrowseNames.Changes, visible, false);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The nodes whose grant admits a condition to a session: the condition itself, every root-notifier
|
||||
/// folder its events bubble up through, and every equipment folder wired as its extra notifier.
|
||||
|
||||
Reference in New Issue
Block a user