Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
21 KiB
Review 01 — src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer (master @ d51d35bd)
Scope: OtOpcUaNodeManager (6,988 lines), AddressSpaceApplier, AddressSpaceComposer, AddressSpacePlan/Planner,
AddressSpaceChangeClassifier, SdkAddressSpaceSink, OtOpcUaSdkServer, OpcUaApplicationHost, Security/*,
HistoryContinuationStore, the HistoryRead helpers (ReverseWalk, ReverseRawReader, ReverseEventsReader,
HistoryBounds, HistoryPaging, RawPageLimit), AdvertisedAggregateFunctions, SdkServiceLevelPublisher.
SDK behaviour claims below were verified against the pinned SDK sources
(~/Desktop/OpcUaTestServer/external/UA-.NETStandard, 1.5.378 line): NodeState.cs,
Diagnostics/CustomNodeManager.cs, NodeManager/MonitoredItem/MonitoredNodeMonitoredItemManager.cs,
NodeManager/MasterNodeManager.cs. All paths are absolute; line numbers are from the current master.
1. Memory / resource leaks
L1. Lazy CreateVariable resurrects removed (or never-declared) NodeIds as orphan variables under the shared root — and then poisons a later re-add
- Severity: High · Confidence: High (mechanism) / Medium (trigger frequency)
- Location:
/Users/dohertj2/Desktop/OtOpcUa/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs:937-945(WriteValueLocked),:1059-1071(WriteAlarmConditionfallback),:6964-6986(CreateVariable),:2974-2975(EnsureVariableearly return) - Evidence:
and
if (!_variables.TryGetValue(key, out var variable)) { variable = CreateVariable(nodeId, ns); // parented under _root, DataType=BaseDataType, CurrentRead only _variables[key] = variable; }EnsureVariable:if (_variables.ContainsKey(key)) return; - Failure scenario:
WriteStatuseswas deliberately made non-creating because "the caller's per-driver ref index can legitimately name a node a surgical remove has just dropped" (:825-828).WriteValues/WriteValuestill create. So a driver publish (or the UNS fan-out) that races a surgicalRemoveVariableNodere-createss=<RawPath>(ands=<Area/Line/Equip/Name>) as a flat, read-only,BaseDataTypevariable directly underOtOpcUa, browsable, with noOnWriteValue, no historian registration, no_arrayOnScalarRefused. It stays until the next full rebuild — surgical deploys made that rare. Worse: if the same tag is later re-added surgically,EnsureVariableearly-returns on the phantom, so the "re-added" tag permanently keeps the phantom's wrong parent/DataType/writability. The planner never names the phantom, so no surgical remove ever reaches it. - Suggested fix: Make
WriteValueLockednon-creating (mirrorWriteStatusLocked: unknown key ⇒ skipped, counted, Debug-logged). The create-on-first-write path predatesEnsureVariableand has no legitimate production producer left. If some test relies on it, gate it behind an explicitAllowLazyNodeCreationthat defaults off.
L2. Stale MonitoredNode2 survives a rebuild / remove+recreate: new subscriptions bind to the dead NodeState, and ACL "restore" re-attaches to dead nodes
- Severity: High · Confidence: High
- Location:
OtOpcUaNodeManager.cs:3471-3537(RebuildAddressSpace),:3573-3607(RemoveVariableNode),:4801/:4889(revalidation restore). SDK:MonitoredNodeMonitoredItemManager.cs:75-83. - Evidence (SDK):
if (!MonitoredNodes.TryGetValue(handle.Node.NodeId, out MonitoredNode2 monitoredNode)) { ... new MonitoredNode2(m_nodeManager, cachedNode); } handle.Node = monitoredNode.Node; // <-- the OLD NodeStateRebuildAddressSpace/RemoveVariableNodedetach nodes fromPredefinedNodesand the parent, but never touch the manager'sMonitoredNodesmap.DeleteMonitoredItemonly drops the entry when its item count hits zero. - Failure scenario: Client A (e.g. ScadaBridge) holds an item on
Plant/Modbus/dev1/Speed. A deploy rebuilds (or surgically removes + re-adds) that node. A's item goes silent (known). Now ANY session — A after reconnecting, or a UaExpert operator — creates a new item on the same NodeId: the SDK reuses the existingMonitoredNode2and setshandle.Nodeto the dead NodeState, so the new item never receives a value either, withGoodcreate status. Only when every session has deleted its items on that NodeId does a fresh create work. Same class:RevalidateMonitoredItemsrestore doestracked.Handle.MonitoredNode?.Add(item)andRevalidateEventItemscallsSubscribeToEvents(SystemContext, tracked.Handle.Node, …)— after a rebuild both re-attach to dead nodes. Memory-wise each live item pins its dead subtree viaLiveItem.Handle(bounded by item count, but it's the whole detached subtree). - Suggested fix: In
RebuildAddressSpace,RemoveVariableNode,RemoveAlarmConditionNode,RemoveEquipmentSubtree: for each removed NodeId,MonitoredNodes.TryGetValue(nodeId, out var mn)→ queueBadNodeIdUnknownto eachmn.DataChangeMonitoredItems, thenMonitoredNodes.Remove(nodeId)(and drop the matching_liveItems/_eventItemsentries). For a rebuild that recreates the same NodeId you could instead re-point theMonitoredNode2to the newNodeState— but removing it and letting clients re-create is simpler and matches the documented "rebuild kills subscriptions" contract while making re-create actually work.
L3. OnEquipmentTagWrite continuation never observes a faulted write task
- Severity: Low · Confidence: High
- Location:
OtOpcUaNodeManager.cs:2202-2209 - Evidence:
var outcome = t.IsCompletedSuccessfully ? t.Result : new NodeWriteOutcome(false, "write dispatch faulted");—t.Exceptionis never read on the faulted branch. - Failure scenario: Every faulted
WriteAsync(gateway Ask timeout/exception) raisesTaskScheduler.UnobservedTaskExceptionat finalization; with a handler that logs, each failed device write becomes a late, context-free error line; the real reason is never logged here either (outcome.Reasonis the constant string). - Suggested fix: On the non-success branch read
t.Exception?.GetBaseException()into the reason and log it (the patternAddressSpaceApplier.ProvisionHistorizedTags:727-733already uses).
L4. Legacy bool[2] placeholder path is reachable in production and transiently shadows the equipment folder in PredefinedNodes
- Severity: Medium · Confidence: Medium
- Location:
OtOpcUaNodeManager.cs:1059-1071; producerAddressSpaceApplier.cs:100-103(SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, …)). - Evidence: The applier writes
RemovedConditionStateagainsteq.EquipmentIdfor every removed equipment; an equipment id is a folder, never a condition, so_alarmConditionsmisses and the "legacy two-element placeholder … (and the existing unit tests)" branch runsCreateVariable(alarmNodeId, ns)+AddPredefinedNode. SDKAddPredefinedNodeisPredefinedNodes.AddOrUpdate(...)(CustomNodeManager.cs:586), so the phantom variable replaces the equipment folder'sPredefinedNodesentry (same realm + sames=id) until the folder loop inRemoveEquipmentSubtree(or the rebuild) removes it. - Failure scenario: During a PureRemove/Rebuild apply a concurrent Read/Browse of the equipment folder resolves to a
bool[2]variable. The phantom is cleaned up by the same apply on every path I traced (InScope matcheskey.Id == equipmentNodeId; an earlyfalseratchets to rebuild), so it is transient — but it is a production path running through a self-described test fallback, and it does one needlessClearChangeMasksper removed equipment. - Suggested fix: Make
WriteAlarmConditiona logged no-op for unknown ids (asWriteAlarmQualityalready is), and drop the applier's equipment-id condition write (an equipment never has a condition node).
L5. (bounded, note) _notifierFolders / _eventNotifierSources not demoted on RemoveAlarmConditionNode
- Severity: Low · Confidence: High
- Location:
OtOpcUaNodeManager.cs:3637-3664 - Documented and bounded by folder count; a raw device/group folder promoted for a native alarm stays a root notifier (and an advertised event-history source) after its last condition is removed until a rebuild. Harmless except
HistoryReadEventson it answers from the historian for a folder with no alarms. No action needed beyond awareness.
2. Unimplemented / stubbed / dormant
U1. HistoryReadEvents projects EventType, SourceNode, ConditionName (and every other field) as Variant.Null
- Severity: Low · Confidence: High
- Location:
OtOpcUaNodeManager.cs:5942-5957 - Only
EventId/SourceName/Time/ReceiveTime/Message/Severityare populated. A client whose select clause includesEventType(most generic alarm viewers) gets nulls; a where-clause onOfTypecannot be honoured. Documented as "spec-conformant", but it is a functional gap for historical alarm browsing. Fix:HistoricalEventwould need to carryEventType/ConditionName; mapSourceNodefrom the source name when resolvable.
U2. Equipment-tag materialisation + its surgical branch are dead code in v3
- Severity: Low · Confidence: High
- Location:
AddressSpaceComposer.cs:590-592(EquipmentTags = Array.Empty<EquipmentTagPlan>()always),AddressSpaceApplier.cs:197-215(surgicalChangedEquipmentTagsloop),:1098-1161(MaterialiseEquipmentTags),AddressSpaceChangeClassifier.cs:162-172. - The composer never emits equipment tags, so the applier's
MaterialiseEquipmentTagspass, theUnsEquipmentVar/alarm-under-equipment branch andTagDeltaIsSurgicalEligibleare unreachable in production (kept "structurally empty"). Not a bug; it is maintained, tested dead weight that still participates inIsEmpty/classification. Candidate for removal.
U3. Historian capability bits are fixed at promotion/materialise time
- Severity: Low · Confidence: High
- Location:
OtOpcUaNodeManager.cs:2831-2848(EnsureFolderIsEventNotifierregisters_eventNotifierSourcesonly if_historianDataSource is not NullHistorianDataSourcenow),:2987/:3084(HistoryWritebit depends on_historianDataUpdaternow). - Documented. Consequence: a
HistorianDataSource/HistorianDataUpdaterassigned after the first apply leaves existing folders without event history / HistoryUpdate until a full rebuild, andSetHistorianDataSource(null)later does not remove the HistoryRead bit from already-promoted folders (they then answerBadHistoryOperationUnsupported). Acceptable given the Host ordering, but there is no runtime guard that the ordering held.
U4. Native conditions: Confirm / shelve / Enable / Disable ⇒ BadNotSupported; HistoryUpdate events/structure arms not overridden
- Severity: Low · Confidence: High
- Location:
:1496-1540, baseHistoryUpdatedispatch (not overridden forUpdateEventDetails/UpdateStructureDataDetails). - Both are deliberate and documented; listed so the parent does not re-investigate.
U5. Test seams present in production type (not dormant DI, just noting)
HistoryContinuationStoreinternal setter +InMemoryHistoryContinuationStore(HistoryContinuationStore.cs:104-158),SessionAclDecisionCache.GetOrAdd("production has no caller"),AllEventsMonitoringConditionVisits,TrackLiveMonitoredIteminternal. All default to the production implementation; none alters behaviour. Fine.
3. Performance limiters
P1. RebuildAddressSpace is O(k²) per folder because NodeState.RemoveChild is a linear scan + RemoveAt
- Severity: Medium · Confidence: High
- Location:
OtOpcUaNodeManager.cs:3475-3479,:3495-3499,:3513-3517; SDKNodeState.cs:4247-4265. - Evidence:
foreach (var v in _variables.Values) { v.Parent?.RemoveChild(v); PredefinedNodes?.Remove(v.NodeId); }and SDKRemoveChildwalksm_childrenwithReferenceEqualsthenRemoveAt(ii)(element shift). Removing all k children of one folder, in map order, costs ~k²/2 compares + shifts, all underLock. - Failure scenario: A device folder with 10k tags ≈ 50M compares under the manager lock on every full rebuild (alarm-bearing delta, container rename, etc.), stalling every Read/Browse/publish for the duration; UNS equipment folders with many references multiply it.
ForgetPredefinedSubtreealso allocates oneList<BaseInstanceState>per node recursively. - Suggested fix: Detach whole subtrees instead of leaves: remove top-level folders from
_rootonce (children go with them), then clear the maps andPredefinedNodeskeys in bulk; or iteratefolder.GetChildrenand build a new child list. KeepPredefinedNodes.Removeper id (dictionary, O(1)).
P2. RemoveEquipmentSubtree scans every map on every call ⇒ O(M·N) for M removed equipment
- Severity: Medium · Confidence: High
- Location:
OtOpcUaNodeManager.cs:3685-3752 - Evidence:
_variables.Keys.Where(InScope).ToList(), same for_alarmConditions,_notifierFolders,_eventNotifierSources,_folders, plusforeach (var wiring in _alarmNotifierWiring.Values) wiring.Folders.Where(...)— each a full scan withStartsWithper key, underLock. - Failure scenario: A PureRemove deploy that drops 200 equipment on a 50k-node server does 200 × ~6 full-map scans ≈ 60M key comparisons under the lock, on the pinned publish thread, serialising all SDK service calls.
- Suggested fix: Walk the equipment
FolderState's children (the SDK tree already has them) to enumerate descendants, or maintain a per-equipment child index populated byEnsureVariable/EnsureFolder/MaterialiseAlarmCondition.
P3. ReconcileAllEventsMonitoring runs a full O(C) reconcile on every RemoveAlarmConditionNode and every all-events (un)subscribe
- Severity: Medium · Confidence: High
- Location:
OtOpcUaNodeManager.cs:3659,:3714,:4240,:4298-4322 - Evidence: Builds a new
HashSetover_alarmConditions.Valuesplus.Where().ToList()each call. The materialise side was already made incremental ("made a deploy of N alarms O(N²) under Lock") but the removal side was not. - Failure scenario: Surgically removing M alarm-bearing raw tags or scripted alarms costs O(M·C) under
Lock; a client that creates/modifies manyi=2253items (e.g. per-subscription) pays O(C) per call. - Suggested fix: Add
LowerAllEventsMonitoring(condition)(mirror ofRaiseAllEventsMonitoring) for the two surgical removal sites; keep the full reconcile for rebuild and all-events (un)subscribe.
P4. HistoryRead/HistoryUpdate arms block SDK request threads (sync-over-async) with a 5 s admission wait
- Severity: Low · Confidence: High
- Location:
OtOpcUaNodeManager.cs:5997-6014(RunBounded→Task.WhenAll(...).GetAwaiter().GetResult()),:6065-6080(gate.Wait(≤5 s)). - Documented and bounded (16 batches × 4 nodes), and HistoryRead is not under
Lock. Under a slow historian (deadline 60 s) up to 16 SDK request threads sit blocked for a minute while 16 more block 5 s each before fast-failing; withMaxRequestThreadCount = 100this is survivable but eats 1/3 of request capacity. ConsiderMaxConcurrentHistoryReadBatchesdefault lower on small nodes, or an asyncHistoryReadAsyncoverride if the SDK version exposes one (IAsyncNodeManagerexists in this SDK line).
P5. WireAlarmNotifiers / SDK notifier lists are linear per folder
- Severity: Low · Confidence: High
- Location:
:1723-1760; SDKNodeState.AddNotifier(NodeState.cs:2385-2410, linear dedup). - An equipment folder referencing K native alarms costs O(K²)
ReferenceEqualson wiring plus per-callToHashSet/Where().ToList()/Anyallocations. Bounded by K (tens–hundreds); only matters on rebuild of alarm-heavy equipment.
P6. Hard-coded SDK request quotas
- Severity: Low · Confidence: High
- Location:
OpcUaApplicationHost.cs:435-441—MinRequestThreadCount = 5, MaxRequestThreadCount = 100, MaxQueuedRequestCount = 200,TransportQuotas = new TransportQuotas()(defaults). - Not configurable from
appsettings; a burst above 200 queued requests is refused by the SDK. Fine for current fleet sizes; flagging as the ceiling.
4. Other correctness / concurrency hazards
H1. Removed-tag resurrection + re-add poisoning (see L1) — correctness, not just leak
Already covered; the correctness half (a re-added tag silently inherits the phantom's shape) is the more serious consequence.
H2. AddPredefinedNode is AddOrUpdate: any id collision between a variable and a folder in the same realm silently replaces the folder's lookup entry
- Severity: Medium · Confidence: High
- Location:
OtOpcUaNodeManager.cs:2901,:3014,:1559,:6984; SDKCustomNodeManager.cs:586. _variables,_folders,_alarmConditionsare separate maps keyed by(realm, id), so nothing prevents the same(realm, id)existing as both a folder and a variable (L4 shows a production producer). The SDK dictionary keeps whichever was added last. Suggest a cross-map guard inEnsureFolder/EnsureVariable/MaterialiseAlarmCondition(ResolveNodeState(key) is not null ⇒ log + return).
H3. ConditionRefresh (ACL path) walks notifier subtrees outside Lock
- Severity: Low · Confidence: High
- Location:
OtOpcUaNodeManager.cs:4646-4651 - Only the
RootNotifierscopy is taken underLock;notifier.ConditionRefresh(...)then walks children while a rebuild can be mutating child lists. The SDK base does exactly the same (CustomNodeManager.cs:3488-3515), andNodeStateguards its child list withm_childrenLock, so this is parity with the SDK rather than a regression. Listed so it is not re-raised.
H4. RemainingShelveMs and condition timestamps use DateTime.UtcNow, not Clock
- Severity: Low · Confidence: High
- Location:
:1087,:1452,:1514,:1926,:2621-2626,:4778. - Inconsistent with
HistoryUpdate/EventHorizon, which use the injectedTimeProvider; purely a testability/consistency nit.
H5. OnEquipmentTagWrite relies on gateway.WriteAsync never throwing synchronously
- Severity: Low · Confidence: Medium
- Location:
:2202 - A synchronous throw escapes the handler while the SDK holds
LockinWrite; the SDK's per-nodetry(CustomNodeManager.csWrite, around line 1885) converts it to a Bad result, so the lock is released — no deadlock, but the optimistic/prior bookkeeping is skipped and no audit event is raised. Wrap the call in try/catch → treat as failed outcome.
5. Checked and fine (do not re-investigate)
_liveItems/_eventItems/_allEventsItemslifecycle.OnMonitoredItemDeletedremoves data items;MasterNodeManagerroutes every delete/transfer of event items throughSubscribeToEvents(…, unsubscribe:true)/SubscribeToAllEvents(…, true)(MasterNodeManager.cs:3329-3345,:3528-3545), which the overrides handle. Not session-leaking.DataPlaneAuthorityevent handler is detached in the setter and inDispose(:448-454,:488-500). Correct.SessionAclDecisionCacheis doubly bounded (20k/shard, 100k global) with sealed-shard counters; sharded by key, cleared on generation advance. No unbounded growth.HistoryContinuationStoreproduction path is the SDK per-session store (capped atMaxHistoryContinuationPoints, disposed with the session).InMemoryHistoryContinuationStoreis test-only and capacity-bounded.CreateDeadline()CTS isusing-disposed per arm;RunBounded'sSemaphoreSlimisusing-disposed; the process-wide_historyReadBatchLimiteris never disposed but holds no OS handle._rebuildCarryOver/_restoredConditionReportscleared atCompleteAddressSpaceApply, which the publish actor calls in afinally(OpcUaPublishActor.cs:500). Bounded.AddRootNotifiercost: SDK copiesServer.EventManager.GetMonitoredItems()per newly-promoted folder and callsSubscribeToEvents(…, true)for all-events items; the protected override short-circuits those (:4257-4261). O(E) per folder on first promotion only.WriteValueschunking (512 perLocktake) is a deliberate, documented trade-off;ClearChangeMasksunderLockis the SDK pattern.- Alarm method handlers under
Lock(ReportAfterMethod,IsNativeAlarmNode) —Lockis a reentrantMonitor; the router delegates are fire-and-forget by contract. - Revalidation lock ordering:
QueueValue(item lock) is taken outsideLock, thenLockis taken for attach/detach — no nested inversion with the publish path (Lock→ item lock). AdvertisedAggregateFunctions.Applytakes the Diagnostics node manager'sLockfrom theHistorianDataSourcesetter — called from host start, never while ourLockis held.OpcUaApplicationHost.DisposeAsyncdetachesImpersonateUserand stops the application; the hosted service stops/disposes the server (OtOpcUaServerHostedService.cs:357-365).- Impersonation
GetAwaiter().GetResult()is irreducible (SDK sync callback underSessionManagerlock) and the API-key authenticator does no I/O; documented. AddressSpacePlanner.DiffByIdis dictionary-based O(n);AddressSpaceComposerbuilds all lookups as dictionaries (no quadratic joins found).BuildEquipmentFolderPathsis computed twice per compose (once for refs, once for raw) — trivial.ReverseWalkhas explicit termination arguments (segment always narrows;granularityfloor), aLookAhead/Floorclamp, and the tie-cluster budget throws rather than looping.