From 00300d0f606717f1bbc67d7e13c75fd23043d709 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 06:41:09 -0400 Subject: [PATCH 01/67] fix(opcua): no write ever creates a node; cross-kind id guard (B1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 01 L1/L4/H2. WriteValue/WriteValues are non-creating (an unknown id is skipped, counted and Debug-logged, as WriteStatuses always was), so a driver publish racing a surgical remove can no longer resurrect the id as a flat BaseDataType phantom under the shared root — nor hand a later re-add of the same tag the phantom's parent/DataType/writability through EnsureVariable's early return. WriteAlarmCondition for an unmaterialised id is a logged no-op instead of minting the legacy bool[2] placeholder (AddPredefinedNode is AddOrUpdate, so it replaced the SDK lookup entry of any folder sharing the id). The applier no longer writes a condition state against every removed equipment id (a folder, never a condition). EnsureFolder/EnsureVariable/MaterialiseAlarmCondition refuse an id already held by another node kind in the same realm. CreateVariable is deleted. Tests: NodeManagerPhantomNodeTests (publish racing a remove leaves no node; the re-add gets the authored shape and is live; unknown alarm id is a no-op; the guard in all three directions, realm-qualified); the sink / batch-write / write-statuses tests now declare their variables. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AddressSpaceApplier.cs | 10 +- .../OtOpcUaNodeManager.cs | 158 ++++++------ .../AddressSpaceApplierFailureSurfaceTests.cs | 30 ++- .../NodeManagerBatchWriteTests.cs | 16 +- .../NodeManagerPhantomNodeTests.cs | 231 ++++++++++++++++++ .../OtOpcUaNodeManagerWriteStatusesTests.cs | 9 +- .../SdkAddressSpaceSinkTests.cs | 41 +++- 7 files changed, 396 insertions(+), 99 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPhantomNodeTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index bd7e8ba0..ce85a7ff 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -93,15 +93,13 @@ public sealed class AddressSpaceApplier } var ts = DateTime.UtcNow; - var removedCount = 0; + // A removed equipment is a FOLDER: it gets no terminal condition write (review 01 L4 — the write the + // applier used to make against the equipment id never found a condition and minted a placeholder + // variable that shadowed the folder's SDK lookup entry). Its subtree teardown is in ApplyPureRemove. + var removedCount = plan.RemovedEquipment.Count; // Swallowed removal-pass condition-write failures are tallied here (not lost to a per-node Warning) // so a degraded removal is operator-visible via AddressSpaceApplyOutcome.FailedNodes (archreview 01/S-1). var failedNodes = 0; - foreach (var eq in plan.RemovedEquipment) - { - if (!SafeWriteAlarmCondition(eq.EquipmentId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++; - removedCount++; - } foreach (var alarm in plan.RemovedAlarms) { if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index f8355b90..a49edfa9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -18,18 +18,19 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer; /// /// Custom OPC UA that owns the writable address space for -/// the OtOpcUa server. Variable nodes are created lazily on first -/// under the manager's namespace; subsequent writes update the existing node's Value + -/// StatusCode + SourceTimestamp and notify subscribed clients via the standard -/// ClearChangeMasks path. +/// the OtOpcUa server. Variable nodes are declared by (the applier's +/// materialise passes); a updates an existing node's Value + StatusCode + +/// SourceTimestamp and notifies subscribed clients via the standard ClearChangeMasks path. +/// No write ever creates a node (review 01 L1): a publish naming an id a surgical remove has just +/// dropped is skipped, so it can neither resurrect the node as a flat phantom under the shared root nor +/// hand a later re-add of the same id the phantom's shape. /// /// This is the F10b production wiring behind the v2 /// seam — once a is bound, OpcUaPublishActor's writes /// materialise as real OPC UA Variable updates that clients can browse + subscribe to. /// /// Node-id encoding uses the manager's default namespace + the caller-supplied string id -/// as the identifier portion (e.g. "ns=2;s=eq-1/temp"). Beyond lazily-created flat -/// variables, the manager also materialises the full UNS Area/Line/Equipment folder hierarchy +/// as the identifier portion (e.g. "ns=2;s=eq-1/temp"). The manager materialises the full UNS Area/Line/Equipment folder hierarchy /// (), typed equipment-tag variables with the correct built-in /// DataType / array shape / access levels (), historized /// nodes (the HistoryRead access bit + HistoryRead overrides), and real Part 9 @@ -705,9 +706,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// - /// Apply a value write from . Creates the - /// variable node on first call; subsequent calls update Value + StatusCode + - /// SourceTimestamp and call ClearChangeMasks so subscribed clients see the change. + /// Apply a value write from : update the materialised + /// variable's Value + StatusCode + SourceTimestamp and call ClearChangeMasks so subscribed clients + /// see the change. Never creates a node — an id nothing declared via + /// (a publish racing a surgical remove) is skipped and Debug-logged, exactly as + /// always did (review 01 L1). /// /// The node identifier of the variable. /// The new value to write. @@ -720,10 +723,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 ArgumentException.ThrowIfNullOrEmpty(nodeId); EnsureAddressSpaceCreated(); - var ns = NamespaceIndexForRealm(realm); var key = MapKey(realm, nodeId); + bool applied; lock (Lock) - WriteValueLocked(nodeId, ns, key, value, statusCode, sourceTimestampUtc); + applied = WriteValueLocked(key, value, statusCode, sourceTimestampUtc); + + if (!applied) + { + _logger.LogDebug( + "OtOpcUaNodeManager: WriteValue skipped {NodeId} (realm {Realm}) — node not materialised.", + nodeId, realm); + } } /// The most items applies under a SINGLE Lock acquisition before @@ -741,7 +751,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Batched : one Lock acquisition per chunk of /// items instead of one per value — the publish path's per-value /// lock take was the steady-state throughput ceiling. Per-item semantics are identical to - /// (create-on-first-write verbatim status codes, ClearChangeMasks per node). + /// (never creates; verbatim status codes; ClearChangeMasks per node) — an item naming a + /// node that is not materialised is skipped and counted, not failed (the caller's per-driver ref index can + /// legitimately name a node a surgical remove has just dropped). /// Items are isolated. A bad item (empty NodeId, or a throw out of the SDK) does not abandon /// the rest of the batch: it is recorded and the loop continues, and the aggregate surfaces afterwards as /// a . Dropping the tail would be silent and durable — the poll @@ -785,6 +797,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } var succeeded = 0; + var skipped = 0; for (var start = 0; start < values.Count; start += MaxWritesPerLockAcquisition) { var end = Math.Min(start + MaxWritesPerLockAcquisition, values.Count); @@ -796,9 +809,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var v = values[i]; try { - WriteValueLocked(resolved[i].NodeId, resolved[i].NamespaceIndex, resolved[i].Key, - v.Value, v.StatusCode, v.SourceTimestampUtc); - succeeded++; + if (WriteValueLocked(resolved[i].Key, v.Value, v.StatusCode, v.SourceTimestampUtc)) succeeded++; + else skipped++; } catch (Exception ex) { @@ -810,6 +822,16 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } + if (skipped > 0) + { + // Debug, not Warning: the caller's index racing a surgical remove is expected, and a driver + // whose nodes were all just removed would otherwise log once per ref. + _logger.LogDebug( + "OtOpcUaNodeManager: WriteValues skipped {Skipped} of {Total} item(s) — node not materialised.", + skipped, + values.Count); + } + if (failed > 0) { throw new ValueWriteBatchException( @@ -925,24 +947,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// False when the item failed the pre-pass and must be skipped. private readonly record struct ResolvedWrite(string NodeId, ushort NamespaceIndex, RealmKey Key, bool IsValid); - /// The body of a single value write. Caller MUST hold Lock — this is factored out - /// solely so can apply a whole batch under one acquisition; it is byte-identical - /// to what has always done inside its own lock. - /// The bare s= identifier of the variable. - /// The namespace index resolved from the write's realm. - /// The realm-qualified map key for . + /// The body of a single value write. Caller MUST hold Lock — factored out so + /// can apply a whole batch under one acquisition. + /// Non-creating (review 01 L1). The former create-on-first-write branch minted a flat, read-only, + /// BaseDataType variable under the shared root whenever a publish raced a surgical remove; it stayed + /// until the next full rebuild, and because early-returns on an existing id, a + /// later re-add of the same tag silently kept the phantom's parent, DataType and writability. + /// The realm-qualified map key of the variable. /// The new value to write. /// The full OPC UA status code, sub-code included. /// The timestamp of the value in UTC. - private void WriteValueLocked(string nodeId, ushort ns, RealmKey key, object? value, uint statusCode, DateTime sourceTimestampUtc) + /// when the node existed and was updated; when no + /// such node is materialised (skipped — deliberately not a create). + private bool WriteValueLocked(RealmKey key, object? value, uint statusCode, DateTime sourceTimestampUtc) { - // CreateVariable mutates the SDK address space (_root.AddChild + AddPredefinedNode), - // so it MUST run under Lock — the SDK's subscription/ConditionRefresh threads take it too. - if (!_variables.TryGetValue(key, out var variable)) - { - variable = CreateVariable(nodeId, ns); - _variables[key] = variable; - } + if (!_variables.TryGetValue(key, out var variable)) return false; variable.Value = value; // The caller's status code reaches the SDK verbatim, sub-code included. This used to @@ -952,6 +971,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 variable.Timestamp = sourceTimestampUtc; // Stays INSIDE the lock, per node: this is how the SDK notifies monitored items of the change. variable.ClearChangeMasks(SystemContext, includeChildren: false); + return true; } /// @@ -959,10 +979,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// been materialised for (via ), /// this projects the whole snapshot /// (Enabled / Active / Acked / Confirmed / Shelving / Severity / Message) onto the live condition - /// node and recomputes Retain (T15 — richer state; still no event firing, that lands in T16). - /// Otherwise it falls back to the legacy two-element [Active, Acknowledged] - /// placeholder so callers whose alarm node hasn't been - /// materialised (and the existing unit tests) keep working. + /// node, recomputes Retain and fires one condition event when the state actually moved. + /// An id with no materialised condition is a logged no-op (review 01 L4) — it used to mint a legacy + /// bool[2] placeholder variable whose AddPredefinedNode (an AddOrUpdate) REPLACED the SDK + /// lookup entry of any folder sharing the id, which the applier's per-removed-equipment write did on + /// every removal. /// /// The node identifier of the alarm (== ScriptedAlarmId for materialised conditions). /// The full condition state to project onto the node. @@ -974,13 +995,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 ArgumentNullException.ThrowIfNull(state); EnsureAddressSpaceCreated(); - var ns = NamespaceIndexForRealm(realm); var key = MapKey(realm, alarmNodeId); // Look up + project under a SINGLE Lock so a concurrent RebuildAddressSpace can't clear // _alarmConditions / detach the condition node between the lookup and the Set* calls. lock (Lock) { - if (_alarmConditions.TryGetValue(key, out var condition)) + if (!_alarmConditions.TryGetValue(key, out var condition)) + { + // Unknown / unmaterialised condition ⇒ safe no-op (a mid-rebuild race, or a caller naming a + // node that is not a condition), mirroring WriteAlarmQuality. + _logger.LogDebug( + "OtOpcUaNodeManager: WriteAlarmCondition skipped {NodeId} (realm {Realm}) — condition not materialised.", + alarmNodeId, realm); + return; + } + { // T20 delta-gate: read the node's CURRENT live condition state FIRST (before projecting // the incoming snapshot onto it), then decide fire-vs-suppress by comparing the incoming @@ -1053,22 +1082,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { ReportConditionEvent(condition, sourceTimestampUtc); } - return; } - - // Fallback: alarm not materialised as a real condition — keep the legacy bool[2] variable so - // un-materialised callers (and the existing unit tests) keep working. CreateVariable mutates - // the SDK address space, so it MUST run under Lock (see WriteValue). - if (!_variables.TryGetValue(key, out var variable)) - { - variable = CreateVariable(alarmNodeId, ns); - _variables[key] = variable; - } - - variable.Value = new[] { state.Active, state.Acknowledged }; - variable.StatusCode = StatusCodes.Good; - variable.Timestamp = sourceTimestampUtc; - variable.ClearChangeMasks(SystemContext, includeChildren: false); } } @@ -1364,6 +1378,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // stays ONLY for the kind-swap (native↔scripted) case, which flips the native flag. if (_alarmConditions.ContainsKey(conditionKey) && _nativeAlarmNodeIds.Contains(conditionKey) == isNative) return; + // Review 01 H2 — a variable or folder already holding this id in this realm wins; never shadow it. + if (!_alarmConditions.ContainsKey(conditionKey) && IsHeldByAnotherKind(conditionKey, "alarm condition")) + return; // Idempotent: drop any prior node for this id so a re-materialise (e.g. changed // type/severity on redeploy) reflects cleanly instead of leaking the old node. @@ -2886,6 +2903,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) { if (_folders.ContainsKey(key)) return; + if (IsHeldByAnotherKind(key, "folder")) return; var parent = ResolveParentFolder(parentNodeId, realm); var folder = new FolderState(parent) @@ -2977,6 +2995,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) { if (_variables.ContainsKey(key)) return; + if (IsHeldByAnotherKind(key, "variable")) return; var parent = ResolveParentFolder(parentFolderNodeId, realm); // Phase C: a non-null historian tagname makes the node Historizing and grants the HistoryRead @@ -6961,28 +6980,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } - private BaseDataVariableState CreateVariable(string nodeId, ushort ns) + /// + /// Review 01 H2 — the cross-map guard every node-minting entry point (, + /// , ) runs before creating a node: + /// true (and a Warning) when is already held by a node of ANOTHER kind. + /// The three maps are separate, so nothing else stops one (realm, id) being both a folder and a + /// variable — and the SDK's AddPredefinedNode is an AddOrUpdate, so the second mint silently + /// replaced the first's lookup entry. MUST be called under Lock. + /// + /// The realm-qualified id about to be minted. + /// The kind the caller is minting (for the log line). + /// when the id is taken by another kind and the caller must not mint. + private bool IsHeldByAnotherKind(RealmKey key, string requestedKind) { - var v = new BaseDataVariableState(_root) - { - NodeId = new NodeId(nodeId, ns), - BrowseName = new QualifiedName(nodeId, ns), - DisplayName = nodeId, - TypeDefinitionId = VariableTypeIds.BaseDataVariableType, - ReferenceTypeId = ReferenceTypeIds.Organizes, - DataType = DataTypeIds.BaseDataType, - // Lazy-created nodes (a WriteValue for a node never declared via EnsureVariable) carry no - // array intent, so they stay scalar with no ArrayDimensions — mirrors EnsureVariable's - // scalar branch. Array nodes are always pre-declared via EnsureVariable(isArray:true). - ValueRank = ValueRanks.Scalar, - ArrayDimensions = null, - AccessLevel = AccessLevels.CurrentRead, - UserAccessLevel = AccessLevels.CurrentRead, - Historizing = false, - }; - _root?.AddChild(v); - AddPredefinedNode(SystemContext, v); - return v; + if (ResolveNodeState(key) is not { } existing) return false; + _logger.LogWarning( + "OtOpcUaNodeManager: refusing to materialise {Kind} {NodeId} (realm {Realm}) — the id is already held by a {ExistingKind} node", + requestedKind, key.Id, key.Realm, existing.GetType().Name); + return true; } - } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs index 08076fb1..a94b824b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -45,17 +45,32 @@ public sealed class AddressSpaceApplierFailureSurfaceTests // ---------------- T2: FailedNodes (removal-pass tally) ---------------- - /// A removal-pass alarm-condition write that throws is counted into FailedNodes (swallowed today). + /// A removal-pass alarm-condition write that throws is counted into FailedNodes (swallowed today). + /// The terminal condition write is made for a removed SCRIPTED ALARM — a removed equipment gets none (review + /// 01 L4: an equipment id is a folder, never a condition, so writing one only minted a phantom). [Fact] public void Apply_WhenRemovalConditionWriteThrows_CountsFailedNodes() { var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true }; var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var outcome = applier.Apply(AlarmRemovalPlan("alm-1")); + + outcome.RemovedNodes.ShouldBe(1); + outcome.FailedNodes.ShouldBe(1); + } + + /// Review 01 L4 — removing an equipment writes NO condition state against the equipment id. + [Fact] + public void Apply_RemovedEquipment_WritesNoConditionAgainstTheEquipmentId() + { + var sink = new ConfigurableThrowingSink { ThrowOnAlarmWrite = true }; + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var outcome = applier.Apply(EquipmentRemovalPlan("eq-1")); outcome.RemovedNodes.ShouldBe(1); - outcome.FailedNodes.ShouldBe(1); + outcome.FailedNodes.ShouldBe(0, "no condition write happens for an equipment folder, so a throwing alarm write cannot be reached"); } // ---------------- T3: Materialise* passes return failed-node counts ---------------- @@ -135,6 +150,17 @@ public sealed class AddressSpaceApplierFailureSurfaceTests RemovedAlarms: Array.Empty(), ChangedAlarms: Array.Empty()); + private static AddressSpacePlan AlarmRemovalPlan(params string[] ids) => new( + AddedEquipment: Array.Empty(), + RemovedEquipment: Array.Empty(), + ChangedEquipment: Array.Empty(), + AddedDrivers: Array.Empty(), + RemovedDrivers: Array.Empty(), + ChangedDrivers: Array.Empty(), + AddedAlarms: Array.Empty(), + RemovedAlarms: ids.Select(id => new ScriptedAlarmPlan(id, "eq-1", "pred", "msg")).ToArray(), + ChangedAlarms: Array.Empty()); + private static AddressSpacePlan EquipmentRemovalPlan(params string[] ids) => new( AddedEquipment: Array.Empty(), RemovedEquipment: ids.Select(id => new EquipmentNode(id, id, "line-1")).ToArray(), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs index bb0c8725..92233d32 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerBatchWriteTests.cs @@ -12,8 +12,9 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// per-value take was the steady-state throughput ceiling on the publish path; the chunk cap keeps the /// first poll cycle's force-raise-everything batch from stalling every SDK service thread). /// These tests pin the observable contract rather than the locking: every item lands with -/// its own Value / StatusCode / Timestamp, an item for an undeclared NodeId is lazily created exactly -/// as does, and an empty batch does nothing at all. +/// its own Value / StatusCode / Timestamp, the realm travels with every item (the same s= id in the +/// two namespaces is two nodes), and an empty batch does nothing at all. An item for an undeclared NodeId +/// is skipped, never created (review 01 L1 — ). /// A non-Good status is included so the #519 verbatim-sub-code passthrough is proven on the batched /// path too — a batch that re-projected status through a 3-state enum would collapse it to a bare /// Bad. @@ -90,16 +91,16 @@ public sealed class NodeManagerBatchWriteTests : IDisposable /// assertion is therefore two nodes with DISTINCT namespace indices, not merely "both reads returned /// something" (which a realm-blind map satisfies, since the shared key resolves for either lookup). [Fact] - public async Task WriteValues_creates_missing_variables() + public async Task WriteValues_is_realm_qualified() { var (host, server) = await BootAsync(); try { var nm = server.NodeManager!; - const string SharedId = "Shared/lazy/id"; - nm.TryGetVariable(SharedId, AddressSpaceRealm.Uns).ShouldBeNull(); - nm.TryGetVariable(SharedId, AddressSpaceRealm.Raw).ShouldBeNull(); + const string SharedId = "Shared/both/id"; + nm.EnsureVariable(SharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Uns); + nm.EnsureVariable(SharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw); var ts = new DateTime(2026, 8, 15, 11, 0, 0, DateTimeKind.Utc); nm.WriteValues(new List @@ -143,6 +144,8 @@ public sealed class NodeManagerBatchWriteTests : IDisposable { var nm = server.NodeManager!; var ts = new DateTime(2026, 8, 15, 12, 0, 0, DateTimeKind.Utc); + nm.EnsureVariable("eq-iso/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Uns); + nm.EnsureVariable("eq-iso/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Uns); var ex = Should.Throw(() => nm.WriteValues(new List { @@ -187,6 +190,7 @@ public sealed class NodeManagerBatchWriteTests : IDisposable var items = new List(Count); for (var i = 0; i < Count; i++) { + nm.EnsureVariable($"eq-chunk/{i}", null, $"{i}", "Int32", writable: false, realm: AddressSpaceRealm.Uns); items.Add(new ValueWrite($"eq-chunk/{i}", i, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns)); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPhantomNodeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPhantomNodeTests.cs new file mode 100644 index 00000000..821f0dab --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerPhantomNodeTests.cs @@ -0,0 +1,231 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Review 01 L1 / L4 / H2 — the node manager never materialises a node from a VALUE or ALARM write, and +/// never lets one (realm, id) be held by two different node kinds. +/// +/// The lazy create-on-first-write path predates and had +/// no production producer left — but it had a production consumer by accident: a driver publish +/// (or the UNS fan-out) racing a surgical remove re-created the removed id as a flat, read-only, +/// BaseDataType variable under the shared root, and because EnsureVariable early-returns +/// on an existing id, a later re-add of the same tag silently kept the phantom's shape. +/// +/// +public sealed class NodeManagerPhantomNodeTests : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-phantom-{Guid.NewGuid():N}"); + + /// A value write to an id nothing declared creates nothing. + [Fact] + public async Task WriteValue_for_an_undeclared_id_creates_no_node() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var before = nm.VariableCount; + + nm.WriteValue("Plant/Modbus/dev1/Ghost", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + + nm.VariableCount.ShouldBe(before); + nm.TryGetVariable("Plant/Modbus/dev1/Ghost", AddressSpaceRealm.Raw).ShouldBeNull(); + nm.GetManagerHandle(new NodeId("Plant/Modbus/dev1/Ghost", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw))) + .ShouldBeNull("the SDK must not resolve a node that was never declared"); + } + finally { await host.DisposeAsync(); } + } + + /// + /// A batch naming a removed id skips it (no node, no exception) while the declared ids in the same batch + /// land — the positive control that proves the skip is a skip and not a dropped batch. + /// + [Fact] + public async Task WriteValues_skips_undeclared_ids_and_still_applies_the_declared_ones() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureVariable("Plant/Modbus/dev1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + var ts = DateTime.UtcNow; + + nm.WriteValues( + [ + new ValueWrite("Plant/Modbus/dev1/Ghost", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw), + new ValueWrite("Plant/Modbus/dev1/Speed", 42, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw), + ]); + + nm.TryGetVariable("Plant/Modbus/dev1/Ghost", AddressSpaceRealm.Raw).ShouldBeNull(); + nm.TryGetVariable("Plant/Modbus/dev1/Speed", AddressSpaceRealm.Raw)!.Value.ShouldBe(42); + } + finally { await host.DisposeAsync(); } + } + + /// + /// The L1 scenario end to end: a publish racing a surgical remove leaves NO node, and the re-add that + /// follows gets the AUTHORED DataType, parent and writability rather than inheriting a phantom's. + /// + [Fact] + public async Task A_publish_racing_a_surgical_remove_leaves_no_node_and_the_readd_gets_the_authored_shape() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + const string id = "Plant/Modbus/dev1/Speed"; + nm.EnsureFolder("Plant/Modbus/dev1", null, "dev1", AddressSpaceRealm.Raw); + nm.EnsureVariable(id, "Plant/Modbus/dev1", "Speed", "Float", writable: false, realm: AddressSpaceRealm.Raw); + nm.RemoveVariableNode(id, AddressSpaceRealm.Raw).ShouldBeTrue(); + + // The race: the driver's fan-out publishes the id the surgical remove just dropped. + nm.WriteValue(id, 1.5f, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + nm.TryGetVariable(id, AddressSpaceRealm.Raw).ShouldBeNull("the racing publish must not resurrect the node"); + + // The re-add (a later deploy re-authors the tag with a different shape). + nm.EnsureVariable(id, "Plant/Modbus/dev1", "Speed", "Int32", writable: true, realm: AddressSpaceRealm.Raw); + + var v = nm.TryGetVariable(id, AddressSpaceRealm.Raw); + v.ShouldNotBeNull(); + v!.DataType.ShouldBe(DataTypeIds.Int32); + v.Parent.ShouldBeSameAs(nm.TryGetFolder("Plant/Modbus/dev1", AddressSpaceRealm.Raw)); + ((int)v.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe(0, "the re-added node is writable as authored"); + v.OnWriteValue.ShouldNotBeNull("a writable node carries the inbound-write handler"); + + // Positive control: the re-added node is live — a publish lands on it. + nm.WriteValue(id, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + v.Value.ShouldBe(7); + } + finally { await host.DisposeAsync(); } + } + + /// A condition write for an id that was never materialised is a logged no-op (L4): no placeholder variable. + [Fact] + public async Task WriteAlarmCondition_for_an_unknown_id_creates_nothing() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns); + var before = nm.VariableCount; + + var snapshot = new AlarmConditionSnapshot( + Active: false, Acknowledged: true, Confirmed: true, Enabled: true, + Shelving: AlarmShelvingKind.Unshelved, Severity: 0, Message: string.Empty); + // The applier used to write this against the EQUIPMENT id on every removal — a folder, never a condition. + nm.WriteAlarmCondition("eq-1", snapshot, DateTime.UtcNow, AddressSpaceRealm.Uns); + + nm.VariableCount.ShouldBe(before); + nm.TryGetVariable("eq-1").ShouldBeNull(); + // The folder's SDK lookup entry survives (the phantom used to REPLACE it — AddPredefinedNode is AddOrUpdate). + var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("eq-1", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns))); + handle.ShouldNotBeNull(); + handle!.Node.ShouldBeOfType(); + } + finally { await host.DisposeAsync(); } + } + + /// H2 — a variable may not take an id a folder already holds in the same realm. + [Fact] + public async Task EnsureVariable_refuses_an_id_held_by_a_folder() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns); + + nm.EnsureVariable("eq-1", null, "eq-1", "Int32", writable: false, realm: AddressSpaceRealm.Uns); + + nm.TryGetVariable("eq-1").ShouldBeNull(); + nm.TryGetFolder("eq-1").ShouldNotBeNull(); + var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("eq-1", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns))); + handle!.Node.ShouldBeSameAs(nm.TryGetFolder("eq-1")); + } + finally { await host.DisposeAsync(); } + } + + /// H2 — a folder may not take an id a variable already holds in the same realm. + [Fact] + public async Task EnsureFolder_refuses_an_id_held_by_a_variable() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureVariable("Plant/X", null, "X", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + + nm.EnsureFolder("Plant/X", null, "X", AddressSpaceRealm.Raw); + + nm.TryGetFolder("Plant/X", AddressSpaceRealm.Raw).ShouldBeNull(); + var handle = (NodeHandle?)nm.GetManagerHandle(new NodeId("Plant/X", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw))); + handle!.Node.ShouldBeSameAs(nm.TryGetVariable("Plant/X", AddressSpaceRealm.Raw)); + } + finally { await host.DisposeAsync(); } + } + + /// H2 — a condition may not take an id a variable already holds in the same realm. + [Fact] + public async Task MaterialiseAlarmCondition_refuses_an_id_held_by_a_variable() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureFolder("Plant/Modbus/dev1", null, "dev1", AddressSpaceRealm.Raw); + nm.EnsureVariable("Plant/Modbus/dev1/temp", "Plant/Modbus/dev1", "temp", "Float", writable: false, realm: AddressSpaceRealm.Raw); + + nm.MaterialiseAlarmCondition("Plant/Modbus/dev1/temp", "Plant/Modbus/dev1", "temp", "OffNormalAlarm", 500, AddressSpaceRealm.Raw, isNative: true); + + nm.TryGetAlarmCondition("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldBeNull(); + nm.TryGetVariable("Plant/Modbus/dev1/temp", AddressSpaceRealm.Raw).ShouldNotBeNull(); + } + finally { await host.DisposeAsync(); } + } + + /// The same bare id in the OTHER realm is a different node and is still allowed (the guard is realm-qualified). + [Fact] + public async Task The_guard_is_realm_qualified() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureFolder("shared", null, "shared", AddressSpaceRealm.Uns); + + nm.EnsureVariable("shared", null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + + nm.TryGetVariable("shared", AddressSpaceRealm.Raw).ShouldNotBeNull(); + nm.TryGetFolder("shared", AddressSpaceRealm.Uns).ShouldNotBeNull(); + } + finally { await host.DisposeAsync(); } + } + + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() => + await OpcUaHostStart.StartAsync( + port => new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.PhantomTest", + ApplicationUri = $"urn:OtOpcUa.PhantomTest:{Guid.NewGuid():N}", + OpcUaPort = port, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OtOpcUaNodeManagerWriteStatusesTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OtOpcUaNodeManagerWriteStatusesTests.cs index 6de7a0c2..8cf7211e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OtOpcUaNodeManagerWriteStatusesTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OtOpcUaNodeManagerWriteStatusesTests.cs @@ -19,8 +19,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// Never creates. A status carries nothing to create a node from, and the caller's /// per-driver index can legitimately name a node a surgical remove has just dropped. A missing id -/// is skipped, not materialised — the opposite of , -/// which lazily creates. +/// is skipped, not materialised — the same contract +/// carries since review 01 L1 (it used to lazily create). /// /// Boots a real , like /// , so the assertions read the SDK node state a client @@ -81,6 +81,8 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc); const string sharedId = "Shared/both/realms"; + nm.EnsureVariable(sharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + nm.EnsureVariable(sharedId, null, "shared", "Int32", writable: false, realm: AddressSpaceRealm.Uns); nm.WriteValue(sharedId, 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); nm.WriteValue(sharedId, 2, OpcUaStatus.Good, ts, AddressSpaceRealm.Uns); @@ -110,6 +112,7 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable { var nm = server.NodeManager!; var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc); + nm.EnsureVariable("Plant/known", null, "known", "Int32", writable: false, realm: AddressSpaceRealm.Raw); nm.WriteValue("Plant/known", 7, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); var before = nm.VariableCount; @@ -143,6 +146,8 @@ public sealed class OtOpcUaNodeManagerWriteStatusesTests : IDisposable { var nm = server.NodeManager!; var ts = new DateTime(2026, 8, 20, 10, 0, 0, DateTimeKind.Utc); + nm.EnsureVariable("Plant/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + nm.EnsureVariable("Plant/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Raw); nm.WriteValue("Plant/first", 1, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); nm.WriteValue("Plant/third", 3, OpcUaStatus.Good, ts, AddressSpaceRealm.Raw); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs index 932e1f95..0611c42f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/SdkAddressSpaceSinkTests.cs @@ -20,18 +20,25 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable Path.GetTempPath(), $"otopcua-sink-{Guid.NewGuid():N}"); - /// Verifies that WriteValue creates and updates variables in the OPC UA node manager. + /// Verifies that WriteValue updates declared variables in the OPC UA node manager (and never + /// creates one — review 01 L1, pinned by ). [Fact] - public async Task WriteValue_creates_and_updates_variable_in_node_manager() + public async Task WriteValue_updates_declared_variables_in_node_manager() { var (host, server) = await BootAsync(); - var sink = new SdkAddressSpaceSink(server.NodeManager!); + var nm = server.NodeManager!; + var sink = new SdkAddressSpaceSink(nm); + sink.EnsureVariable("eq-1/temp", null, "temp", "Double", writable: false, realm: AddressSpaceRealm.Uns); + sink.EnsureVariable("eq-1/pressure", null, "pressure", "Int32", writable: false, realm: AddressSpaceRealm.Uns); sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); sink.WriteValue("eq-1/temp", 23.1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); sink.WriteValue("eq-1/pressure", 100, OpcUaStatus.Uncertain, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue("eq-1/undeclared", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); - server.NodeManager!.VariableCount.ShouldBe(2); + nm.VariableCount.ShouldBe(2); + nm.TryGetVariable("eq-1/temp")!.Value.ShouldBe(23.1); + nm.TryGetVariable("eq-1/pressure")!.StatusCode.ShouldBe((StatusCode)StatusCodes.Uncertain); await host.DisposeAsync(); } @@ -50,6 +57,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable // (a concrete-typed call would simply stop compiling, which is not the regression being pinned). IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm); var ts = new DateTime(2026, 8, 15, 9, 0, 0, DateTimeKind.Utc); + sink.EnsureVariable("Plant/dev1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + sink.EnsureVariable("eq-1/Speed", null, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Uns); sink.WriteValues(new List { @@ -91,6 +100,8 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var nm = server.NodeManager!; IOpcUaAddressSpaceSink sink = new SdkAddressSpaceSink(nm); var ts = new DateTime(2026, 8, 15, 9, 30, 0, DateTimeKind.Utc); + sink.EnsureVariable("eq-fwd/first", null, "first", "Int32", writable: false, realm: AddressSpaceRealm.Uns); + sink.EnsureVariable("eq-fwd/third", null, "third", "Int32", writable: false, realm: AddressSpaceRealm.Uns); var ex = Should.Throw(() => sink.WriteValues(new List { @@ -113,18 +124,22 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable } } - /// Verifies that WriteAlarmCondition (un-materialised fallback) creates a dedicated node - /// distinct from value writes. + /// A WriteAlarmCondition for an id no condition was materialised under is a no-op (review 01 L4): + /// it creates no placeholder node, and the value write beside it is unaffected. [Fact] - public async Task WriteAlarmCondition_creates_dedicated_node_distinct_from_value_writes() + public async Task WriteAlarmCondition_for_an_unmaterialised_id_creates_no_node() { var (host, server) = await BootAsync(); - var sink = new SdkAddressSpaceSink(server.NodeManager!); + var nm = server.NodeManager!; + var sink = new SdkAddressSpaceSink(nm); + sink.EnsureVariable("eq-1/temp", null, "temp", "Double", writable: false, realm: AddressSpaceRealm.Uns); sink.WriteAlarmCondition("alarm-7", Snapshot(active: true, acknowledged: false), DateTime.UtcNow, AddressSpaceRealm.Uns); sink.WriteValue("eq-1/temp", 22.5, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); - server.NodeManager!.VariableCount.ShouldBe(2); + nm.VariableCount.ShouldBe(1); + nm.TryGetVariable("alarm-7").ShouldBeNull(); + nm.TryGetAlarmCondition("alarm-7").ShouldBeNull(); await host.DisposeAsync(); } @@ -242,17 +257,21 @@ public sealed class SdkAddressSpaceSinkTests : IDisposable var (host, server) = await BootAsync(); var sink = new SdkAddressSpaceSink(server.NodeManager!); + sink.EnsureVariable("a", null, "a", "Int32", writable: false, realm: AddressSpaceRealm.Uns); + sink.EnsureVariable("b", null, "b", "Int32", writable: false, realm: AddressSpaceRealm.Uns); + sink.EnsureVariable("c", null, "c", "Int32", writable: false, realm: AddressSpaceRealm.Uns); sink.WriteValue("a", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); sink.WriteValue("b", 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); - sink.WriteAlarmCondition("alarm-c", Snapshot(active: true), DateTime.UtcNow, AddressSpaceRealm.Uns); server.NodeManager!.VariableCount.ShouldBe(3); sink.RebuildAddressSpace(); server.NodeManager.VariableCount.ShouldBe(0); - // After rebuild, subsequent writes start fresh. + // After rebuild, subsequent materialisation starts fresh. + sink.EnsureVariable("a", null, "a", "Int32", writable: false, realm: AddressSpaceRealm.Uns); sink.WriteValue("a", 99, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); server.NodeManager.VariableCount.ShouldBe(1); + server.NodeManager.TryGetVariable("a")!.Value.ShouldBe(99); await host.DisposeAsync(); } From eaac65286df1bdf57ae28de358c09e4f7900d1fc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 06:41:09 -0400 Subject: [PATCH 02/67] fix(opcua): tombstone MonitoredNode2 on every node removal (B2a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 01 L2. The SDK keys its fan-out lists by NodeId and its component cache hands back the CACHED NodeState for a NodeId it already holds, so a rebuild or a surgical remove+re-add left a NEW item on the same NodeId — from any session — bound to the DEAD node: created Good, fed its stale initial value, never again, until every session had deleted its items. Every removal path (RebuildAddressSpace, RemoveVariableNode, RemoveAlarmConditionNode incl. the condition's instance-scoped children, RemoveEquipmentSubtree) now queues BadNodeIdUnknown to the node's data items, drops the MonitoredNodes entry, releases each item's component cache reference and forgets the _liveItems/_eventItems entries. Items the ACL pass stood down (on no fan-out list) are swept by NodeId. A later client delete of an orphaned item is completed by the DeleteMonitoredItem override / the SubscribeToEvents unsubscribe arm instead of answering BadMonitoredItemIdInvalid or NRE-ing in MonitoredNode2.Remove. The revalidation restore re-resolves the node by NodeId and no longer re-attaches to Handle.Node blindly. Timestamps now come from the injected Clock (UtcNow). Tests: StaleMonitoredNodeWireTests (real SDK, three legs: rebuild, surgical remove+re-add, second session; each keeps the old item alive, proves the new item receives a value written after its creation, the old one gets BadNodeIdUnknown, and its delete succeeds — all three fail with the tombstone neutered), NodeManagerStaleMonitoredNodeTests (stood-down item swept on surgical remove and rebuild; sibling removal leaves it restorable). Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OtOpcUaNodeManager.cs | 250 ++++++++++++++++- .../StaleMonitoredNodeWireTests.cs | 252 ++++++++++++++++++ .../NodeManagerStaleMonitoredNodeTests.cs | 197 ++++++++++++++ 3 files changed, 694 insertions(+), 5 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index a49edfa9..774fc3f6 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -290,6 +290,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 private volatile TimeProvider _clock = TimeProvider.System; + /// The manager's "now" (UTC) — read off so a test clock drives every timestamp this + /// manager stamps (condition / shelve / revert / revalidation times), not only the HistoryUpdate bound. + private DateTime UtcNow => _clock.GetUtcNow().UtcDateTime; + private volatile IOpcUaNodeWriteGateway _nodeWriteGateway = NullOpcUaNodeWriteGateway.Instance; /// @@ -3491,6 +3495,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { lock (Lock) { + // Review 01 L2 — every monitored item on a node this rebuild discards is told and forgotten BEFORE + // the nodes go, so a later create on the same NodeId binds to the re-created node (see + // TombstoneMonitoredNode). The shared root survives the rebuild and keeps its items. + TombstoneAllMonitoredNodes(); + foreach (var v in _variables.Values) { v.Parent?.RemoveChild(v); @@ -3602,6 +3611,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node // teardown inside RebuildAddressSpace, scoped to this one id. if (!_variables.TryRemove(key, out var variable)) return false; + TombstoneMonitoredNode(variable.NodeId, UtcNow); // 2026-08-15 review I3: sever the INVERSE half of every cross-tree Organizes edge this node holds // BEFORE detaching it — the same hazard (and the same fix shape) as UnwireAlarmNotifiers on a // condition removal. AddReference wires the edge bidirectionally: forward on the UNS reference @@ -3668,6 +3678,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole // equipment goes). if (!_alarmConditions.TryRemove(key, out var condition)) return false; + TombstoneMonitoredSubtree(condition, UtcNow); // unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE // the condition drops — the folders (equipment, UNS realm) SURVIVE this scoped remove, so without // this they would keep a dangling inverse-notifier entry to the removed condition. @@ -3704,11 +3715,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 bool InScope(RealmKey key) => key.Realm == realm && (key.Id == equipmentNodeId || key.Id.StartsWith(prefix, StringComparison.Ordinal)); + var now = UtcNow; // Value variables (+ their historized-tagname registrations). foreach (var id in _variables.Keys.Where(InScope).ToList()) { if (_variables.TryRemove(id, out var v)) { + TombstoneMonitoredNode(v.NodeId, now); // Sever the inverse half of each cross-tree Organizes edge // first, exactly as RemoveVariableNode does — a UNS reference variable in scope points at a // RAW node that survives this UNS-scoped removal and would otherwise keep a dangling edge. @@ -3725,6 +3738,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (_alarmConditions.TryRemove(id, out var c)) { + TombstoneMonitoredSubtree(c, now); c.Parent?.RemoveChild(c); ForgetPredefinedSubtree(c); } @@ -3765,6 +3779,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (_folders.TryRemove(id, out var f)) { + TombstoneMonitoredNode(f.NodeId, now); f.Parent?.RemoveChild(f); PredefinedNodes?.Remove(f.NodeId); } @@ -4080,8 +4095,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (unsubscribe) { - // An item RevalidateMonitoredItems stood down is ALREADY off its notifier's fan-out. - // Handing its delete to the SDK would make it remove an item that is not there — at best a + // An item RevalidateMonitoredItems stood down is ALREADY off its notifier's fan-out, and so is + // one whose notifier a rebuild / surgical remove tombstoned (review 01 L2, TombstoneMonitoredNode). + // Handing either delete to the SDK would make it remove an item that is not there — at best a // spurious BadNodeIdUnknown on the client's delete, at worst a NullReferenceException inside // MonitoredNode2.Remove when the node's event list has gone null. Checked and dropped under the // same Lock the revalidation pass re-attaches under, so the two cannot interleave into @@ -4089,7 +4105,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 lock (Lock) { if (_eventItems.TryRemove(monitoredItem, out var stoodDown) && stoodDown.Suppressed) + { + Interlocked.Decrement(ref _suppressedItems); return ServiceResult.Good; + } + + if (IsHandleInNamespace(sourceId) is { } h + && (!MonitoredNodes.TryGetValue(h.NodeId, out var current) + || current.EventMonitoredItems?.Contains(monitoredItem) != true)) + { + return ServiceResult.Good; + } } return base.SubscribeToEvents(context, sourceId, subscriptionId, monitoredItem, unsubscribe); @@ -4175,10 +4201,186 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 protected override void OnMonitoredItemDeleted( ServerSystemContext context, NodeHandle handle, ISampledDataChangeMonitoredItem monitoredItem) { - _liveItems.TryRemove(monitoredItem, out _); + if (_liveItems.TryRemove(monitoredItem, out var tracked) && tracked.Suppressed) Interlocked.Decrement(ref _suppressedItems); base.OnMonitoredItemDeleted(context, handle, monitoredItem); } + // Review 01 L2 — a client deleting an item whose node this manager removed from under it (a rebuild or + // a surgical remove tombstoned the node's MonitoredNode2, see TombstoneMonitoredNode) must succeed. The + // SDK's own delete looks the node up by NodeId: with the entry gone it answers BadMonitoredItemIdInvalid, + // and with a FRESH entry (the same NodeId re-created and re-subscribed) it would try to remove the item + // from a fan-out list it is not on — a NullReferenceException when that list has gone null. An item that + // is not on the current fan-out for its NodeId has nothing left to detach, so its delete is completed here. + /// + protected override ServiceResult DeleteMonitoredItem( + ServerSystemContext context, IMonitoredItem monitoredItem, NodeHandle handle) + { + lock (Lock) + { + if (monitoredItem is ISampledDataChangeMonitoredItem dataItem + && (!MonitoredNodes.TryGetValue(handle.NodeId, out var current) + || current.DataChangeMonitoredItems?.Contains(dataItem) != true)) + { + OnMonitoredItemDeleted(context, handle, dataItem); + return ServiceResult.Good; + } + } + + return base.DeleteMonitoredItem(context, monitoredItem, handle); + } + + /// How many items the revalidation pass currently holds detached (stood down). Lets the + /// tombstone sweep for stood-down items — which are not on any fan-out list — run only when there are any. + private int _suppressedItems; + + /// + /// Review 01 L2 — forget the of a node this manager is removing, telling + /// every data-change item on it BadNodeIdUnknown first. MUST be called under Lock, BEFORE + /// the node is detached. + /// + /// + /// + /// The SDK keys its fan-out lists by NodeId and never clears them on the node manager's behalf + /// (DeleteMonitoredItem drops an entry only when its item count reaches zero), and its + /// component cache (AddNodeToComponentCache) hands back the CACHED + /// for a NodeId it already holds. So after a rebuild or a surgical remove+re-add, a NEW item on the + /// same NodeId — from any session — was bound to the DEAD node: created Good, never fed. Only + /// once every session had deleted its items did a create bind to the live node. + /// + /// + /// The fix removes the entry and releases each item's component-cache reference (the refcount the + /// SDK took at create), so the next create binds to the re-created node. The items themselves stay in + /// their client's subscription — a node manager cannot remove them — told BadNodeIdUnknown + /// (data items; an event item has no value slot) and dropped from the revalidation maps; their later + /// client delete is completed by / the SubscribeToEvents + /// unsubscribe arm. Queueing under Lock follows the publish path's own order (Lock, then + /// the item lock) — the inversion the revalidation pass avoids is the other way round. + /// + /// + /// An item the revalidation pass has STOOD DOWN is on no fan-out list, so it is swept from the + /// tracking map by NodeId instead — it still holds a component-cache reference that would otherwise + /// keep the dead node cached. + /// + /// + /// The NodeId being removed. + /// The timestamp the terminal BadNodeIdUnknown carries. + private void TombstoneMonitoredNode(NodeId nodeId, DateTime now) + { + if (MonitoredNodes.TryGetValue(nodeId, out var monitored)) + { + MonitoredNodes.Remove(nodeId); + if (monitored.DataChangeMonitoredItems is { Count: > 0 } dataItems) + { + foreach (var item in dataItems.ToArray()) + { + TellNodeRemoved(item, now); + if (item is ISampledDataChangeMonitoredItem sampled) _liveItems.TryRemove(sampled, out _); + ReleaseComponentCacheReference(item); + } + } + + if (monitored.EventMonitoredItems is { Count: > 0 } eventItems) + { + foreach (var item in eventItems.ToArray()) + { + _eventItems.TryRemove(item, out _); + ReleaseComponentCacheReference(item); + } + } + } + + if (Volatile.Read(ref _suppressedItems) > 0) SweepStoodDownItems(nodeId, now); + } + + /// Tombstones and every descendant (a condition's instance-scoped + /// children are monitorable in their own right). MUST be called under Lock. + private void TombstoneMonitoredSubtree(NodeState node, DateTime now) + { + TombstoneMonitoredNode(node.NodeId, now); + var children = new List(); + node.GetChildren(SystemContext, children); + foreach (var child in children) TombstoneMonitoredSubtree(child, now); + } + + /// The rebuild form: every monitored node this manager serves except the shared root, which the + /// rebuild keeps. MUST be called under Lock. + private void TombstoneAllMonitoredNodes() + { + var now = UtcNow; + var rootId = _root?.NodeId; + foreach (var nodeId in MonitoredNodes.Keys.ToList()) + { + if (rootId is not null && nodeId == rootId) continue; + TombstoneMonitoredNode(nodeId, now); + } + + if (Volatile.Read(ref _suppressedItems) > 0) SweepStoodDownItems(nodeId: null, now); + } + + /// Drops the stood-down items of (every stood-down item when null — + /// the rebuild case) from the revalidation maps, telling the data items BadNodeIdUnknown. + private void SweepStoodDownItems(NodeId? nodeId, DateTime now) + { + foreach (var (item, tracked) in _liveItems) + { + if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId)) continue; + if (!_liveItems.TryRemove(item, out _)) continue; + Interlocked.Decrement(ref _suppressedItems); + TellNodeRemoved(item, now); + ReleaseComponentCacheReference(item); + } + + foreach (var (item, tracked) in _eventItems) + { + if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId)) continue; + if (!_eventItems.TryRemove(item, out _)) continue; + Interlocked.Decrement(ref _suppressedItems); + ReleaseComponentCacheReference(item); + } + } + + /// Queues the terminal BadNodeIdUnknown on a data-change item whose node is going away. + private void TellNodeRemoved(IDataChangeMonitoredItem2 item, DateTime now) + { + try + { + item.QueueValue( + new DataValue(StatusCodes.BadNodeIdUnknown) { SourceTimestamp = now, ServerTimestamp = now }, + new ServiceResult(StatusCodes.BadNodeIdUnknown), + ignoreFilters: true); + } + catch (Exception ex) + { + // A disabled item refuses the value; nothing else here is worth failing a removal for. + _logger.LogDebug(ex, "OtOpcUaNodeManager: could not queue the removal status to a monitored item"); + } + } + + /// Gives back the component-cache reference the SDK took for at create, so + /// the cache stops handing out the removed node for that NodeId. + private void ReleaseComponentCacheReference(IMonitoredItem item) + { + if (item.ManagerHandle is NodeHandle handle) RemoveNodeFromComponentCache(SystemContext, handle); + } + + /// Test observability: how many NodeIds currently carry a fan-out. + internal int MonitoredNodeCount + { + get { lock (Lock) return MonitoredNodes.Count; } + } + + /// Test observability: whether currently carries a fan-out. + internal bool IsNodeMonitored(NodeId nodeId) + { + lock (Lock) return MonitoredNodes.ContainsKey(nodeId); + } + + /// Test observability: how many data-change items the revalidation map tracks. + internal int LiveItemCount => _liveItems.Count; + + /// Test observability: how many direct event items the revalidation map tracks. + internal int LiveEventItemCount => _eventItems.Count; + /// /// Live data-change monitored items on nodes this manager owns, so a grant revoked by a deployment /// can reach the subscriptions it was authorizing. @@ -4817,7 +5019,26 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (!tracked.Suppressed) continue; - lock (Lock) tracked.Handle.MonitoredNode?.Add(item); + // Re-resolve the node by NodeId rather than trusting the handle (review 01 L2): the fan-out + // the item came off belongs to the NodeState that existed when it was stood down, and a + // rebuild or surgical remove in between has tombstoned that node — then the item was already + // dropped from this map, so a mismatch here is a race with one; tell the client and let go. + bool reattached; + lock (Lock) + { + reattached = ResolveNodeState(MapKey(nodeId)) is { } liveNode + && ReferenceEquals(liveNode, tracked.Handle.Node); + if (reattached) tracked.Handle.MonitoredNode?.Add(item); + else _liveItems.TryRemove(item, out _); + } + + Interlocked.Decrement(ref _suppressedItems); + if (!reattached) + { + TellNodeRemoved(item, now); + continue; + } + tracked.Suppressed = false; restored++; continue; @@ -4832,8 +5053,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 new ServiceResult(StatusCodes.BadUserAccessDenied), ignoreFilters: true); - lock (Lock) tracked.Handle.MonitoredNode?.Remove(item); + lock (Lock) + { + // The fan-out list may have gone null if every other item on the node was deleted meanwhile. + if (tracked.Handle.MonitoredNode is { DataChangeMonitoredItems: not null } fanOut) fanOut.Remove(item); + } tracked.Suppressed = true; + Interlocked.Increment(ref _suppressedItems); stoodDown++; } @@ -4905,8 +5131,22 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (!_eventItems.TryGetValue(item, out var current) || !ReferenceEquals(current, tracked)) continue; + // Re-resolve the notifier by NodeId (review 01 L2): re-attaching to the handle's NodeState + // after a rebuild would bind the item to a dead node. A notifier that is gone, or has been + // re-created, cannot be the one this item was stood down from — drop the item instead. + if (permitted + && (ResolveNodeState(MapKey(tracked.Handle.NodeId)) is not { } live || !ReferenceEquals(live, tracked.Handle.Node))) + { + _eventItems.TryRemove(item, out _); + Interlocked.Decrement(ref _suppressedItems); + ReleaseComponentCacheReference(item); + continue; + } + SubscribeToEvents(SystemContext, tracked.Handle.Node, item, unsubscribe: !permitted); tracked.Suppressed = !permitted; + if (permitted) Interlocked.Decrement(ref _suppressedItems); + else Interlocked.Increment(ref _suppressedItems); } if (permitted) restored++; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs new file mode 100644 index 00000000..6cacc60e --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs @@ -0,0 +1,252 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; + +/// +/// Review 01 L2, over the wire against the real SDK: a rebuild (or a surgical remove + re-add) that +/// recreates a NodeId must not leave the SDK's per-NodeId MonitoredNode2 — and its component-cache +/// entry — pointing at the DEAD NodeState. Before the fix, ANY new item on that NodeId, from any +/// session, bound to the dead node while an older item on it still existed: created Good, fed its +/// stale initial value, then never again. +/// +/// Each test keeps the OLD item alive while creating the NEW one — that is exactly the trap — and +/// proves the positive path (the new item receives a value written AFTER its creation) before asserting +/// the old item's BadNodeIdUnknown. Deleting the old item afterwards must succeed too. +/// +/// +public sealed class StaleMonitoredNodeWireTests +{ + private const string ServerUri = "urn:OtOpcUa.StaleMonitoredNode"; + private const string Device = "Plant/Modbus/dev1"; + private const string Tag = "Plant/Modbus/dev1/Speed"; + + /// Rebuild + re-create the same NodeId: the old item is told, a new item on the same NodeId receives values. + [Fact] + public async Task A_new_item_after_a_rebuild_binds_to_the_recreated_node() + { + await RunAsync("rebuild", nm => + { + nm.RebuildAddressSpace(); + nm.CompleteAddressSpaceApply(); + Materialise(nm); + }); + } + + /// Surgical remove + re-add of the same NodeId: same contract as the rebuild. + [Fact] + public async Task A_new_item_after_a_surgical_remove_and_readd_binds_to_the_recreated_node() + { + await RunAsync("surgical", nm => + { + nm.RemoveVariableNode(Tag, AddressSpaceRealm.Raw).ShouldBeTrue(); + nm.EnsureVariable(Tag, Device, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + }); + } + + /// + /// The same trap from a SECOND session: a UaExpert operator connecting after a deploy while ScadaBridge's + /// original item is still alive. + /// + [Fact] + public async Task A_second_session_binds_to_the_recreated_node_while_the_first_sessions_item_lives() + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-2s-{Guid.NewGuid():N}"); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host, port) = await BootServerAsync(pkiRoot, ServerUri + ".TwoSessions", ct); + await using var _ = host; + var nm = server.NodeManager!; + Materialise(nm); + nm.WriteValue(Tag, 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + + using var sessionA = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var nodeId = new NodeId(Tag, (ushort)sessionA.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); + var (subA, oldItem, oldValues, gate) = await SubscribeAsync(sessionA, nodeId, ct); + await WaitUntilAsync(() => { lock (gate) return oldValues.Count >= 1; }, TimeSpan.FromSeconds(5)); + + nm.RebuildAddressSpace(); + nm.CompleteAddressSpaceApply(); + Materialise(nm); + nm.WriteValue(Tag, 2, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + + // Session B arrives AFTER the rebuild, while A's item is still in A's subscription. + using var sessionB = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var (subB, newItem, newValues, gateB) = await SubscribeAsync(sessionB, nodeId, ct); + await WaitUntilAsync(() => { lock (gateB) return newValues.Count >= 1; }, TimeSpan.FromSeconds(5)); + + nm.WriteValue(Tag, 3, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + await WaitUntilAsync(() => { lock (gateB) return newValues.Any(v => Equals(v.Value, 3)); }, TimeSpan.FromSeconds(5), + "the second session's item must receive a value written after its creation"); + + lock (gate) oldValues.ShouldContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown, "the first session's item was told its node went away"); + lock (gateB) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); + + subA.RemoveItem(oldItem); + await subA.ApplyChangesAsync(ct); + subB.RemoveItem(newItem); + await subB.ApplyChangesAsync(ct); + } + finally + { + SafeDelete(pkiRoot); + } + } + + private static async Task RunAsync(string name, Action recreate) + { + var pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-{name}-{Guid.NewGuid():N}"); + var ct = TestContext.Current.CancellationToken; + try + { + var (server, host, port) = await BootServerAsync(pkiRoot, $"{ServerUri}.{name}", ct); + await using var _ = host; + var nm = server.NodeManager!; + Materialise(nm); + nm.WriteValue(Tag, 41, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + + using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); + var nodeId = new NodeId(Tag, (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri)); + var (subscription, oldItem, oldValues, gate) = await SubscribeAsync(session, nodeId, ct); + await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => Equals(v.Value, 41)); }, TimeSpan.FromSeconds(5)); + nm.IsNodeMonitored(nodeId).ShouldBeTrue("non-vacuity: the SDK really holds a fan-out for the node"); + + // The node goes away and comes back under the same NodeId; a fresh value lands on the NEW node. + recreate(nm); + nm.WriteValue(Tag, 42, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + + // A NEW item on the same NodeId, in the same subscription, with the old one still present. + var newValues = new List(); + var newItem = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10, + }; + newItem.Notification += (item, _) => { foreach (var v in item.DequeueValues()) lock (gate) newValues.Add(v); }; + subscription.AddItem(newItem); + await subscription.ApplyChangesAsync(ct); + newItem.Status.Error.ShouldBeNull(); + + // Positive control: the initial value is the NEW node's, and a value written afterwards arrives. + await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5), + "the new item's initial read must come from the re-created node"); + nm.WriteValue(Tag, 43, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 43)); }, TimeSpan.FromSeconds(5), + "the new item must receive a value written after its creation"); + lock (gate) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); + + // The old item was told, and the dead node's fan-out is gone. + await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); }, TimeSpan.FromSeconds(5), + "the item on the removed node must be told BadNodeIdUnknown"); + nm.IsNodeMonitored(nodeId).ShouldBeTrue("the NEW item's fan-out exists for the re-created node"); + + // The old item's delete completes — its node is gone, not its right to be deleted. + subscription.RemoveItem(oldItem); + await subscription.ApplyChangesAsync(ct); + (oldItem.Status.Error is null || ServiceResult.IsGood(oldItem.Status.Error)).ShouldBeTrue( + $"deleting the orphaned item must succeed, got {oldItem.Status.Error}"); + + // And the new item is unaffected by that delete. + nm.WriteValue(Tag, 44, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 44)); }, TimeSpan.FromSeconds(5)); + } + finally + { + SafeDelete(pkiRoot); + } + } + + private static void Materialise(OtOpcUaNodeManager nm) + { + nm.EnsureFolder(Device, null, "dev1", AddressSpaceRealm.Raw); + nm.EnsureVariable(Tag, Device, "Speed", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + } + + private static async Task<(Subscription Subscription, MonitoredItem Item, List Values, object Gate)> SubscribeAsync( + ISession session, NodeId nodeId, CancellationToken ct) + { + var values = new List(); + var gate = new object(); + var subscription = new Subscription(session.DefaultSubscription) { PublishingInterval = 100 }; + session.AddSubscription(subscription); + await subscription.CreateAsync(ct); + var item = new MonitoredItem(subscription.DefaultItem) + { + StartNodeId = nodeId, AttributeId = Attributes.Value, SamplingInterval = 50, QueueSize = 10, + }; + item.Notification += (i, _) => { foreach (var v in i.DequeueValues()) lock (gate) values.Add(v); }; + subscription.AddItem(item); + await subscription.ApplyChangesAsync(ct); + return (subscription, item, values, gate); + } + + private static async Task<(OtOpcUaSdkServer Server, OpcUaApplicationHost Host, int Port)> BootServerAsync( + string pkiRoot, string appUri, CancellationToken ct) + { + var boundPort = 0; + var started = await OpcUaHostStart.StartAsync( + port => + { + boundPort = port; + return new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = appUri, + ApplicationUri = appUri, + OpcUaPort = port, + PublicHostname = "127.0.0.1", + PkiStoreRoot = pkiRoot, + EnabledSecurityProfiles = new List { OpcUaSecurityProfile.None }, + AutoAcceptUntrustedClientCertificates = true, + }, + NullLogger.Instance); + }, + () => new OtOpcUaSdkServer(), ct); + return (started.Server, started.Host, boundPort); + } + + private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) + { + var appConfig = new ApplicationConfiguration + { + ApplicationName = "OtOpcUa.StaleMonitoredNodeClient", + ApplicationUri = $"urn:OtOpcUa.StaleMonitoredNodeClient.{Guid.NewGuid():N}", + ApplicationType = ApplicationType.Client, + SecurityConfiguration = TestClientSecurity.Build(TestClientSecurity.AllocatePkiRoot()), + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, + }; + await appConfig.ValidateAsync(ApplicationType.Client, ct); + appConfig.CertificateValidator.CertificateValidation += (_, e) => e.Accept = true; + + var endpoint = await CoreClientUtils.SelectEndpointAsync( + appConfig, endpointUrl, false, DefaultTelemetry.Create(_ => { }), ct); + var configuredEndpoint = new ConfiguredEndpoint(null, endpoint, EndpointConfiguration.Create(appConfig)); + + return await new DefaultSessionFactory(DefaultTelemetry.Create(_ => { })).CreateAsync( + appConfig, configuredEndpoint, updateBeforeConnect: false, checkDomain: false, + sessionName: "StaleMonitoredNodeWireTests", sessionTimeout: 60_000, + identity: new UserIdentity(new AnonymousIdentityToken()), preferredLocales: null, ct: ct); + } + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout, string? because = null) + { + var deadline = DateTime.UtcNow + timeout; + while (DateTime.UtcNow < deadline) + { + if (condition()) return; + await Task.Delay(50); + } + condition().ShouldBeTrue(because ?? "condition not met within timeout"); + } + + private static void SafeDelete(string dir) + { + if (!Directory.Exists(dir)) return; + try { Directory.Delete(dir, recursive: true); } + catch { /* best-effort */ } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs new file mode 100644 index 00000000..fcd966e5 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs @@ -0,0 +1,197 @@ +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Server; +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.Authorization; +using ZB.MOM.WW.OtOpcUa.OpcUaServer.Security; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Review 01 L2, the half the wire tests (StaleMonitoredNodeWireTests) cannot reach: an item the +/// ACL revalidation pass has STOOD DOWN is on no SDK fan-out list, so the tombstone a removal runs must find +/// it through the revalidation map. Otherwise it keeps a component-cache reference to the dead node — and +/// the cache hands that node to the next item created on the NodeId. +/// +public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable +{ + private const string Cluster = "SITE-A"; + private const string Principal = "svc"; + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-stale-{Guid.NewGuid():N}"); + + /// A surgical remove tells a stood-down item and drops it from the revalidation map. + [Fact] + public async Task A_surgical_remove_tells_and_forgets_a_stood_down_item() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var (item, nodeId) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed"); + + nm.RemoveVariableNode("Plant/Modbus/dev1/Speed", AddressSpaceRealm.Raw).ShouldBeTrue(); + + item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown); + nm.LiveItemCount.ShouldBe(0, "the item's node is gone; the map must not keep re-authorizing it"); + nm.IsNodeMonitored(nodeId).ShouldBeFalse(); + } + finally { await host.DisposeAsync(); } + } + + /// A rebuild sweeps every stood-down item. + [Fact] + public async Task A_rebuild_tells_and_forgets_every_stood_down_item() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var (item, _) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed"); + + nm.RebuildAddressSpace(); + nm.CompleteAddressSpaceApply(); + + item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown); + nm.LiveItemCount.ShouldBe(0); + } + finally { await host.DisposeAsync(); } + } + + /// + /// Positive control for the sweep: a stood-down item on a SURVIVING node is left alone by a surgical + /// remove of a sibling, and the next permitted pass restores it. + /// + [Fact] + public async Task A_stood_down_item_on_a_surviving_node_is_untouched_and_still_restorable() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureVariable("Plant/Modbus/dev1/Other", null, "Other", "Float", writable: false, realm: AddressSpaceRealm.Raw); + var (item, _) = await StandDownAsync(nm, "Plant/Modbus/dev1/Speed"); + + nm.RemoveVariableNode("Plant/Modbus/dev1/Other", AddressSpaceRealm.Raw).ShouldBeTrue(); + + item.Statuses.ShouldNotContain((StatusCode)StatusCodes.BadNodeIdUnknown); + nm.LiveItemCount.ShouldBe(1); + + // Grant restored ⇒ the pass re-attaches it (a second stand-down proves the restore happened). + nm.Authorizer = Authorizer(permits: true); + nm.RevalidateMonitoredItems(); + nm.Authorizer = Authorizer(permits: false); + nm.RevalidateMonitoredItems(); + item.Statuses.Count(s => s == StatusCodes.BadUserAccessDenied).ShouldBe(2); + } + finally { await host.DisposeAsync(); } + } + + /// Registers a stub item on and stands it down through a denying pass. + private static async Task<(StubItem Item, NodeId NodeId)> StandDownAsync(OtOpcUaNodeManager nm, string rawPath) + { + nm.EnsureVariable(rawPath, null, rawPath, "Float", writable: false, realm: AddressSpaceRealm.Raw); + var nodeId = nm.TryGetVariable(rawPath, AddressSpaceRealm.Raw)!.NodeId; + var handle = (NodeHandle)nm.GetManagerHandle(nodeId); + var item = new StubItem(handle, Identity()); + nm.TrackLiveMonitoredItem(handle, item); + + nm.Authorizer = Authorizer(permits: false); + nm.RevalidateMonitoredItems(); + item.Statuses.ShouldContain((StatusCode)StatusCodes.BadUserAccessDenied, "non-vacuity: the item really was stood down"); + await Task.CompletedTask; + return (item, nodeId); + } + + private static DataPlaneAuthorizer Authorizer(bool permits) + { + var state = new DataPlaneAuthorityState(); + var grants = permits + ? new[] + { + new NodeAcl + { + NodeAclId = "acl-cluster", ClusterId = Cluster, Principal = Principal, + ScopeKind = NodeAclScopeKind.Cluster, PermissionFlags = NodePermissions.Subscribe, + }, + } + : []; + state.Apply(Cluster, "rev-1", [], grants, new Dictionary>()); + return new DataPlaneAuthorizer(state, new TriePermissionEvaluator(state.Tries), enabled: true, NullLogger.Instance); + } + + private static RoleCarryingUserIdentity Identity() => + new(new UserNameIdentityToken { UserName = "apikey" }, [], Principal, apiKeyId: null); + + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() => + await OpcUaHostStart.StartAsync( + port => new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.StaleTest", + ApplicationUri = $"urn:OtOpcUa.StaleTest:{Guid.NewGuid():N}", + OpcUaPort = port, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } + + /// The minimum of a sampled item the removal path touches: its handle, identity and value queue. + private sealed class StubItem(NodeHandle handle, IUserIdentity identity) : ISampledDataChangeMonitoredItem + { + public List Statuses { get; } = []; + public IUserIdentity EffectiveIdentity => identity; + public object ManagerHandle => handle; + public NodeId NodeId => handle.NodeId; + public void QueueValue(DataValue value, ServiceResult error, bool ignoreFilters) => Statuses.Add(error?.StatusCode ?? value.StatusCode); + public void QueueValue(DataValue value, ServiceResult error) => QueueValue(value, error, false); + public void Dispose() { } + + public INodeManager NodeManager => throw new NotSupportedException(); + public ISession Session => throw new NotSupportedException(); + public uint Id => throw new NotSupportedException(); + public uint SubscriptionId => throw new NotSupportedException(); + public bool IsDurable => throw new NotSupportedException(); + public uint ClientHandle => throw new NotSupportedException(); + public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public int MonitoredItemType => throw new NotSupportedException(); + public bool IsReadyToPublish => throw new NotSupportedException(); + public bool IsReadyToTrigger { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public bool IsResendData => throw new NotSupportedException(); + public MonitoringMode MonitoringMode => throw new NotSupportedException(); + public double SamplingInterval => throw new NotSupportedException(); + public DataChangeFilter DataChangeFilter => throw new NotSupportedException(); + public uint AttributeId => throw new NotSupportedException(); + public NumericRange IndexRange => throw new NotSupportedException(); + public QualifiedName DataEncoding => throw new NotSupportedException(); + public bool AlwaysReportUpdates { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public DiagnosticsMasks DiagnosticsMasks => throw new NotSupportedException(); + public uint QueueSize => throw new NotSupportedException(); + public double MinimumSamplingInterval => throw new NotSupportedException(); + public int TimeToNextSample => throw new NotSupportedException(); + public void SetupResendDataTrigger() => throw new NotSupportedException(); + public ServiceResult GetCreateResult(out MonitoredItemCreateResult result) => throw new NotSupportedException(); + public ServiceResult GetModifyResult(out MonitoredItemModifyResult result) => throw new NotSupportedException(); + public IStoredMonitoredItem ToStorableMonitoredItem() => throw new NotSupportedException(); + public MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode) => throw new NotSupportedException(); + public bool Publish(OperationContext context, Queue notifications, Queue diagnostics, uint maxNotificationsPerPublish, ILogger logger) => throw new NotSupportedException(); + public void SetSemanticsChanged() => throw new NotSupportedException(); + public void SetStructureChanged() => throw new NotSupportedException(); + public bool SamplingIntervalExpired() => throw new NotSupportedException(); + public ReadValueId GetReadValueId() => throw new NotSupportedException(); + public ServiceResult ModifyAttributes(DiagnosticsMasks diagnosticsMasks, TimestampsToReturn timestampsToReturn, uint clientHandle, MonitoringFilter originalFilter, MonitoringFilter filterToUse, Opc.Ua.Range range, double samplingInterval, uint queueSize, bool discardOldest) => throw new NotSupportedException(); + public void SetSamplingInterval(double samplingInterval) => throw new NotSupportedException(); + } +} From dca822dc59b37ca2f784546d41ae7546108f5587 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:17:49 -0400 Subject: [PATCH 03/67] perf(opcua): de-quadratic the lock-time teardown paths (B3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 01 P1/P2/P3/P5 — work done under the manager-wide SDK Lock on the pinned publish thread, where every Read/Browse/Publish waits. - P1 RebuildAddressSpace detaches the shared root's top-level children once instead of every leaf from its parent (NodeState.RemoveChild is a linear scan + RemoveAt, ~k²/2 compares per k-child folder); root notifiers are cleared as a list instead of one O(F) RemoveRootNotifier each; ForgetPredefinedSubtree / TombstoneMonitoredSubtree are iterative with one child buffer. - P2 RemoveEquipmentSubtree walks a parent→children index maintained by EnsureFolder/EnsureVariable/MaterialiseAlarmCondition (keyed by the parent id the caller named) instead of prefix-scanning six maps per removed equipment; a surviving condition's wiring into a removed folder is found through the folder's own notifier list; descendants are not detached from a folder that is itself being discarded. - P3 LowerAllEventsMonitoring(condition) replaces the full O(C) reconcile on RemoveAlarmConditionNode / RemoveEquipmentSubtree / the kind-swap. - P5 AlarmNotifierWiring.Folders is a reference-identity HashSet and an already-wired pair skips the SDK's linear AddNotifier dedup. Benchmark (NodeManagerLockTimeBenchmarkTests, M-series Mac under load, three runs each): rebuild of a 10k-tag folder 75–150 ms → 5–6 ms; removing 200 equipment (250 signals each) from a 50k-node space 471–681 ms → 58–64 ms; all-events bookkeeping visits for 10 condition removals over 50 conditions 900 → 10. The two timing legs assert < 1 s; the P3 leg asserts the visit count (machine-independent). Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OtOpcUaNodeManager.cs | 331 +++++++++++------- .../NodeManagerLockTimeBenchmarkTests.cs | 190 ++++++++++ 2 files changed, 397 insertions(+), 124 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 774fc3f6..9a2fe15f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -111,6 +111,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// against it (a miss ⇒ BadHistoryOperationUnsupported). Cleared on /// . private readonly ConcurrentDictionary _eventNotifierSources = new(); + /// Review 01 P2 — parent key → the keys of the folders / variables / conditions materialised + /// under it (by the parent id the CALLER named, so a node whose parent was unknown at creation and hangs + /// under the shared root is still found under its intended parent). + /// walks this instead of prefix-scanning every map per removed equipment. Guarded by Lock. + private readonly Dictionary> _childIndex = new(); + /// The reverse of : child key → parent key. Guarded by Lock. + private readonly Dictionary _parentIndex = new(); private FolderState? _root; private readonly ILogger _logger; @@ -1400,8 +1407,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 ForgetPredefinedSubtree(existing); // 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); + LowerAllEventsMonitoring(existing); } // H6a: re-materialising the same id as the OTHER kind (native↔scripted) must reflect the new @@ -1579,6 +1585,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 AddPredefinedNode(SystemContext, alarm); _alarmConditions[conditionKey] = alarm; + if (!string.IsNullOrEmpty(equipmentNodeId)) IndexChild(MapKey(realm, equipmentNodeId), conditionKey); // 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. @@ -1690,11 +1697,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } /// One materialised native-alarm condition together with the equipment folders wired as extra - /// event-notifier roots for it. The list is mutated in place under Lock as - /// notifiers are wired / torn down; is the concrete - /// instance the notifiers were wired against (a rebuild recreates the instance, so the entry is reset when - /// the instance changes). - private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, List Folders); + /// event-notifier roots for it. The set (reference identity — review 01 P5: the + /// membership probe per wired folder was a linear ReferenceEquals walk) is mutated in place under + /// Lock as notifiers are wired / torn down; is the concrete + /// instance the notifiers were wired against (a rebuild recreates the + /// instance, so the entry is reset when the instance changes). + private sealed record AlarmNotifierWiring(AlarmConditionState Alarm, HashSet Folders); // This is a RECONCILE, not additive-only: it wires the supplied set AND unwires any // previously-tracked folder whose id is no longer in notifierFolderNodeIds @@ -1735,7 +1743,8 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // the entry (the old folder list referenced the discarded instance) when the instance differs. if (!_alarmNotifierWiring.TryGetValue(alarmKey, out var wiring) || !ReferenceEquals(wiring.Alarm, alarm)) { - wiring = new AlarmNotifierWiring(alarm, new List()); + wiring = new AlarmNotifierWiring( + alarm, new HashSet(System.Collections.Generic.ReferenceEqualityComparer.Instance)); _alarmNotifierWiring[alarmKey] = wiring; } @@ -1772,13 +1781,13 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // alarm.AddNotifier(isInverse:true, folder) — the upward bubble to the equipment folder // folder.AddNotifier(isInverse:false, alarm) — the downward AreEventsMonitored link // EnsureFolderIsEventNotifier(folder) — SubscribeToEvents + AddRootNotifier (idempotent) - // AddNotifier dedups by ReferenceEquals(Node), so re-wiring the same pair (idempotent re-apply) - // updates rather than duplicates. + // A pair this wiring already holds is skipped outright (review 01 P5): the SDK's AddNotifier + // dedups by a linear ReferenceEquals walk of the condition's notifier list, so re-wiring K + // folders on an idempotent re-apply cost O(K²) for nothing. + if (!wiring.Folders.Add(folder)) continue; alarm.AddNotifier(SystemContext, null, isInverse: true, folder); folder.AddNotifier(SystemContext, null, isInverse: false, alarm); EnsureFolderIsEventNotifier(folder); - - if (!wiring.Folders.Any(f => ReferenceEquals(f, folder))) wiring.Folders.Add(folder); } } } @@ -2826,10 +2835,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// The node whose subtree is forgotten. private void ForgetPredefinedSubtree(NodeState node) { - PredefinedNodes?.Remove(node.NodeId); + // Iterative with one reused child buffer — the recursive form allocated a list per node (review 01 P1). + var pending = new Stack(); + pending.Push(node); var children = new List(); - node.GetChildren(SystemContext, children); - foreach (var child in children) ForgetPredefinedSubtree(child); + while (pending.Count > 0) + { + var current = pending.Pop(); + PredefinedNodes?.Remove(current.NodeId); + children.Clear(); + current.GetChildren(SystemContext, children); + foreach (var child in children) pending.Push(child); + } } /// Promote to and @@ -2922,9 +2939,33 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 parent.AddChild(folder); AddPredefinedNode(SystemContext, folder); _folders[key] = folder; + if (!string.IsNullOrEmpty(parentNodeId)) IndexChild(MapKey(realm, parentNodeId), key); } } + /// Records under in the P2 subtree index. MUST be + /// called under Lock. Idempotent for the same pair; a child re-indexed under another parent moves. + private void IndexChild(RealmKey parent, RealmKey child) + { + if (_parentIndex.TryGetValue(child, out var previous)) + { + if (previous == parent) return; + UnindexChild(child); + } + + if (!_childIndex.TryGetValue(parent, out var children)) _childIndex[parent] = children = new HashSet(); + children.Add(child); + _parentIndex[child] = parent; + } + + /// Drops from the P2 subtree index. MUST be called under Lock. + private void UnindexChild(RealmKey child) + { + if (!_parentIndex.Remove(child, out var parent)) return; + if (_childIndex.TryGetValue(parent, out var children) && children.Remove(child) && children.Count == 0) + _childIndex.Remove(parent); + } + /// /// Update an EXISTING folder node's in place and notify /// subscribers, WITHOUT a rebuild — the in-place counterpart of for a @@ -3036,6 +3077,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 parent.AddChild(variable); AddPredefinedNode(SystemContext, variable); _variables[key] = variable; + if (!string.IsNullOrEmpty(parentFolderNodeId)) IndexChild(MapKey(realm, parentFolderNodeId), key); // Phase C: register the resolved historian tagname so the HistoryRead override can map this // NodeId back to its Aveva/historian source. A historized UNS reference node passes the SAME // tagname as its backing raw node — each NodeId keys its own entry, both mapping to one tagname. @@ -3500,16 +3542,6 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // TombstoneMonitoredNode). The shared root survives the rebuild and keeps its items. TombstoneAllMonitoredNodes(); - foreach (var v in _variables.Values) - { - v.Parent?.RemoveChild(v); - PredefinedNodes?.Remove(v.NodeId); - } - _variables.Clear(); - // Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map. - _historizedTagnames.Clear(); - _arrayOnScalarRefused.Clear(); - // Capture every NATIVE condition's live state before the conditions are discarded, so the // re-materialise that follows in this same apply can put it back (see CaptureCarryOver). Scripted // conditions are not captured — their engine re-asserts them after the reload. @@ -3520,38 +3552,56 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (_nativeAlarmNodeIds.Contains(key)) _rebuildCarryOver[key] = CaptureCarryOver(alarm); } - foreach (var alarm in _alarmConditions.Values) - { - alarm.Parent?.RemoveChild(alarm); - ForgetPredefinedSubtree(alarm); - } // tear down every native-alarm→equipment-folder notifier pair bidirectionally // BEFORE the conditions + folders are cleared below, so no inverse-notifier entry leaks across the - // rebuild. The condition + folder NodeState objects still exist here (the loops above/below only - // detach them from their parents + PredefinedNodes), so RemoveNotifier is valid; then clear the - // tracking map so the re-materialise + re-wire on the next apply starts from a clean slate. + // rebuild. The condition + folder NodeState objects still exist here, so RemoveNotifier is valid; + // then clear the tracking map so the re-materialise + re-wire on the next apply starts clean. foreach (var alarmKey in _alarmNotifierWiring.Keys.ToList()) UnwireAlarmNotifiers(alarmKey); + // Review 01 P1 — detach whole subtrees, not leaves. NodeState.RemoveChild is a linear scan + + // RemoveAt, so detaching each of a folder's k children from it cost ~k²/2 compares under Lock + // (a 10k-tag device folder ≈ 50M). Everything this manager minted hangs under the shared root, + // which survives the rebuild, so detaching the root's own children once discards every subtree + // beneath them; the SDK lookup dictionary is still cleared per id (O(1) each) because + // PredefinedNodes indexes every node, condition children included. + if (_root is { } root) + { + var topLevel = new List(); + root.GetChildren(SystemContext, topLevel); + // Last-first: RemoveChild still scans for the reference, but RemoveAt on the tail shifts nothing. + for (var i = topLevel.Count - 1; i >= 0; i--) root.RemoveChild(topLevel[i]); + } + + foreach (var v in _variables.Values) PredefinedNodes?.Remove(v.NodeId); + _variables.Clear(); + // Phase C: drop the NodeId→historian-tagname registrations alongside the variables they map. + _historizedTagnames.Clear(); + _arrayOnScalarRefused.Clear(); + + foreach (var alarm in _alarmConditions.Values) ForgetPredefinedSubtree(alarm); _alarmConditions.Clear(); // H6a: drop the native-alarm flags in lock-step with the conditions they classify, so a // re-materialise on the next apply (possibly as the other kind) starts from a clean slate. _nativeAlarmNodeIds.Clear(); - foreach (var f in _folders.Values) - { - f.Parent?.RemoveChild(f); - PredefinedNodes?.Remove(f.NodeId); - } + foreach (var f in _folders.Values) PredefinedNodes?.Remove(f.NodeId); _folders.Clear(); + _childIndex.Clear(); + _parentIndex.Clear(); // Detach the Server↔folder HasNotifier ref for every promoted folder before dropping the // guard, otherwise the rebuild leaks an orphaned root-notifier reference on the Server - // object. RemoveRootNotifier just severs that link, so its order relative to the folder - // teardown above doesn't matter — but it must run under this same Lock. - foreach (var folder in _notifierFolders.Values) + // object. Done the way RemoveRootNotifier does it, but over the whole list at once: the SDK's + // per-folder removal scans RootNotifiers each time (O(F²) for F promoted folders). + if (RootNotifiers is { Count: > 0 } rootNotifiers) { - RemoveRootNotifier(folder); + foreach (var notifier in rootNotifiers) + { + notifier.OnReportEvent = null; + notifier.RemoveReference(ReferenceTypeIds.HasNotifier, true, ObjectIds.Server); + } + rootNotifiers.Clear(); } // Drop the notifier-folder guard so re-materialised alarms re-promote their (rebuilt) @@ -3611,6 +3661,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // caller (AddressSpaceApplier) falls back to a full rebuild (resync). Mirrors the per-node // teardown inside RebuildAddressSpace, scoped to this one id. if (!_variables.TryRemove(key, out var variable)) return false; + UnindexChild(key); TombstoneMonitoredNode(variable.NodeId, UtcNow); // 2026-08-15 review I3: sever the INVERSE half of every cross-tree Organizes edge this node holds // BEFORE detaching it — the same hazard (and the same fix shape) as UnwireAlarmNotifiers on a @@ -3678,6 +3729,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // lingering notifier folder is harmless (RemoveEquipmentSubtree demotes it when the whole // equipment goes). if (!_alarmConditions.TryRemove(key, out var condition)) return false; + UnindexChild(key); TombstoneMonitoredSubtree(condition, UtcNow); // unwire this condition's extra equipment-folder notifiers bidirectionally BEFORE // the condition drops — the folders (equipment, UNS realm) SURVIVE this scoped remove, so without @@ -3686,7 +3738,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 condition.Parent?.RemoveChild(condition); ForgetPredefinedSubtree(condition); _nativeAlarmNodeIds.Remove(key); - ReconcileAllEventsMonitoring(); + LowerAllEventsMonitoring(condition); e = BuildNodesRemovedModelChange(alarmNodeId, realm); } ReportModelChangeOutsideLock(e, alarmNodeId); @@ -3706,84 +3758,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Unknown equipment id ⇒ map drift ⇒ false (caller rebuilds). if (!_folders.ContainsKey(equipKey)) return false; - // Maps key on the (realm, bare id) pair. Every descendant in the SAME realm shares that id exactly - // or begins with "/" (sub-folders, variables, condition nodes); the explicit realm - // compare is what makes the match realm-scoped — it replaces the old string key's leading "ns=N;s=" - // — so a node in the OTHER namespace that happens to share a bare id is never in scope. Snapshot - // each map's in-scope keys first (ToList) so we don't mutate while enumerating. - var prefix = equipmentNodeId + "/"; - bool InScope(RealmKey key) => key.Realm == realm - && (key.Id == equipmentNodeId || key.Id.StartsWith(prefix, StringComparison.Ordinal)); - - var now = UtcNow; - // Value variables (+ their historized-tagname registrations). - foreach (var id in _variables.Keys.Where(InScope).ToList()) - { - if (_variables.TryRemove(id, out var v)) - { - TombstoneMonitoredNode(v.NodeId, now); - // Sever the inverse half of each cross-tree Organizes edge - // first, exactly as RemoveVariableNode does — a UNS reference variable in scope points at a - // RAW node that survives this UNS-scoped removal and would otherwise keep a dangling edge. - SeverOutgoingReferences(v); - v.Parent?.RemoveChild(v); - PredefinedNodes?.Remove(v.NodeId); - } - _historizedTagnames.TryRemove(id, out _); - _arrayOnScalarRefused.TryRemove(id, out _); - } - - // Part 9 condition nodes (+ their native flags). - foreach (var id in _alarmConditions.Keys.Where(InScope).ToList()) - { - if (_alarmConditions.TryRemove(id, out var c)) - { - TombstoneMonitoredSubtree(c, now); - c.Parent?.RemoveChild(c); - ForgetPredefinedSubtree(c); - } - _nativeAlarmNodeIds.Remove(id); - } - ReconcileAllEventsMonitoring(); - - // a native alarm condition lives at its raw tag (Raw realm) and SURVIVES this - // UNS-scoped subtree removal, but it may hold an in-scope equipment folder as an extra - // event-notifier root. Unwire those condition↔folder pairs bidirectionally BEFORE the folders drop, - // else the surviving condition keeps a dangling inverse-notifier entry to a removed folder. (The - // condition's own scoped removal — if its raw tag is also removed this apply — runs later via - // RemoveAlarmConditionNode; this only reaches surviving conditions.) - foreach (var wiring in _alarmNotifierWiring.Values) - { - foreach (var folder in wiring.Folders.Where(f => InScope(MapKey(f.NodeId))).ToList()) - { - wiring.Alarm.RemoveNotifier(SystemContext, folder, bidirectional: true); - wiring.Folders.Remove(folder); - } - } - - // Notifier demotion BEFORE dropping the folders: sever the Server↔folder HasNotifier ref for - // every promoted folder in the subtree (an equipment folder, or a sub-folder that hosted an - // alarm), else the removal leaks an orphaned root-notifier reference on the Server object. - // _notifierFolders is keyed by the folder's NodeId; match InScope on its realm-qualified map key. - foreach (var kvp in _notifierFolders.Where(kv => InScope(MapKey(kv.Key))).ToList()) - { - RemoveRootNotifier(kvp.Value); - _notifierFolders.Remove(kvp.Key); - } - // Drop the event-history source registrations for the subtree (keyed by the folder id string). - foreach (var src in _eventNotifierSources.Keys.Where(InScope).ToList()) - _eventNotifierSources.TryRemove(src, out _); - - // Sub-folders + the equipment folder itself (children already detached above). - foreach (var id in _folders.Keys.Where(InScope).ToList()) - { - if (_folders.TryRemove(id, out var f)) - { - TombstoneMonitoredNode(f.NodeId, now); - f.Parent?.RemoveChild(f); - PredefinedNodes?.Remove(f.NodeId); - } - } + // Review 01 P2 — walk the parent→children index the materialise calls maintain instead of + // prefix-scanning every map (variables, conditions, folders, notifier folders, event sources and + // every alarm's wiring) per removed equipment, which made a PureRemove of M equipment O(M·N) + // under Lock. A node is detached from its SDK parent only when that parent SURVIVES (the + // equipment folder from its line; a node hung under the shared root because its parent was + // unknown at creation) — detaching k children from a folder that is itself being discarded cost + // k²/2 compares for nothing (NodeState.RemoveChild is a linear scan). + var removedParents = new HashSet(System.Collections.Generic.ReferenceEqualityComparer.Instance); + RemoveSubtree(equipKey, removedParents, UtcNow); e = BuildNodesRemovedModelChange(equipmentNodeId, realm); } @@ -3791,6 +3774,84 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 return true; } + /// Removes the node at and, when it is a folder, everything indexed beneath + /// it (children first). MUST be called under Lock. + /// The node to remove. + /// The folders already discarded by this removal — a node under one of them is + /// not detached from it. + /// The timestamp the removal statuses carry. + private void RemoveSubtree(RealmKey key, HashSet removedParents, DateTime now) + { + if (_folders.TryGetValue(key, out var folder)) + { + removedParents.Add(folder); + if (_childIndex.TryGetValue(key, out var children)) + { + foreach (var child in children.ToArray()) RemoveSubtree(child, removedParents, now); + _childIndex.Remove(key); + } + + _folders.TryRemove(key, out _); + UnindexChild(key); + TombstoneMonitoredNode(folder.NodeId, now); + + // A native alarm condition lives at its raw tag (Raw realm) and SURVIVES this UNS-scoped + // subtree removal, but it may hold this folder as an extra event-notifier root. Unwire those + // condition↔folder pairs bidirectionally BEFORE the folder drops, else the surviving condition + // keeps a dangling inverse-notifier entry to a removed folder. The folder's own notifier list + // names exactly the conditions wired into it (the forward half of each pair). + var notifiers = new List(); + folder.GetNotifiers(SystemContext, notifiers); + foreach (var entry in notifiers) + { + if (entry.IsInverse || entry.Node is not AlarmConditionState alarm) continue; + if (!_alarmNotifierWiring.TryGetValue(MapKey(alarm.NodeId), out var wiring) || !ReferenceEquals(wiring.Alarm, alarm)) continue; + alarm.RemoveNotifier(SystemContext, folder, bidirectional: true); + wiring.Folders.Remove(folder); + } + + // Notifier demotion BEFORE dropping the folder: sever the Server↔folder HasNotifier ref for a + // promoted folder (an equipment folder, or a sub-folder that hosted an alarm), else the removal + // leaks an orphaned root-notifier reference on the Server object. + if (_notifierFolders.Remove(folder.NodeId, out var promoted)) RemoveRootNotifier(promoted); + _eventNotifierSources.TryRemove(key, out _); + + if (folder.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(folder); + PredefinedNodes?.Remove(folder.NodeId); + return; + } + + if (_variables.TryRemove(key, out var v)) + { + UnindexChild(key); + TombstoneMonitoredNode(v.NodeId, now); + // Sever the inverse half of each cross-tree Organizes edge first, exactly as RemoveVariableNode + // does — a UNS reference variable in scope points at a RAW node that survives this UNS-scoped + // removal and would otherwise keep a dangling edge. + SeverOutgoingReferences(v); + if (v.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(v); + PredefinedNodes?.Remove(v.NodeId); + _historizedTagnames.TryRemove(key, out _); + _arrayOnScalarRefused.TryRemove(key, out _); + return; + } + + if (_alarmConditions.TryRemove(key, out var c)) + { + UnindexChild(key); + TombstoneMonitoredSubtree(c, now); + UnwireAlarmNotifiers(key); + if (c.Parent is { } parent && !removedParents.Contains(parent)) parent.RemoveChild(c); + ForgetPredefinedSubtree(c); + _nativeAlarmNodeIds.Remove(key); + LowerAllEventsMonitoring(c); + return; + } + + // Indexed but already gone (a surgical remove dropped it first) — nothing to do. + UnindexChild(key); + } + private FolderState ResolveParentFolder(string? parentNodeId, AddressSpaceRealm realm) { EnsureAddressSpaceCreated(); @@ -4296,10 +4357,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// children are monitorable in their own right). MUST be called under Lock. private void TombstoneMonitoredSubtree(NodeState node, DateTime now) { - TombstoneMonitoredNode(node.NodeId, now); + if (MonitoredNodes.Count == 0 && Volatile.Read(ref _suppressedItems) == 0) return; + var pending = new Stack(); + pending.Push(node); var children = new List(); - node.GetChildren(SystemContext, children); - foreach (var child in children) TombstoneMonitoredSubtree(child, now); + while (pending.Count > 0) + { + var current = pending.Pop(); + TombstoneMonitoredNode(current.NodeId, now); + children.Clear(); + current.GetChildren(SystemContext, children); + foreach (var child in children) pending.Push(child); + } } /// The rebuild form: every monitored node this manager serves except the shared root, which the @@ -4560,6 +4629,20 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 condition.SetAreEventsMonitored(SystemContext, true, includeChildren: true); } + /// + /// The removal-side mirror of (review 01 P3): gives back the one + /// condition's unit, touching no other condition. Call under Lock, for a condition being removed + /// or replaced. The surgical removal sites used to run the full reconcile — O(C) per removed condition + /// while any Server-object item was live, i.e. always in production. + /// + /// The condition leaving _alarmConditions. + private void LowerAllEventsMonitoring(AlarmConditionState condition) + { + AllEventsMonitoringConditionVisits++; + if (_allEventsMonitoredConditions.Remove(condition)) + condition.SetAreEventsMonitored(SystemContext, false, includeChildren: true); + } + /// /// How many conditions the Server-object monitored bookkeeping has visited since start — every /// condition a full walks, plus one per incremental diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs new file mode 100644 index 00000000..848529f3 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs @@ -0,0 +1,190 @@ +using System.Diagnostics; +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Review 01 P1 / P2 / P3 — the lock-time budget of the three teardown paths that run under the +/// manager-wide SDK Lock on the pinned publish thread, where every Read / Browse / Publish waits. +/// +/// P1: a full rebuild of a 10k-tag device folder was O(k²) per folder because +/// NodeState.RemoveChild is a linear scan + RemoveAt per leaf (~50M compares). +/// P2: removing M equipment from an N-node space scanned every map per equipment (O(M·N)). +/// P3: every surgical condition removal re-walked every condition for the Server-object +/// monitoring bookkeeping (O(C) per removal) while any i=2253 item existed. +/// +/// The wall-clock budgets are generous for CI hardware; the before/after numbers are in the commit +/// message. P3 is pinned by the bookkeeping's own visit counter, which does not depend on the machine. +/// +[Trait("Category", "Benchmark")] +public sealed class NodeManagerLockTimeBenchmarkTests(ITestOutputHelper output) : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + private static readonly TimeSpan Budget = TimeSpan.FromSeconds(1); + + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-bench-{Guid.NewGuid():N}"); + + /// P1 — a rebuild that discards one device folder holding 10k tags completes within the budget. + [Fact] + public async Task Rebuild_of_a_10k_tag_folder_stays_within_budget() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + const string device = "Plant/Modbus/dev1"; + nm.EnsureFolder(device, null, "dev1", AddressSpaceRealm.Raw); + for (var i = 0; i < 10_000; i++) + nm.EnsureVariable($"{device}/T{i}", device, $"T{i}", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + nm.VariableCount.ShouldBe(10_000); + + var sw = Stopwatch.StartNew(); + nm.RebuildAddressSpace(); + sw.Stop(); + nm.CompleteAddressSpaceApply(); + + nm.VariableCount.ShouldBe(0); + nm.FolderCount.ShouldBe(0); + // Positive control: the space is really empty for the SDK too, and can be re-populated. + nm.GetManagerHandle(new NodeId($"{device}/T0", nm.NamespaceIndexForRealm(AddressSpaceRealm.Raw))).ShouldBeNull(); + nm.EnsureFolder(device, null, "dev1", AddressSpaceRealm.Raw); + nm.EnsureVariable($"{device}/T0", device, "T0", "Int32", writable: false, realm: AddressSpaceRealm.Raw); + nm.TryGetVariable($"{device}/T0", AddressSpaceRealm.Raw)!.Parent.ShouldBeSameAs(nm.TryGetFolder(device, AddressSpaceRealm.Raw)); + + output.WriteLine($"BENCH rebuild 10k-tag folder: {sw.ElapsedMilliseconds} ms"); + sw.Elapsed.ShouldBeLessThan(Budget, $"rebuild of a 10k-tag folder took {sw.ElapsedMilliseconds} ms"); + } + finally { await host.DisposeAsync(); } + } + + /// P2 — removing 200 equipment (250 signals each, 50k nodes) completes within the budget. + [Fact] + public async Task Removing_200_equipment_from_a_50k_node_space_stays_within_budget() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + const int equipmentCount = 200, perEquipment = 250; + for (var e = 0; e < equipmentCount; e++) + { + var equip = $"area/line/eq{e}"; + nm.EnsureFolder(equip, null, $"eq{e}", AddressSpaceRealm.Uns); + for (var i = 0; i < perEquipment; i++) + nm.EnsureVariable($"{equip}/S{i}", equip, $"S{i}", "Float", writable: false, realm: AddressSpaceRealm.Uns); + } + // A survivor the removals must not touch. + nm.EnsureFolder("area/line/keep", null, "keep", AddressSpaceRealm.Uns); + nm.EnsureVariable("area/line/keep/S0", "area/line/keep", "S0", "Float", writable: false, realm: AddressSpaceRealm.Uns); + nm.VariableCount.ShouldBe(equipmentCount * perEquipment + 1); + + var sw = Stopwatch.StartNew(); + for (var e = 0; e < equipmentCount; e++) + nm.RemoveEquipmentSubtree($"area/line/eq{e}", AddressSpaceRealm.Uns).ShouldBeTrue(); + sw.Stop(); + + nm.VariableCount.ShouldBe(1); + nm.FolderCount.ShouldBe(1); + nm.TryGetVariable("area/line/keep/S0").ShouldNotBeNull(); + nm.GetManagerHandle(new NodeId("area/line/eq0/S0", nm.NamespaceIndexForRealm(AddressSpaceRealm.Uns))).ShouldBeNull(); + + output.WriteLine($"BENCH remove 200 equipment / 50k nodes: {sw.ElapsedMilliseconds} ms"); + sw.Elapsed.ShouldBeLessThan(Budget, $"removing 200 equipment took {sw.ElapsedMilliseconds} ms"); + } + finally { await host.DisposeAsync(); } + } + + /// + /// P3 — with a Server-object item live, a surgical condition removal lowers only that condition's + /// monitoring unit instead of re-walking every condition: the bookkeeping visits at most one + /// condition per removal. (A full reconcile visits every condition twice — desired + held.) + /// + [Fact] + public async Task A_surgical_condition_removal_visits_only_its_own_condition() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns); + const int conditions = 50; + for (var i = 0; i < conditions; i++) + nm.MaterialiseAlarmCondition($"alm-{i}", "eq-1", $"Alarm {i}", "OffNormalAlarm", 500, AddressSpaceRealm.Uns); + + // A Server-object (all-events) subscriber, so the per-condition monitoring unit is in play. + nm.SubscribeToAllEvents( + new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.CreateMonitoredItems, identity: null), + 1, new StubEventItem(), unsubscribe: false); + nm.ConditionEventsMonitored("alm-0", AddressSpaceRealm.Uns).ShouldBeTrue("non-vacuity: the units really were raised"); + + var before = nm.AllEventsMonitoringConditionVisits; + for (var i = 0; i < 10; i++) + nm.RemoveAlarmConditionNode($"alm-{i}", AddressSpaceRealm.Uns).ShouldBeTrue(); + var visits = nm.AllEventsMonitoringConditionVisits - before; + output.WriteLine($"BENCH all-events visits for 10 condition removals over {conditions} conditions: {visits}"); + + visits.ShouldBeLessThanOrEqualTo(10, $"ten removals visited {visits} conditions; a full reconcile per removal would visit ~{10 * 2 * conditions}"); + // The survivors keep their unit. + nm.ConditionEventsMonitored("alm-49", AddressSpaceRealm.Uns).ShouldBeTrue(); + } + finally { await host.DisposeAsync(); } + } + + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() => + await OpcUaHostStart.StartAsync( + port => new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.BenchTest", + ApplicationUri = $"urn:OtOpcUa.BenchTest:{Guid.NewGuid():N}", + OpcUaPort = port, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } + + /// The minimum of a Server-object event item the all-events bookkeeping touches: identity only. + private sealed class StubEventItem : IEventMonitoredItem + { + public bool MonitoringAllEvents => true; + public NodeId NodeId => ObjectIds.Server; + public IUserIdentity EffectiveIdentity => null!; + public object ManagerHandle => null!; + public void QueueEvent(IFilterTarget instance) { } + public void QueueEvent(IFilterTarget instance, bool bypassFilter) { } + public void Dispose() { } + + public INodeManager NodeManager => throw new NotSupportedException(); + public ISession Session => throw new NotSupportedException(); + public uint Id => 1; + public uint SubscriptionId => 1; + public bool IsDurable => false; + public uint ClientHandle => 1; + public ISubscription SubscriptionCallback { get => throw new NotSupportedException(); set => throw new NotSupportedException(); } + public int MonitoredItemType => MonitoredItemTypeMask.Events; + public bool IsReadyToPublish => false; + public bool IsReadyToTrigger { get => false; set { } } + public bool IsResendData => false; + public MonitoringMode MonitoringMode => MonitoringMode.Reporting; + public double SamplingInterval => 0; + public EventFilter EventFilter => null!; + public void SetupResendDataTrigger() { } + public ServiceResult GetCreateResult(out MonitoredItemCreateResult result) => throw new NotSupportedException(); + public ServiceResult GetModifyResult(out MonitoredItemModifyResult result) => throw new NotSupportedException(); + public IStoredMonitoredItem ToStorableMonitoredItem() => throw new NotSupportedException(); + public MonitoringMode SetMonitoringMode(MonitoringMode monitoringMode) => throw new NotSupportedException(); + public bool Publish(OperationContext context, Queue notifications, uint maxNotificationsPerPublish) => throw new NotSupportedException(); + public ServiceResult ModifyAttributes(DiagnosticsMasks diagnosticsMasks, TimestampsToReturn timestampsToReturn, uint clientHandle, MonitoringFilter originalFilter, MonitoringFilter filterToUse, Opc.Ua.Range range, double samplingInterval, uint queueSize, bool discardOldest) => throw new NotSupportedException(); + } +} From 1578efd4a91bae7b0261498bad6408e35f031670 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:22:29 -0400 Subject: [PATCH 04/67] fix(opcua): write-path robustness, injected clock, configurable quotas (B4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 01 L3/H5/H4/P6/P4. - L3/H5 OnEquipmentTagWrite's dispatch is factored into DispatchDeviceWrite: a gateway that throws synchronously is refused inline with BadDeviceFailure (it used to escape the handler under the SDK Lock), and a faulted/cancelled dispatch task has its exception observed and logged and carries the real reason into the revert/audit event instead of surfacing as UnobservedTaskException at finalization. - H4 every timestamp the manager stamps (condition quality, timed-shelve expiry, method-transition events, carry-over, revert, revalidation) reads the injected Clock; RemainingShelveMs takes the clock reading. - P6 OpcUa:Quotas:* (OpcUaQuotaOptions on OpcUaApplicationHostOptions): Min/MaxRequestThreadCount, MaxQueuedRequestCount with the former hard-coded 5/100/200 as defaults, and the TransportQuotas members as nullable overrides over the SDK defaults. - P4 the SDK line exposes IHistoryReadAsyncNodeManager but CustomNodeManager2 offers no async virtual to override (adopting it means re-implementing the base dispatcher), so the lever is the admission cap: MaxConcurrentHistoryReadBatches defaults to clamp(2*ProcessorCount, 4, 16). The Host still overrides it from ServerHistorianOptions (default 16) — follow-up F3. Tests: NodeManagerWriteDispatchTests (sync throw refused; faulted task reverts; success leaves the value; clock-stamped revert), OpcUaApplicationHostQuotaTests. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OpcUaApplicationHost.cs | 89 +++++++++- .../OtOpcUaNodeManager.cs | 110 +++++++++--- .../NodeManagerWriteDispatchTests.cs | 160 ++++++++++++++++++ .../OpcUaApplicationHostQuotaTests.cs | 74 ++++++++ 4 files changed, 402 insertions(+), 31 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteDispatchTests.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostQuotaTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index b1e265fd..4b12e0d1 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -105,6 +105,55 @@ public sealed class OpcUaApplicationHostOptions /// is always element 0. /// public IList PeerApplicationUris { get; set; } = new List(); + + /// + /// SDK request-processing and transport quotas (OpcUa:Quotas:*, review 01 P6). The defaults are + /// exactly the values that used to be hard-coded (5 / 100 / 200 request threads and queue) and the SDK's + /// own transport defaults, so an absent section changes nothing. + /// + public OpcUaQuotaOptions Quotas { get; set; } = new(); +} + +/// +/// The OpcUa:Quotas section: the SDK's request-thread / queue ceilings and its transport quotas. +/// Request quotas carry the former hard-coded defaults; a transport quota left null keeps the SDK +/// default ('s constructor values). +/// +public sealed class OpcUaQuotaOptions +{ + /// Minimum SDK request-processing threads (default 5). + public int MinRequestThreadCount { get; set; } = 5; + + /// Maximum SDK request-processing threads (default 100). + public int MaxRequestThreadCount { get; set; } = 100; + + /// Requests the SDK queues before refusing with BadTooManyOperations (default 200) — the + /// burst ceiling review 01 P6 flagged. + public int MaxQueuedRequestCount { get; set; } = 200; + + /// Per-operation timeout in milliseconds; null keeps the SDK default. + public int? OperationTimeoutMs { get; set; } + + /// Largest string the transport decodes; null keeps the SDK default. + public int? MaxStringLength { get; set; } + + /// Largest byte string the transport decodes; null keeps the SDK default. + public int? MaxByteStringLength { get; set; } + + /// Largest array the transport decodes; null keeps the SDK default. + public int? MaxArrayLength { get; set; } + + /// Largest message the transport accepts, in bytes; null keeps the SDK default. + public int? MaxMessageSize { get; set; } + + /// Transport buffer size in bytes; null keeps the SDK default. + public int? MaxBufferSize { get; set; } + + /// Secure-channel lifetime in milliseconds; null keeps the SDK default. + public int? ChannelLifetimeMs { get; set; } + + /// Security-token lifetime in milliseconds; null keeps the SDK default. + public int? SecurityTokenLifetimeMs { get; set; } } /// @@ -435,10 +484,8 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable var serverConfig = new ServerConfiguration { BaseAddresses = { $"opc.tcp://{_options.PublicHostname}:{_options.OpcUaPort}/OtOpcUa" }, - MinRequestThreadCount = 5, - MaxRequestThreadCount = 100, - MaxQueuedRequestCount = 200, }; + ApplyRequestQuotas(serverConfig, _options.Quotas); foreach (var policy in BuildSecurityPolicies(_options.EnabledSecurityProfiles)) { @@ -469,7 +516,7 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable RejectedCertificateStore = new CertificateTrustList { StoreType = "Directory", StorePath = Path.Combine(_options.PkiStoreRoot, "rejected") }, AutoAcceptUntrustedCertificates = _options.AutoAcceptUntrustedClientCertificates, }, - TransportQuotas = new TransportQuotas(), + TransportQuotas = BuildTransportQuotas(_options.Quotas), ClientConfiguration = new ClientConfiguration(), TraceConfiguration = new TraceConfiguration(), }; @@ -479,6 +526,40 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable return config; } + /// + /// Applies the request-processing quotas (OpcUa:Quotas:*, review 01 P6 — they were hard-coded at + /// 5 / 100 / 200) to the SDK server configuration. + /// + /// The configuration to set. + /// The configured quotas. + internal static void ApplyRequestQuotas(ServerConfiguration serverConfig, OpcUaQuotaOptions quotas) + { + serverConfig.MinRequestThreadCount = quotas.MinRequestThreadCount; + serverConfig.MaxRequestThreadCount = quotas.MaxRequestThreadCount; + serverConfig.MaxQueuedRequestCount = quotas.MaxQueuedRequestCount; + } + + /// + /// Builds the SDK transport quotas from OpcUa:Quotas:*: every member starts at the SDK default and + /// only a configured (non-null) value replaces it, so an absent section is byte-identical to the former + /// new TransportQuotas(). + /// + /// The configured quotas. + /// The transport quotas to install. + internal static TransportQuotas BuildTransportQuotas(OpcUaQuotaOptions quotas) + { + var t = new TransportQuotas(); + if (quotas.OperationTimeoutMs is { } operationTimeout) t.OperationTimeout = operationTimeout; + if (quotas.MaxStringLength is { } maxString) t.MaxStringLength = maxString; + if (quotas.MaxByteStringLength is { } maxByteString) t.MaxByteStringLength = maxByteString; + if (quotas.MaxArrayLength is { } maxArray) t.MaxArrayLength = maxArray; + if (quotas.MaxMessageSize is { } maxMessage) t.MaxMessageSize = maxMessage; + if (quotas.MaxBufferSize is { } maxBuffer) t.MaxBufferSize = maxBuffer; + if (quotas.ChannelLifetimeMs is { } channelLifetime) t.ChannelLifetime = channelLifetime; + if (quotas.SecurityTokenLifetimeMs is { } tokenLifetime) t.SecurityTokenLifetime = tokenLifetime; + return t; + } + /// /// Maps each configured to a SDK /// . Duplicate profiles are silently de-duped because diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 9a2fe15f..8f03cd75 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -581,9 +581,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// Process-wide cap on concurrently-served HistoryRead batches (arch-review 03/S3). A batch that /// cannot acquire a slot within its (bounded) wait budget fast-fails every handle with /// BadTooManyOperations. Mirrors ServerHistorianOptions.MaxConcurrentHistoryReadBatches; - /// the Host sets it at StartAsync. The default (16) survives when the section is absent. + /// the Host sets it at StartAsync. The default () + /// survives when the section is absent. + /// + /// Review 01 P4: every HistoryRead / HistoryUpdate arm is sync-over-async on an SDK request thread — + /// the SDK line pins this manager to the synchronous CustomNodeManager2.HistoryRead dispatcher + /// (its IHistoryReadAsyncNodeManager is a separate interface, adapted for sync managers, that + /// offers no async virtual to override; adopting it means re-implementing the base's handle validation + /// and continuation-point plumbing). So the admission cap is the lever: with a slow historian a + /// batch holds its request thread for up to , and 16 of them on a + /// small node is a third of MaxRequestThreadCount. The default therefore scales with the + /// machine — clamp(2 × ProcessorCount, 4, 16) — instead of a flat 16. + /// /// - public int MaxConcurrentHistoryReadBatches { get; set; } = 16; + public int MaxConcurrentHistoryReadBatches { get; set; } = DefaultMaxConcurrentHistoryReadBatches; + + /// The CPU-scaled default of : clamp(2 × ProcessorCount, 4, 16). + public static int DefaultMaxConcurrentHistoryReadBatches { get; } = Math.Clamp(Environment.ProcessorCount * 2, 4, 16); /// /// Per-request wall-clock deadline for a HistoryRead batch (arch-review 03/S3). A node still in @@ -1058,7 +1072,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 SystemContext, shelved: state.Shelving != AlarmShelvingKind.Unshelved, oneShot: state.Shelving == AlarmShelvingKind.OneShot, - shelvingTime: state.Shelving == AlarmShelvingKind.Timed ? RemainingShelveMs(state.UnshelveAtUtc) : 0); + shelvingTime: state.Shelving == AlarmShelvingKind.Timed ? RemainingShelveMs(state.UnshelveAtUtc, UtcNow) : 0); } condition.SetSeverity(SystemContext, ToPart9Severity(state.Severity)); condition.Message.Value = new LocalizedText(state.Message); @@ -1105,11 +1119,12 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// at most int.MaxValue (the SDK timer's range). /// /// The engine's expiry instant, or null. + /// The manager's clock reading (), so a test clock drives it. /// The shelving time in milliseconds. - internal static double RemainingShelveMs(DateTime? unshelveAtUtc) + internal static double RemainingShelveMs(DateTime? unshelveAtUtc, DateTime nowUtc) { if (unshelveAtUtc is not { } at) return 0; - var remaining = (at - DateTime.UtcNow).TotalMilliseconds; + var remaining = (at - nowUtc).TotalMilliseconds; return Math.Clamp(Math.Ceiling(remaining), 1, int.MaxValue); } @@ -1476,7 +1491,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (alarm.Quality is not null) { alarm.Quality.Value = isNative ? StatusCodes.BadWaitingForInitialData : StatusCodes.Good; - if (alarm.Quality.SourceTimestamp is not null) alarm.Quality.SourceTimestamp.Value = DateTime.UtcNow; + if (alarm.Quality.SourceTimestamp is not null) alarm.Quality.SourceTimestamp.Value = UtcNow; } // Initial state via the SDK setters (T14: basic state only, NO event firing). @@ -1538,7 +1553,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 var (operation, unshelveAt) = !shelving ? ("Unshelve", (DateTime?)null) : oneShot ? ("OneShotShelve", null) - : ("TimedShelve", DateTime.UtcNow + TimeSpan.FromMilliseconds(shelvingTime)); + : ("TimedShelve", UtcNow + TimeSpan.FromMilliseconds(shelvingTime)); return HandleAlarmCommand(context, condition, operation, comment: null, unshelveAt); }; // The SDK's auto-unshelve timer (armed by SetShelvingState for the real remaining time since the @@ -1635,9 +1650,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// /// The live native condition, about to be discarded. /// Its carried state. - private static NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new( + private NativeConditionCarryOver CaptureCarryOver(AlarmConditionState condition) => new( ReadConditionDelta(condition), - condition.Time?.Value ?? DateTime.UtcNow, + condition.Time?.Value ?? UtcNow, condition.ActiveState?.TransitionTime?.Value, condition.Quality?.SourceTimestamp?.Value, condition.Comment?.Value, @@ -1953,7 +1968,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } if (alwaysReport || ReadConditionDelta(alarm) != before) - ReportConditionEvent(alarm, DateTime.UtcNow); + ReportConditionEvent(alarm, UtcNow); return result; } @@ -2223,20 +2238,60 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // presents ("apikey"), so an audit trail built on it cannot tell two consuming systems apart. var clientUserId = identity is { Principal.Length: > 0 } ? identity.Principal : identity?.DisplayName; - // Fire-and-forget — MUST NOT block under Lock. On a FAILED outcome, compare-and-revert (off-Lock - // continuation). A faulted/cancelled WriteAsync is treated as a failure so the optimistic value never - // sticks when the route never resolved a real outcome. RunContinuationsAsynchronously guarantees the - // revert never runs inline on the SDK write thread (the gateway can return a synchronously-completed - // task — e.g. its boot-window "no DriverHostActor yet" branch), so RevertOptimisticWriteIfNeeded never - // re-enters lock (Lock) while CustomNodeManager2.Write still holds it. - _ = gateway.WriteAsync(nodeKey, optimisticValue, nodeRealm, CancellationToken.None) - .ContinueWith( - t => + return DispatchDeviceWrite(gateway, nodeKey, nodeBareId, nodeRealm, optimisticValue, priorValue, priorStatus, clientUserId); + } + + /// + /// The dispatch half of : start the device write and arm the off-Lock + /// revert. Fire-and-forget — MUST NOT block under Lock. On a FAILED outcome, compare-and-revert + /// (off-Lock continuation). A faulted/cancelled WriteAsync is treated as a failure so the optimistic + /// value never sticks when the route never resolved a real outcome; its exception is OBSERVED and logged + /// here (review 01 L3 — it used to surface only as an UnobservedTaskException at finalization, with + /// the constant reason "write dispatch faulted" in the audit event). A gateway that throws SYNCHRONOUSLY + /// (review 01 H5) is refused inline with BadDeviceFailure before the SDK applies anything, rather + /// than escaping the handler under Lock. RunContinuationsAsynchronously guarantees the revert + /// never runs inline on the SDK write thread (the gateway can return a synchronously-completed task — e.g. + /// its boot-window "no DriverHostActor yet" branch), so never + /// re-enters Lock while CustomNodeManager2.Write still holds it. + /// + /// once the write is in flight (the SDK then applies the value + /// optimistically); a Bad result when the dispatch itself threw. + internal ServiceResult DispatchDeviceWrite( + IOpcUaNodeWriteGateway gateway, string nodeKey, string nodeBareId, AddressSpaceRealm nodeRealm, + object? optimisticValue, object? priorValue, StatusCode priorStatus, string? clientUserId) + { + Task dispatch; + try + { + dispatch = gateway.WriteAsync(nodeKey, optimisticValue, nodeRealm, CancellationToken.None); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "OtOpcUaNodeManager: write dispatch for {NodeId} threw synchronously; the write is refused", nodeKey); + return new ServiceResult(StatusCodes.BadDeviceFailure, $"write dispatch threw: {ex.Message}"); + } + + _ = dispatch.ContinueWith( + t => + { + NodeWriteOutcome outcome; + if (t.IsCompletedSuccessfully) { - var outcome = t.IsCompletedSuccessfully ? t.Result : new NodeWriteOutcome(false, "write dispatch faulted"); - RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm); - }, - CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default); + outcome = t.Result; + } + else + { + // Reading t.Exception observes it, so the failure never reaches UnobservedTaskException. + var fault = t.Exception?.GetBaseException(); + var reason = fault is not null ? $"write dispatch faulted: {fault.Message}" + : t.IsCanceled ? "write dispatch cancelled" : "write dispatch faulted"; + _logger.LogWarning(fault, "OtOpcUaNodeManager: write dispatch for {NodeId} did not complete — {Reason}", nodeKey, reason); + outcome = new NodeWriteOutcome(false, reason); + } + + RevertOptimisticWriteIfNeeded(nodeBareId, outcome, optimisticValue, priorValue, priorStatus, clientUserId, nodeRealm); + }, + CancellationToken.None, TaskContinuationOptions.RunContinuationsAsynchronously, TaskScheduler.Default); return ServiceResult.Good; } @@ -2647,13 +2702,14 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // Item B: surface a transient Bad-quality blip on the still-applied optimistic value, then restore // the prior value/status. Both publishes are under this single Lock hold (mirrors WriteValue's // node-update shape). See the method remarks for the queue-coalescing caveat. + var now = UtcNow; variable.StatusCode = StatusCodes.BadDeviceFailure; - variable.Timestamp = DateTime.UtcNow; + variable.Timestamp = now; variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — the Bad blip variable.Value = priorValue; variable.StatusCode = priorStatus; - variable.Timestamp = DateTime.UtcNow; + variable.Timestamp = now; variable.ClearChangeMasks(SystemContext, includeChildren: false); // notify — restore prior // Item C: build the audit event-state while we hold the node reference + Lock, but DON'T report yet. @@ -2710,7 +2766,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 severity: EventSeverity.Medium, message: new LocalizedText($"Inbound write rejected by device: {reason}"), status: false, - actionTimestamp: DateTime.UtcNow); + actionTimestamp: UtcNow); // Write-specific fields (AuditWriteUpdateEventType). AttributeId = Value; IndexRange empty (full write); // OldValue = the real pre-write value; NewValue = the value the client attempted. The AuditWriteUpdate @@ -5079,7 +5135,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { if (!AclEnforced || (_liveItems.IsEmpty && _eventItems.IsEmpty)) return; - var now = DateTime.UtcNow; + var now = UtcNow; var stoodDown = 0; var restored = 0; diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteDispatchTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteDispatchTests.cs new file mode 100644 index 00000000..404a8550 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerWriteDispatchTests.cs @@ -0,0 +1,160 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Review 01 L3 / H5 / H4 — the device-write dispatch behind +/// and the manager's clock: a gateway that throws synchronously is refused inline (the handler runs under +/// the SDK Lock, and the throw used to escape it), a faulted dispatch task is observed and still +/// reverts the optimistic value, and the timestamps the manager stamps come from the injected +/// . +/// +public sealed class NodeManagerWriteDispatchTests : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-dispatch-{Guid.NewGuid():N}"); + + /// H5 — a synchronous throw out of the gateway is a Bad result, never an escaped exception. + [Fact] + public async Task A_gateway_that_throws_synchronously_is_refused_inline() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + + var result = nm.DispatchDeviceWrite( + new ThrowingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns, + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); + + ServiceResult.IsBad(result).ShouldBeTrue(); + result.StatusCode.ShouldBe((StatusCode)StatusCodes.BadDeviceFailure); + result.ToString().ShouldContain("boom"); + nm.TryGetVariable("eq-1/sp")!.Value.ShouldBe(7, "nothing was applied, so nothing needs reverting"); + } + finally { await host.DisposeAsync(); } + } + + /// L3 — a faulted dispatch task is observed and the optimistic value is reverted with its reason. + [Fact] + public async Task A_faulted_dispatch_is_observed_and_reverts_the_optimistic_value() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + var node = nm.TryGetVariable("eq-1/sp")!; + + var result = nm.DispatchDeviceWrite( + new FaultingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns, + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); + ServiceResult.IsGood(result).ShouldBeTrue("the dispatch itself started; the SDK applies the value optimistically"); + node.Value = 42; // what the SDK does on Good + + var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5); + while (DateTime.UtcNow < deadline && !Equals(node.Value, 7)) await Task.Delay(20, Ct); + node.Value.ShouldBe(7, "the faulted dispatch must revert the optimistic value"); + node.StatusCode.ShouldBe((StatusCode)StatusCodes.Good); + } + finally { await host.DisposeAsync(); } + } + + /// Positive control for the two above: a gateway that succeeds leaves the optimistic value standing. + [Fact] + public async Task A_successful_dispatch_leaves_the_optimistic_value() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + var node = nm.TryGetVariable("eq-1/sp")!; + + nm.DispatchDeviceWrite( + new SucceedingGateway(), "ns=2;s=eq-1/sp", "eq-1/sp", AddressSpaceRealm.Uns, + optimisticValue: 42, priorValue: 7, priorStatus: StatusCodes.Good, clientUserId: "op"); + node.Value = 42; + + await Task.Delay(200, Ct); + node.Value.ShouldBe(42); + } + finally { await host.DisposeAsync(); } + } + + /// H4 — the revert stamps the manager's clock, not the wall clock. + [Fact] + public async Task The_revert_timestamps_come_from_the_injected_clock() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var fixedNow = new DateTime(2030, 1, 2, 3, 4, 5, DateTimeKind.Utc); + nm.Clock = new FixedClock(fixedNow); + nm.EnsureVariable("eq-1/sp", null, "Sp", "Int32", writable: true, realm: AddressSpaceRealm.Uns); + nm.WriteValue("eq-1/sp", 7, OpcUaStatus.Good, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), AddressSpaceRealm.Uns); + var node = nm.TryGetVariable("eq-1/sp")!; + node.Value = 42; + + nm.RevertOptimisticWriteIfNeeded( + "eq-1/sp", new NodeWriteOutcome(false, "rejected"), optimisticValue: 42, priorValue: 7, + priorStatus: StatusCodes.Good, clientUserId: "op", realm: AddressSpaceRealm.Uns); + + node.Timestamp.ShouldBe(fixedNow); + OtOpcUaNodeManager.RemainingShelveMs(fixedNow.AddSeconds(30), fixedNow).ShouldBe(30_000); + } + finally { await host.DisposeAsync(); } + } + + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() => + await OpcUaHostStart.StartAsync( + port => new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.DispatchTest", + ApplicationUri = $"urn:OtOpcUa.DispatchTest:{Guid.NewGuid():N}", + OpcUaPort = port, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } + + private sealed class ThrowingGateway : IOpcUaNodeWriteGateway + { + public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) => + throw new InvalidOperationException("boom"); + } + + private sealed class FaultingGateway : IOpcUaNodeWriteGateway + { + public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) => + Task.FromException(new TimeoutException("gateway ask timed out")); + } + + private sealed class SucceedingGateway : IOpcUaNodeWriteGateway + { + public Task WriteAsync(string nodeId, object? value, AddressSpaceRealm realm, CancellationToken ct) => + Task.FromResult(new NodeWriteOutcome(true, null)); + } + + private sealed class FixedClock(DateTime utcNow) : TimeProvider + { + public override DateTimeOffset GetUtcNow() => new(utcNow, TimeSpan.Zero); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostQuotaTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostQuotaTests.cs new file mode 100644 index 00000000..0809e916 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostQuotaTests.cs @@ -0,0 +1,74 @@ +using Opc.Ua; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// Review 01 P6 — the SDK request and transport quotas are configurable (OpcUa:Quotas:*) and +/// default to exactly what used to be hard-coded. +public sealed class OpcUaApplicationHostQuotaTests +{ + [Fact] + public void Default_request_quotas_are_the_former_hard_coded_values() + { + var config = new ServerConfiguration(); + OpcUaApplicationHost.ApplyRequestQuotas(config, new OpcUaQuotaOptions()); + + config.MinRequestThreadCount.ShouldBe(5); + config.MaxRequestThreadCount.ShouldBe(100); + config.MaxQueuedRequestCount.ShouldBe(200); + } + + [Fact] + public void Configured_request_quotas_are_applied() + { + var config = new ServerConfiguration(); + OpcUaApplicationHost.ApplyRequestQuotas(config, new OpcUaQuotaOptions + { + MinRequestThreadCount = 8, MaxRequestThreadCount = 250, MaxQueuedRequestCount = 1000, + }); + + config.MinRequestThreadCount.ShouldBe(8); + config.MaxRequestThreadCount.ShouldBe(250); + config.MaxQueuedRequestCount.ShouldBe(1000); + } + + [Fact] + public void Unset_transport_quotas_keep_the_sdk_defaults() + { + var sdkDefault = new TransportQuotas(); + var built = OpcUaApplicationHost.BuildTransportQuotas(new OpcUaQuotaOptions()); + + built.OperationTimeout.ShouldBe(sdkDefault.OperationTimeout); + built.MaxStringLength.ShouldBe(sdkDefault.MaxStringLength); + built.MaxByteStringLength.ShouldBe(sdkDefault.MaxByteStringLength); + built.MaxArrayLength.ShouldBe(sdkDefault.MaxArrayLength); + built.MaxMessageSize.ShouldBe(sdkDefault.MaxMessageSize); + built.MaxBufferSize.ShouldBe(sdkDefault.MaxBufferSize); + built.ChannelLifetime.ShouldBe(sdkDefault.ChannelLifetime); + built.SecurityTokenLifetime.ShouldBe(sdkDefault.SecurityTokenLifetime); + } + + [Fact] + public void Configured_transport_quotas_replace_only_what_is_set() + { + var sdkDefault = new TransportQuotas(); + var built = OpcUaApplicationHost.BuildTransportQuotas(new OpcUaQuotaOptions + { + MaxMessageSize = 8 * 1024 * 1024, OperationTimeoutMs = 45_000, MaxArrayLength = 1_000_000, + }); + + built.MaxMessageSize.ShouldBe(8 * 1024 * 1024); + built.OperationTimeout.ShouldBe(45_000); + built.MaxArrayLength.ShouldBe(1_000_000); + built.MaxStringLength.ShouldBe(sdkDefault.MaxStringLength); + built.ChannelLifetime.ShouldBe(sdkDefault.ChannelLifetime); + } + + [Fact] + public void History_read_batch_cap_default_scales_with_the_machine() + { + OtOpcUaNodeManager.DefaultMaxConcurrentHistoryReadBatches + .ShouldBe(Math.Clamp(Environment.ProcessorCount * 2, 4, 16)); + } +} From 118933d5d515b356aa79cd39f141c6412b9e6831 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:29:54 -0400 Subject: [PATCH 05/67] refactor(opcua): delete the dead v3 equipment-tag materialisation (B5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 01 U2. The composer never emits EquipmentTags in v3 (device values live in the Raw subtree and reach equipment as UnsReferenceVariables), so the equipment-tag plan diff sets (Added/Removed/ChangedEquipmentTags, EquipmentTagDelta), the planner's diff over them, the classifier's TagDeltaIsSurgicalEligible and its Rebuild clause, the applier's MaterialiseEquipmentTags pass, surgical ChangedEquipmentTags loop, removed-tag teardown loop and add-announcement term, and the publish actor's equipment-tag pass were unreachable, maintained dead weight. IsEmpty keeps its semantics (an otherwise-empty plan is still Empty). Kept, deliberately: AddressSpaceComposition.EquipmentTags and the EquipmentTagPlan record — Runtime's DeploymentArtifact (outside this lane) still assigns the empty set and the artifact parity tests assert it; follow-up F2 removes both together. Tests: the equipment-tag vehicle tests for PureRemove / AddRemoveMix / the rebuild ratchet are re-expressed over raw tags (same applier paths); the equipment-tag surgical and classifier tests are deleted (their raw / UNS-reference equivalents already exist); SubscriptionSurvivalTests now materialises its tags through MaterialiseRawSubtree. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AddressSpaceApplier.cs | 154 +--- .../AddressSpaceChangeClassifier.cs | 42 +- .../AddressSpacePlan.cs | 37 +- .../OpcUa/OpcUaPublishActor.cs | 20 +- .../SubscriptionSurvivalTests.cs | 87 +- .../AddressSpaceApplierFailureSurfaceTests.cs | 26 +- .../AddressSpaceApplierHierarchyTests.cs | 15 +- .../AddressSpaceApplierTests.cs | 781 ++---------------- .../AddressSpaceChangeClassifierTests.cs | 75 +- .../AddressSpacePlannerTests.cs | 61 -- 10 files changed, 159 insertions(+), 1139 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index ce85a7ff..9d42e409 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -105,23 +105,21 @@ public sealed class AddressSpaceApplier if (!SafeWriteAlarmCondition(alarm.ScriptedAlarmId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++; removedCount++; } - // Removed equipment tags / VirtualTags are plain variable nodes (no Part 9 condition to write - // before tear-down), but they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes - // is accurate on a removed-tag-only deploy, which now reaches the rebuild path below. v3 Batch 4: - // removed raw containers/tags + UNS reference variables are likewise real removals. - removedCount += plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count + + // Removed VirtualTags are plain variable nodes (no Part 9 condition to write before tear-down), but + // they ARE real removals — count them so AddressSpaceApplyOutcome.RemovedNodes is accurate on a + // removed-vtag-only deploy. v3 Batch 4: removed raw containers/tags + UNS reference variables are + // likewise real removals. + removedCount += plan.RemovedEquipmentVirtualTags.Count + plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count; var changedCount = plan.ChangedEquipment.Count + plan.ChangedDrivers.Count + plan.ChangedAlarms.Count + - plan.ChangedEquipmentTags.Count + plan.ChangedEquipmentVirtualTags.Count + plan.ChangedRawContainers.Count + plan.ChangedRawTags.Count + plan.ChangedUnsReferenceVariables.Count + // A UNS Area/Line rename is an in-place change to an existing folder node. plan.RenamedFolders.Count; var addedCount = plan.AddedEquipment.Count + plan.AddedDrivers.Count + plan.AddedAlarms.Count + - plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count + plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count; @@ -143,7 +141,7 @@ public sealed class AddressSpaceApplier // order even when an id is reused across the remove + add sets (the recreated node is a fresh // NodeState, correct — it is a different tag). Any remove false/throw ⇒ full-rebuild fallback. // • Rebuild ⇒ the default-closed safety valve for any node-affecting change (ChangedEquipment / - // ChangedAlarms / non-surgical ChangedEquipmentTags / node-relevant ChangedEquipmentVirtualTags) + // ChangedAlarms / node-relevant ChangedEquipmentVirtualTags / non-surgical raw-tag deltas) // — the classifier's rule 2, which also catches any future plan field that makes a changed // record unequal, so the worst outcome of a misclassification is today's full rebuild. // ChangedDrivers is node-inert (routes through DriverHostActor's spawn plan) — the classifier leaves @@ -154,7 +152,6 @@ public sealed class AddressSpaceApplier // afterward by the publish actor's Materialise passes + announce). var hasRemovePass = kind is AddressSpaceChangeKind.PureRemove or AddressSpaceChangeKind.AddRemoveMix; - var surgicalTagDeltas = plan.ChangedEquipmentTags.Where(AddressSpaceChangeClassifier.TagDeltaIsSurgicalEligible).ToList(); // UNS Area / Line renames are surgically applicable (in-place DisplayName swap) // when no structural rebuild fires. When a rebuild DOES fire (any add/remove/structural change), // MaterialiseHierarchy re-creates every folder with the new display names, so the renames are @@ -174,7 +171,7 @@ public sealed class AddressSpaceApplier rebuildFailed = !SafeRebuild(); rebuilt = true; } - else if (surgicalTagDeltas.Count > 0 || renamedFolders.Count > 0 || hasRemovePass || + else if (renamedFolders.Count > 0 || hasRemovePass || surgicalRawTagDeltas.Count > 0 || unsRefDeltas.Count > 0) { if (_sink is ISurgicalAddressSpaceSink surgical) @@ -192,25 +189,6 @@ public sealed class AddressSpaceApplier } if (!ok) { allApplied = false; break; } } - foreach (var d in allApplied ? surgicalTagDeltas : Enumerable.Empty()) - { - // Compute the node id + writable + historian + shape EXACTLY as MaterialiseEquipmentTags - // would so the in-place update matches what a rebuild would have produced. Array tags are - // forced read-only (same as EnsureVariable: the driver write path doesn't handle arrays). - var nodeId = UnsEquipmentVar(d.Current.EquipmentId, d.Current.FolderPath, d.Current.Name); - var writable = d.Current.Writable && !d.Current.IsArray; - var historian = d.Current.IsHistorized - ? (string.IsNullOrWhiteSpace(d.Current.HistorianTagname) ? d.Current.FullName : d.Current.HistorianTagname) - : null; - bool ok; - try { ok = surgical.UpdateTagAttributes(nodeId, writable, historian, d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength, AddressSpaceRealm.Uns); } - catch (Exception ex) - { - _logger.LogError(ex, "AddressSpaceApplier: surgical UpdateTagAttributes threw for {Node}", nodeId); - ok = false; - } - if (!ok) { allApplied = false; break; } - } // v3 RAW tags — the same in-place attribute update, in the RAW realm. Adding or dropping ONE // UnsTagReference flips its backing raw tag to Changed purely through ReferencingEquipmentPaths, // and before this path existed that tore down every node on the server for an edit that creates @@ -318,8 +296,8 @@ public sealed class AddressSpaceApplier } _logger.LogInformation( - "AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalTags={Surgical}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})", - kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalTagDeltas.Count, + "AddressSpaceApplier: applied plan (kind={Kind}, added={Added}, removed={Removed}, changed={Changed}, surgicalRawTags={SurgicalRaw}, unsRefDeltas={UnsRefs}, renamedFolders={Renamed}, rebuild={Rebuild})", + kind, addedCount, removedCount, changedCount, rebuilt ? 0 : surgicalRawTagDeltas.Count, rebuilt ? 0 : unsRefDeltas.Count, rebuilt ? 0 : renamedFolders.Count, rebuilt); @@ -340,10 +318,9 @@ public sealed class AddressSpaceApplier /// Apply a PureRemove plan by tearing down ONLY the affected nodes IN PLACE (no full /// rebuild). Removed equipment "own" their child tags/vtags/alarms, so any removed child whose /// EquipmentId is itself in is SUBSUMED by the - /// subtree removal and skipped individually. For surviving-equipment removals: a removed value + /// subtree removal and skipped individually. For surviving-equipment removals: a removed VirtualTag /// variable gets a terminal Bad WriteValue then ; - /// a removed alarm-bearing tag (previously it got no terminal condition write) and a - /// removed scripted alarm get a terminal write then + /// a removed scripted alarm gets a terminal write then /// ; each removed equipment gets one /// . The terminal write is what an /// in-flight MonitoredItem observes (a final Bad); after the node is gone, re-subscription gets @@ -361,25 +338,6 @@ public sealed class AddressSpaceApplier plan.RemovedEquipment.Select(e => e.EquipmentId), StringComparer.Ordinal); bool NotSubsumed(string equipmentId) => !removedEquipmentIds.Contains(equipmentId); - // Removed equipment tags — value variables OR alarm-bearing conditions — for SURVIVING equipment. - foreach (var tag in plan.RemovedEquipmentTags.Where(t => NotSubsumed(t.EquipmentId))) - { - var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name); - if (tag.Alarm is not null) - { - // Alarm-bearing tag → a Part 9 condition node. Terminal RemovedConditionState (today-gap - // closed), then remove the condition in place. - if (!SafeWriteAlarmCondition(nodeId, RemovedConditionState, ts, AddressSpaceRealm.Uns)) failedNodes++; - if (!SafeRemoveAlarmCondition(surgical, nodeId, AddressSpaceRealm.Uns)) return false; - } - else - { - // Plain value variable → terminal Bad so an in-flight MonitoredItem sees the removal. - SafeWriteValue(nodeId, null, OpcUaStatus.Bad, ts, AddressSpaceRealm.Uns); - if (!SafeRemoveVariable(surgical, nodeId, AddressSpaceRealm.Uns)) return false; - } - } - // Removed VirtualTags (always plain value variables) for surviving equipment. foreach (var v in plan.RemovedEquipmentVirtualTags.Where(t => NotSubsumed(t.EquipmentId))) { @@ -548,7 +506,6 @@ public sealed class AddressSpaceApplier ArgumentNullException.ThrowIfNull(plan); var ids = new HashSet(StringComparer.Ordinal); - foreach (var t in plan.AddedEquipmentTags) ids.Add(MaterialiseParent(t.EquipmentId, t.FolderPath)); foreach (var v in plan.AddedEquipmentVirtualTags) ids.Add(MaterialiseParent(v.EquipmentId, v.FolderPath)); foreach (var a in plan.AddedAlarms) ids.Add(a.EquipmentId); // v3 Batch 4 / review I5 — an added UNS reference variable is materialised directly under its @@ -1072,96 +1029,9 @@ public sealed class AddressSpaceApplier return failed; } - /// - /// Materialise Equipment-namespace tags from a composition snapshot. - /// For each , - /// ensure its optional FolderPath sub-folder under the existing equipment folder, then - /// ensure a Variable (NodeId = FullName, the driver-side ref) inside it. Variables - /// start BadWaitingForInitialData; the driver fills live values in a later milestone. - /// Idempotent. This is a sink-based pass, NOT a reuse of EquipmentNodeWalker: no - /// sink-backed IAddressSpaceBuilder adapter exists, and the walker re-creates the - /// whole Area/Line/Equipment tree with browse-path NodeIds, incompatible with this path's - /// logical-Id NodeIds and the already-materialised equipment folders. This pass adds ONLY - /// variables (and any per-tag sub-folder); owns the - /// equipment folders and this pass never re-creates them. The sink's EnsureVariable - /// takes a plain string dataType (not a DriverAttributeInfo). - /// Subset overload (2026-08-15 surgical-deploys). narrows WHAT is - /// materialised to a plan's added tags; stays the FULL composition for - /// context. A null subset means "the whole composition" (the rebuild path). - /// - /// The FULL composition — the context even when a subset is passed. - /// Materialise only these tags; null ⇒ the whole composition's. - /// The count of swallowed sink failures in this pass (0 on a clean apply) — the publish actor - /// sums it into the degraded-apply surface (archreview 01/S-1). - public int MaterialiseEquipmentTags( - AddressSpaceComposition composition, - IReadOnlyList? only = null) - { - ArgumentNullException.ThrowIfNull(composition); - var equipmentTags = only ?? composition.EquipmentTags; - if (equipmentTags.Count == 0) return 0; - - var failed = 0; - // Sub-folders first — a tag's FolderPath becomes one folder UNDER its equipment folder - // (deduped per distinct equipment+path). Tags with no FolderPath hang directly under the - // equipment folder, which MaterialiseHierarchy already created (never re-create - // the equipment folder here). - var foldersCreated = new HashSet(StringComparer.Ordinal); - foreach (var tag in equipmentTags) - { - if (string.IsNullOrWhiteSpace(tag.FolderPath)) continue; - var folderNodeId = UnsSubFolder(tag.EquipmentId, tag.FolderPath); - if (!foldersCreated.Add(folderNodeId)) continue; - if (!SafeEnsureFolder(folderNodeId, parentNodeId: tag.EquipmentId, displayName: tag.FolderPath, realm: AddressSpaceRealm.Uns)) failed++; - } - - // Variables: NodeId is FOLDER-SCOPED ("/"), NOT the raw FullName — a driver - // ref (e.g. a Modbus register) is not unique across identical machines, so FullName-as-NodeId - // would collide in the sink (EnsureVariable keys on NodeId) and drop all but one machine's - // signal. The driver-side FullName lives on EquipmentTagPlan for the later values milestone to - // route by. Parent is the FolderPath sub-folder when set, else the equipment folder directly. - // Per-variable idempotency relies on the sink's own EnsureVariable. - foreach (var tag in equipmentTags) - { - var parent = string.IsNullOrWhiteSpace(tag.FolderPath) - ? tag.EquipmentId - : UnsSubFolder(tag.EquipmentId, tag.FolderPath); - var nodeId = UnsEquipmentVar(tag.EquipmentId, tag.FolderPath, tag.Name); - if (tag.Alarm is not null) - { - // Native alarm tag → a real Part 9 condition node (reuses the scripted-alarm path), - // NOT a value variable. Parent is the sub-folder when set, else the equipment folder. - if (!SafeMaterialiseAlarmCondition(nodeId, parent, tag.Name, tag.Alarm.AlarmType, tag.Alarm.Severity, isNative: true, realm: AddressSpaceRealm.Uns)) failed++; - } - else - { - // Phase C: a historized tag materialises Historizing + HistoryRead. Resolve the effective - // historian tagname HERE (default-vs-override): a null/blank override falls back to the - // driver-side FullName; null means the tag is not historized at all. - string? historianTagname = tag.IsHistorized - ? (string.IsNullOrWhiteSpace(tag.HistorianTagname) ? tag.FullName : tag.HistorianTagname) - : null; - // Retired equipment-tag path (structurally empty in v3): array tags stay forced read-only - // here. The live array-write capability is carried on RawTagPlan / - // UnsReferenceVariable only — see RawNodeWritable. - var writable = tag.Writable && !tag.IsArray; - if (!SafeEnsureVariable(nodeId, parent, tag.Name, tag.DataType, writable, AddressSpaceRealm.Uns, historianTagname, tag.IsArray, tag.ArrayLength)) failed++; - } - } - - _logger.LogInformation( - "AddressSpaceApplier: equipment tags materialised (tags={Tags}, equipment={Equipment}, subset={Subset}, failed={Failed})", - equipmentTags.Count, - equipmentTags.Select(t => t.EquipmentId).Distinct(StringComparer.Ordinal).Count(), - only is not null, - failed); - return failed; - } - - /// /// Materialise Equipment-namespace VirtualTags from a composition snapshot — the VirtualTag - /// analogue of . For each , + /// computed-signal pass. For each , /// ensure its optional FolderPath sub-folder under the existing equipment folder (in /// practice FolderPath is empty for VirtualTags, so this is usually a no-op), then ensure /// a Variable inside it. Like the tag pass, the variable's NodeId is FOLDER-SCOPED diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs index a64f3055..8d3b97d0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceChangeClassifier.cs @@ -8,7 +8,7 @@ public enum AddressSpaceChangeKind /// — nothing to do. Empty, - /// Only surgical-eligible changed equipment/raw tags, surgical UNS-reference deltas, + /// Only surgical-eligible changed raw tags, surgical UNS-reference deltas, /// node-irrelevant vtag changes, folder renames, and/or node-inert driver deltas — the existing F10b /// in-place path. No adds, no removes. AttributeOnly, @@ -27,7 +27,7 @@ public enum AddressSpaceChangeKind AddRemoveMix, /// Anything with a node-affecting change (ChangedEquipment, ChangedAlarms, ChangedRawContainers, - /// non-surgical ChangedEquipmentTags, alarm-bearing/identity-changing ChangedRawTags, node-relevant + /// alarm-bearing/identity-changing ChangedRawTags, node-relevant /// ChangedEquipmentVirtualTags) — the unclassifiable-safe default. Full rebuild. Rebuild, } @@ -53,8 +53,7 @@ public static class AddressSpaceChangeClassifier if (plan.IsEmpty) return AddressSpaceChangeKind.Empty; // 2 — any node-affecting CHANGE forces a full rebuild (default-closed). ChangedEquipment / - // ChangedAlarms / ChangedRawContainers are always node-affecting; a changed equipment tag is - // node-affecting UNLESS it is surgical-eligible, a changed raw tag UNLESS it is + // ChangedAlarms / ChangedRawContainers are always node-affecting; a changed raw tag UNLESS it is // RawTagDeltaIsSurgicalEligible, and a changed vtag UNLESS it is node-irrelevant. Evaluated BEFORE the // add/remove split so a non-surgical change alongside pure adds still rebuilds. // @@ -94,7 +93,6 @@ public static class AddressSpaceChangeClassifier plan.ChangedAlarms.Count > 0 || plan.ChangedRawContainers.Count > 0 || plan.ChangedRawTags.Any(d => !RawTagDeltaIsSurgicalEligible(d)) || - plan.ChangedEquipmentTags.Any(d => !TagDeltaIsSurgicalEligible(d)) || plan.ChangedEquipmentVirtualTags.Any(d => !VtagDeltaIsNodeIrrelevant(d))) { return AddressSpaceChangeKind.Rebuild; @@ -105,12 +103,12 @@ public static class AddressSpaceChangeClassifier // add/remove split below and leave a driver-only plan classified AttributeOnly. var hasAdds = plan.AddedEquipment.Count + plan.AddedAlarms.Count + - plan.AddedEquipmentTags.Count + plan.AddedEquipmentVirtualTags.Count + + plan.AddedEquipmentVirtualTags.Count + plan.AddedRawContainers.Count + plan.AddedRawTags.Count + plan.AddedUnsReferenceVariables.Count > 0; var hasRemoves = plan.RemovedEquipment.Count + plan.RemovedAlarms.Count + - plan.RemovedEquipmentTags.Count + plan.RemovedEquipmentVirtualTags.Count + + plan.RemovedEquipmentVirtualTags.Count + plan.RemovedRawContainers.Count + plan.RemovedRawTags.Count + plan.RemovedUnsReferenceVariables.Count > 0; @@ -141,36 +139,6 @@ public static class AddressSpaceChangeClassifier Historize = d.Current.Historize, }).Equals(d.Current); - // F10b: a CHANGED equipment tag whose ONLY differences are Writable / IsHistorized / - // HistorianTagname / DataType / IsArray / ArrayLength (a plain value variable — no alarm condition node) - // can be updated IN PLACE on the existing node via ISurgicalAddressSpaceSink.UpdateTagAttributes, - // avoiding a full rebuild (preserving subscriptions). The presentation-shape fields (DataType / IsArray / - // ArrayLength) join the whitelist now that the surgical sink swaps DataType + ValueRank + ArrayDimensions - // in place (and raises a GeneralModelChangeEvent). FullName / DriverInstanceId / Name / identity / alarm - // differences still fall through to a rebuild — FullName/DriverInstanceId re-route the node to a different - // driver point, Name re-derives the NodeId, and an alarm flip turns the node into a Part 9 condition. The - // override-unequal default also covers any future field. - // REACH (live-verified): the shape path only fires for drivers whose TagConfig carries a stable - // top-level "FullName" (Galaxy = tag_name.AttributeName; OpcUaClient = the node id) — there a DataType/array - // edit leaves FullName untouched ⇒ surgical. For structured-TagConfig protocol drivers (Modbus/S7/AbCip/…) - // TagConfigIntent.Parse falls back to the RAW TagConfig blob as FullName, so a DataType/ - // array edit also mutates that blob ⇒ FullName differs ⇒ this returns false ⇒ full rebuild. That is the - // correct safe default (a protocol driver's subscription needs re-spawning for a new shape anyway). - /// Whether a changed equipment tag can be updated in place on its existing node instead of rebuilding. - /// The equipment-tag delta to classify. - /// when the tag has no alarm and differs only in the in-place-updatable attributes; otherwise . - internal static bool TagDeltaIsSurgicalEligible(AddressSpacePlan.EquipmentTagDelta d) => - d.Previous.Alarm is null && d.Current.Alarm is null && - (d.Previous with - { - Writable = d.Current.Writable, - IsHistorized = d.Current.IsHistorized, - HistorianTagname = d.Current.HistorianTagname, - DataType = d.Current.DataType, - IsArray = d.Current.IsArray, - ArrayLength = d.Current.ArrayLength, - }).Equals(d.Current); - // v3 surgical raw-tag eligibility: a NON-alarm raw tag whose only differences are the in-place-updatable // attributes (Writable / IsHistorized / HistorianTagname / DataType / IsArray / ArrayLength / ArrayWritable) // and/or its ReferencingEquipmentPaths. ArrayWritable only feeds the node's writability — the diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs index 8b6cdd26..466a4a37 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpacePlan.cs @@ -23,28 +23,15 @@ public sealed record AddressSpacePlan( IReadOnlyList RemovedAlarms, IReadOnlyList ChangedAlarms) { - /// - /// Equipment-namespace tag diff sets, keyed by . Added as - /// init-only members (defaulting empty) rather than positional parameters so existing - /// AddressSpacePlan construction sites compile unchanged — consistent with how - /// was added. Without these, an - /// incremental deploy that changes ONLY equipment tags produced an empty plan and - /// OpcUaPublishActor.HandleRebuild short-circuited before materialising them. - /// - public IReadOnlyList AddedEquipmentTags { get; init; } = Array.Empty(); - /// - public IReadOnlyList RemovedEquipmentTags { get; init; } = Array.Empty(); - /// - public IReadOnlyList ChangedEquipmentTags { get; init; } = Array.Empty(); - /// /// Equipment-namespace VirtualTag diff sets, keyed by . - /// The value-side analogue of : a VirtualTag carries an + /// A VirtualTag carries an /// Expression evaluated over DependencyRefs, so a deploy that changes ONLY /// VirtualTags (e.g. a new computed signal or an edited formula) must still produce a /// non-empty plan and drive a rebuild — without these the diff was blind to VirtualTags and - /// such a deploy silently no-op'd. Added as init-only members (defaulting empty) for the same - /// compile-compatibility reason as . + /// such a deploy silently no-op'd. Added as init-only members (defaulting empty) so existing + /// construction sites compile unchanged. (The retired v3 equipment-tag diff sets — review 01 U2 — are gone: + /// device values live in the Raw subtree and reach equipment as s.) /// public IReadOnlyList AddedEquipmentVirtualTags { get; init; } = Array.Empty(); /// @@ -65,7 +52,7 @@ public sealed record AddressSpacePlan( /// uses), preserving every client's subscriptions; a sink lacking the surgical capability or a /// missing folder falls back to a full rebuild. Added as an init-only member (defaulting empty) so /// every existing AddressSpacePlan construction site compiles unchanged — consistent with - /// the EquipmentTag / EquipmentVirtualTag diff sets above. + /// the EquipmentVirtualTag diff sets above. /// public IReadOnlyList RenamedFolders { get; init; } = Array.Empty(); @@ -74,7 +61,7 @@ public sealed record AddressSpacePlan( /// keyed by (the RawPath). A rename of any container changes /// its RawPath (and every descendant's), so it manifests as remove(old)+add(new) — an OPC UA NodeId /// is immutable identity. Init-only, defaulting empty, so existing construction sites compile - /// unchanged (consistent with the EquipmentTag diff sets above). + /// unchanged. /// public IReadOnlyList AddedRawContainers { get; init; } = Array.Empty(); /// @@ -114,7 +101,6 @@ public sealed record AddressSpacePlan( AddedEquipment.Count == 0 && RemovedEquipment.Count == 0 && ChangedEquipment.Count == 0 && AddedDrivers.Count == 0 && RemovedDrivers.Count == 0 && ChangedDrivers.Count == 0 && AddedAlarms.Count == 0 && RemovedAlarms.Count == 0 && ChangedAlarms.Count == 0 && - AddedEquipmentTags.Count == 0 && RemovedEquipmentTags.Count == 0 && ChangedEquipmentTags.Count == 0 && AddedEquipmentVirtualTags.Count == 0 && RemovedEquipmentVirtualTags.Count == 0 && ChangedEquipmentVirtualTags.Count == 0 && AddedRawContainers.Count == 0 && RemovedRawContainers.Count == 0 && ChangedRawContainers.Count == 0 && AddedRawTags.Count == 0 && RemovedRawTags.Count == 0 && ChangedRawTags.Count == 0 && @@ -124,7 +110,6 @@ public sealed record AddressSpacePlan( public sealed record EquipmentDelta(EquipmentNode Previous, EquipmentNode Current); public sealed record DriverDelta(DriverInstancePlan Previous, DriverInstancePlan Current); public sealed record AlarmDelta(ScriptedAlarmPlan Previous, ScriptedAlarmPlan Current); - public sealed record EquipmentTagDelta(EquipmentTagPlan Previous, EquipmentTagPlan Current); public sealed record EquipmentVirtualTagDelta(EquipmentVirtualTagPlan Previous, EquipmentVirtualTagPlan Current); /// A Raw container node present in both snapshots (same RawPath) with ≥1 differing field. @@ -177,12 +162,7 @@ public static class AddressSpacePlanner a => a.ScriptedAlarmId, (a, b) => new AddressSpacePlan.AlarmDelta(a, b)); - var (addedEqTags, removedEqTags, changedEqTags) = DiffById( - previous.EquipmentTags, next.EquipmentTags, - t => t.TagId, - (a, b) => new AddressSpacePlan.EquipmentTagDelta(a, b)); - - // VirtualTags diff by VirtualTagId, mirroring the EquipmentTags pass. EquipmentVirtualTagPlan + // VirtualTags diff by VirtualTagId. EquipmentVirtualTagPlan // overrides record equality to compare ALL fields by value — scalars (Expression/DataType/ // Name/FolderPath) plus DependencyRefs element-wise (SequenceEqual). So a no-op redeploy (fresh // list instances, identical contents) correctly diffs to empty; only a real content change is @@ -232,9 +212,6 @@ public static class AddressSpacePlanner addedDrv, removedDrv, changedDrv, addedAlarm, removedAlarm, changedAlarm) { - AddedEquipmentTags = addedEqTags, - RemovedEquipmentTags = removedEqTags, - ChangedEquipmentTags = changedEqTags, AddedEquipmentVirtualTags = addedVTags, RemovedEquipmentVirtualTags = removedVTags, ChangedEquipmentVirtualTags = changedVTags, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 68c23d4d..207585c9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -725,8 +725,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } /// - /// Run the five node-materialising applier passes — Raw subtree, UNS references, scripted alarms, - /// equipment tags, equipment VirtualTags — either over the FULL composition (after a rebuild) or over + /// Run the four node-materialising applier passes — Raw subtree, UNS references, scripted alarms, + /// equipment VirtualTags — either over the FULL composition (after a rebuild) or over /// just this plan's delta (surgical deploys). MaterialiseHierarchy is NOT one of them: /// the caller always runs it in full, because parents must exist before any add. /// @@ -809,21 +809,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers if (addedAlarms.Count > 0) failedNodes += applier.MaterialiseScriptedAlarms(composition, addedAlarms); } - // Equipment-namespace tags get their own pass: ensures each signal's Variable (and any - // FolderPath sub-folder) exists under its already-materialised equipment folder so - // clients can browse them. Live values are pushed by DriverHostActor.ForwardBatchToMux after - // each subscription cycle; variables show BadWaitingForInitialData only until the first - // publish interval fires. - if (fullComposition) - { - failedNodes += applier.MaterialiseEquipmentTags(composition); - } - else if (plan.AddedEquipmentTags.Count > 0) - { - failedNodes += applier.MaterialiseEquipmentTags(composition, plan.AddedEquipmentTags); - } - - // Equipment-namespace VirtualTags get their own pass right after the equipment tags: + // Equipment-namespace VirtualTags get their own pass right after the scripted alarms: // ensures each computed signal's Variable (and any FolderPath sub-folder) exists under its // equipment folder with a folder-scoped NodeId. VirtualTagHostActor.OnResult pushes live // values once the first dependency update arrives; until then variables show BadWaitingForInitialData. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs index b8701f69..31b597ef 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/SubscriptionSurvivalTests.cs @@ -26,7 +26,7 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests; /// announce (T4b) land. /// Run note: this is a heavy in-process server+client integration test — run it in the /// serial integration pass (it is NOT part of the lightweight unit sweep). The -/// namespace URI is ; the client resolves the +/// namespace URI is ; the client resolves the /// folder-scoped variable NodeIds against it. /// public sealed class SubscriptionSurvivalTests @@ -68,15 +68,15 @@ public sealed class SubscriptionSurvivalTests // --- Materialise tag A (equipment eq-1, tag "A") and seed it a Good value. --- var withA = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null)); + RawTag("tag-a", "A")); applier.MaterialiseHierarchy(withA); - applier.MaterialiseEquipmentTags(withA); - var nodeIdAString = V3NodeIds.Uns("eq-1", "A"); - sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + applier.MaterialiseRawSubtree(withA); + var nodeIdAString = "Plant/dev1/A"; + sink.WriteValue(nodeIdAString, 41, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); // --- Open a client session + subscription + monitored item on A. --- using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); - var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri); + var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); ns.ShouldBeGreaterThan((ushort)0); var nodeIdA = new NodeId(nodeIdAString, ns); @@ -106,22 +106,22 @@ public sealed class SubscriptionSurvivalTests // --- Drive a PURE-ADD apply adding tag B (previous={A}, next={A,B}). --- var withAB = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null)); + RawTag("tag-a", "A"), + RawTag("tag-b", "B")); var plan = AddressSpacePlanner.Compute(withA, withAB); - plan.AddedEquipmentTags.Count.ShouldBe(1); + plan.AddedRawTags.Count.ShouldBe(1); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); // PureAdd — no teardown // Materialise passes (as the publish actor runs them) then announce — over the FULL composition. applier.MaterialiseHierarchy(withAB); - applier.MaterialiseEquipmentTags(withAB); + applier.MaterialiseRawSubtree(withAB); applier.AnnounceAddedNodes(plan); // --- A's monitored item must still be alive: a new write to A is delivered. --- lock (gate) received.Clear(); - sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue(nodeIdAString, 42, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => { lock (gate) return received.Any(v => Equals(v.Value, 42)); }, TimeSpan.FromSeconds(5)); lock (gate) @@ -131,8 +131,8 @@ public sealed class SubscriptionSurvivalTests } // --- B is browsable + readable (the add landed). --- - var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns); - sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + var nodeIdB = new NodeId("Plant/dev1/B", ns); + sink.WriteValue(nodeIdB.Identifier.ToString()!, 7, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); var bValue = await session.ReadValueAsync(nodeIdB, ct); bValue.StatusCode.ShouldNotBe(StatusCodes.BadNodeIdUnknown); bValue.Value.ShouldBe(7); @@ -180,17 +180,17 @@ public sealed class SubscriptionSurvivalTests // Materialise A + B and seed both Good. var withAB = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null)); + RawTag("tag-a", "A"), + RawTag("tag-b", "B")); applier.MaterialiseHierarchy(withAB); - applier.MaterialiseEquipmentTags(withAB); - var nodeIdAString = V3NodeIds.Uns("eq-1", "A"); - var nodeIdBString = V3NodeIds.Uns("eq-1", "B"); - sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); - sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + applier.MaterialiseRawSubtree(withAB); + var nodeIdAString = "Plant/dev1/A"; + var nodeIdBString = "Plant/dev1/B"; + sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); + sink.WriteValue(nodeIdBString, 20, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); - var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri); + var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); var nodeIdA = new NodeId(nodeIdAString, ns); var nodeIdB = new NodeId(nodeIdBString, ns); @@ -211,9 +211,9 @@ public sealed class SubscriptionSurvivalTests // Drive a PURE-REMOVE apply removing B (previous={A,B}, next={A}). var withA = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null)); + RawTag("tag-a", "A")); var plan = AddressSpacePlanner.Compute(withAB, withA); - plan.RemovedEquipmentTags.Count.ShouldBe(1); + plan.RemovedRawTags.Count.ShouldBe(1); lock (gate) { receivedA.Clear(); receivedB.Clear(); } var outcome = applier.Apply(plan); @@ -223,7 +223,7 @@ public sealed class SubscriptionSurvivalTests await WaitUntilAsync(() => { lock (gate) return receivedB.Any(v => StatusCode.IsBad(v.StatusCode)); }, TimeSpan.FromSeconds(5)); // A stays alive: a fresh write to A is delivered. - sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5)); // B is gone: a re-read surfaces BadNodeIdUnknown. The 1.5.378 node-cache read path @@ -273,15 +273,15 @@ public sealed class SubscriptionSurvivalTests var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var withAB = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-b", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Int32", FullName: "2", Writable: false, Alarm: null)); + RawTag("tag-a", "A"), + RawTag("tag-b", "B")); applier.MaterialiseHierarchy(withAB); - applier.MaterialiseEquipmentTags(withAB); - var nodeIdAString = V3NodeIds.Uns("eq-1", "A"); - sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + applier.MaterialiseRawSubtree(withAB); + var nodeIdAString = "Plant/dev1/A"; + sink.WriteValue(nodeIdAString, 10, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); using var session = await OpenSessionAsync($"opc.tcp://127.0.0.1:{port}/OtOpcUa", ct); - var ns = (ushort)session.NamespaceUris.GetIndex(OtOpcUaNodeManager.DefaultNamespaceUri); + var ns = (ushort)session.NamespaceUris.GetIndex(V3NodeIds.RawNamespaceUri); var nodeIdA = new NodeId(nodeIdAString, ns); var receivedA = new List(); @@ -297,32 +297,32 @@ public sealed class SubscriptionSurvivalTests // MIXED apply: remove B, add C (previous={A,B}, next={A,C}). var withAC = CompositionWith( - new EquipmentTagPlan("tag-a", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Int32", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-c", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Int32", FullName: "3", Writable: false, Alarm: null)); + RawTag("tag-a", "A"), + RawTag("tag-c", "C")); var plan = AddressSpacePlanner.Compute(withAB, withAC); - plan.RemovedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.Count.ShouldBe(1); + plan.RemovedRawTags.Count.ShouldBe(1); + plan.AddedRawTags.Count.ShouldBe(1); lock (gate) receivedA.Clear(); var outcome = applier.Apply(plan); outcome.RebuildCalled.ShouldBeFalse(); // AddRemoveMix — removes in place, no rebuild // Publish-actor sequence: removes ran inside Apply; now materialise the adds + announce. - applier.MaterialiseEquipmentTags(withAC); + applier.MaterialiseRawSubtree(withAC); applier.AnnounceAddedNodes(plan); // A stays alive. - sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + sink.WriteValue(nodeIdAString, 11, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => { lock (gate) return receivedA.Any(v => Equals(v.Value, 11)); }, TimeSpan.FromSeconds(5)); // B is gone. (1.5.378 node-cache read throws for an unknown node — see above.) - var nodeIdB = new NodeId(V3NodeIds.Uns("eq-1", "B"), ns); + var nodeIdB = new NodeId("Plant/dev1/B", ns); var bReadEx = await Should.ThrowAsync( async () => await session.ReadValueAsync(nodeIdB, ct)); bReadEx.StatusCode.ShouldBe(StatusCodes.BadNodeIdUnknown); // C is browsable + readable. - var nodeIdC = new NodeId(V3NodeIds.Uns("eq-1", "C"), ns); - sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Uns); + var nodeIdC = new NodeId("Plant/dev1/C", ns); + sink.WriteValue(nodeIdC.Identifier.ToString()!, 9, Commons.OpcUa.OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); var cRead = await session.ReadValueAsync(nodeIdC, ct); cRead.StatusCode.ShouldNotBe((StatusCode)StatusCodes.BadNodeIdUnknown); cRead.Value.ShouldBe(9); @@ -333,7 +333,11 @@ public sealed class SubscriptionSurvivalTests } } - private static AddressSpaceComposition CompositionWith(params EquipmentTagPlan[] tags) => + private static RawTagPlan RawTag(string tagId, string name) => + new(tagId, $"Plant/dev1/{name}", ParentNodeId: "Plant/dev1", "drv", Name: name, DataType: "Int32", Writable: false, + Alarm: null, ReferencingEquipmentPaths: Array.Empty()); + + private static AddressSpaceComposition CompositionWith(params RawTagPlan[] tags) => new( UnsAreas: Array.Empty(), UnsLines: Array.Empty(), @@ -341,7 +345,8 @@ public sealed class SubscriptionSurvivalTests DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()) { - EquipmentTags = tags, + RawContainers = new[] { new RawContainerNode("Plant/dev1", null, "dev1", RawNodeKind.Device) }, + RawTags = tags, }; private static async Task OpenSessionAsync(string endpointUrl, CancellationToken ct) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs index a94b824b..1282a731 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs @@ -75,30 +75,26 @@ public sealed class AddressSpaceApplierFailureSurfaceTests // ---------------- T3: Materialise* passes return failed-node counts ---------------- - /// Every EnsureVariable throwing in MaterialiseEquipmentTags is tallied into the pass's int return. + /// Every EnsureVariable throwing in MaterialiseRawSubtree is tallied into the pass's int return. [Fact] - public void MaterialiseEquipmentTags_WhenEnsureVariableThrows_ReturnsFailedCount() + public void MaterialiseRawSubtree_WhenEnsureVariableThrows_ReturnsFailedCount() { var sink = new ConfigurableThrowingSink { ThrowOnEnsureVariable = true }; var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var composition = CompositionWithTags( - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "", Name: "C", DataType: "Float", FullName: "40003", Writable: false, Alarm: null)); + var composition = CompositionWithRawTags(RawTag("t1", "A"), RawTag("t2", "B"), RawTag("t3", "C")); - applier.MaterialiseEquipmentTags(composition).ShouldBe(3); + applier.MaterialiseRawSubtree(composition).ShouldBe(3); } - /// All five Materialise* passes return 0 failures on a clean sink — the happy-path regression. + /// All four Materialise* passes return 0 failures on a clean sink — the happy-path regression. [Fact] public void Materialise_HappyPath_AllPassesReturnZero() { var sink = new ConfigurableThrowingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var tags = CompositionWithTags( - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); + var tags = CompositionWithRawTags(RawTag("t1", "A")); var vtags = CompositionWithVirtualTags( new EquipmentVirtualTagPlan("v1", "eq-1", FolderPath: "", Name: "VA", DataType: "Float", Expression: "1", DependencyRefs: Array.Empty(), Historize: false)); var alarms = CompositionWithScriptedAlarms( @@ -106,17 +102,21 @@ public sealed class AddressSpaceApplierFailureSurfaceTests Array.Empty(), HistorizeToAveva: false, Retain: true, Enabled: true)); applier.MaterialiseHierarchy(tags).ShouldBe(0); - applier.MaterialiseEquipmentTags(tags).ShouldBe(0); + applier.MaterialiseRawSubtree(tags).ShouldBe(0); applier.MaterialiseEquipmentVirtualTags(vtags).ShouldBe(0); applier.MaterialiseScriptedAlarms(alarms).ShouldBe(0); } // ---------------- fixtures ---------------- - private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) => + private static RawTagPlan RawTag(string id, string name) => + new(id, $"Plant/dev1/{name}", ParentNodeId: null, "drv", Name: name, DataType: "Float", Writable: false, Alarm: null, + ReferencingEquipmentPaths: Array.Empty()); + + private static AddressSpaceComposition CompositionWithRawTags(params RawTagPlan[] tags) => new(Array.Empty(), Array.Empty(), Array.Empty()) { - EquipmentTags = tags, + RawTags = tags, }; private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) => diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs index 39d26056..4528812e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierHierarchyTests.cs @@ -110,13 +110,13 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable sdkServer.NodeManager!.FolderCount.ShouldBe(5); } - /// Verifies MaterialiseEquipmentTags is idempotent against a real SDK node manager: + /// Verifies MaterialiseRawSubtree is idempotent against a real SDK node manager: /// applying the same composition twice yields a single Variable node (no duplicates). This is the /// restart-safety guarantee — HandleRebuild runs on both the apply path and the /// DriverHostActor.RestoreApplied bootstrap path (same RebuildAddressSpace message), so a node /// restart re-runs this pass and must not double-materialise. [Fact] - public async Task MaterialiseEquipmentTags_against_real_SDK_node_manager_is_idempotent() + public async Task MaterialiseRawSubtree_against_real_SDK_node_manager_is_idempotent() { var applicationUri = $"urn:OtOpcUa.EquipmentTags:{Guid.NewGuid():N}"; var started = await OpcUaHostStart.StartAsync( @@ -145,16 +145,17 @@ public sealed class AddressSpaceApplierHierarchyTests : IDisposable DriverInstancePlans: Array.Empty(), ScriptedAlarmPlans: Array.Empty()) { - EquipmentTags = new[] + RawTags = new[] { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), + new RawTagPlan("tag-1", "Plant/dev1/Speed", ParentNodeId: null, "drv", Name: "Speed", DataType: "Float", + Writable: false, Alarm: null, ReferencingEquipmentPaths: Array.Empty()), }, }; - // Equipment folder first (the variable's parent), then the tag pass — applied twice. + // The raw pass — applied twice. applier.MaterialiseHierarchy(composition); - applier.MaterialiseEquipmentTags(composition); - applier.MaterialiseEquipmentTags(composition); + applier.MaterialiseRawSubtree(composition); + applier.MaterialiseRawSubtree(composition); sdkServer.NodeManager!.VariableCount.ShouldBe(1); // single variable despite the double-apply } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs index f3e61370..2a60cf29 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierTests.cs @@ -12,30 +12,6 @@ public sealed class AddressSpaceApplierTests - /// R2-07 T2 — added equipment tags in an otherwise-empty plan are a PureAdd: the applier - /// SKIPS the rebuild (the idempotent Materialise passes create the new variables; existing subscriptions - /// survive). (Supersedes the pre-R2-07 "added tags ⇒ rebuild" pin.) - [Fact] - public void Added_equipment_tags_are_pure_add_and_skip_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = EmptyPlan with - { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - outcome.AddedNodes.ShouldBe(1); - sink.RebuildCalls.ShouldBe(0); - } - /// R2-07 T2 — added Equipment VirtualTags in an otherwise-empty plan are a PureAdd: the applier /// SKIPS the rebuild (parity with the equipment-tag path). (Supersedes the pre-R2-07 "added vtags ⇒ /// rebuild" pin.) @@ -61,27 +37,22 @@ public sealed class AddressSpaceApplierTests sink.RebuildCalls.ShouldBe(0); } - /// R2-07 T2 — a PureAdd plan carrying a coincident surgical tag delta (only Writable flips on - /// an existing tag) SKIPS the rebuild AND applies the in-place tag update via the surgical sink. The - /// classifier routes adds+surgical-change to PureAdd (rule 2 clears because the change is - /// surgical-eligible); the applier still runs the F10b surgical pass. + /// R2-07 T11 (raw-tag vehicle, review 01 U2 retired the equipment-tag one) — a remove-one-raw-tag + /// plan is a PureRemove: the applier writes a terminal Bad to the removed variable then removes it in place + /// via RemoveVariableNode in the RAW realm — NO rebuild. An alarm-bearing raw tag gets the terminal + /// RemovedConditionState and a condition-node removal instead. [Fact] - public void Pure_add_with_coincident_surgical_tag_delta_skips_rebuild_and_updates_in_place() + public void Removed_raw_tags_write_terminal_state_and_remove_in_place_without_rebuild() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var plan = EmptyPlan with { - AddedEquipmentTags = new[] + RemovedRawTags = new[] { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null), - }, - ChangedEquipmentTags = new[] - { - new AddressSpacePlan.EquipmentTagDelta( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)), + RawTag("tag-1", "Plant/dev1/Speed"), + RawTag("tag-alm", "Plant/dev1/OverTemp", alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)), }, }; @@ -89,125 +60,16 @@ public sealed class AddressSpaceApplierTests outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); - var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.Writable.ShouldBeTrue(); - } - - /// R2-07 T2 — during a PureAdd apply, if a coincident surgical tag update reports the node - /// missing (returns false), the applier falls back to a full rebuild (the F10b safety valve is - /// preserved through the classifier routing). - [Fact] - public void Pure_add_with_surgical_returns_false_falls_back_to_rebuild() - { - var sink = new RecordingSink { SurgicalReturns = false }; - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = EmptyPlan with - { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null), - }, - ChangedEquipmentTags = new[] - { - new AddressSpacePlan.EquipmentTagDelta( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); // fell back to a full rebuild - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first - } - - /// R2-07 T11 — a remove-one-tag plan is a PureRemove: the applier writes a terminal Bad to the - /// removed variable then removes it in place via RemoveVariableNode — NO rebuild. (Supersedes the T2 - /// phase pin.) - [Fact] - public void Removed_equipment_tag_writes_terminal_bad_and_removes_variable_without_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = EmptyPlan with - { - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - var nodeId = V3NodeIds.Uns("eq-1", "Speed"); - // Terminal Bad written to the removed variable BEFORE the removal. - sink.ValueWrites.ShouldContain((nodeId, OpcUaStatus.Bad, AddressSpaceRealm.Uns)); - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId, AddressSpaceRealm.Uns)); - } - - /// R2-07 T11 — a removed alarm-bearing tag writes the terminal RemovedConditionState (closing - /// the pre-R2-07 today-gap where a removed alarm tag got no condition write) then removes the CONDITION - /// node in place (not a value variable). - [Fact] - public void Removed_alarm_bearing_tag_writes_removed_condition_state_and_removes_condition() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = EmptyPlan with - { - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-alm", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, - Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700)), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - var nodeId = V3NodeIds.Uns("eq-1", "OverTemp"); - // Terminal RemovedConditionState (inactive/acked/confirmed) written to the condition node. + sink.ValueWrites.ShouldContain(("Plant/dev1/Speed", OpcUaStatus.Bad, AddressSpaceRealm.Raw)); var write = sink.AlarmWrites.ShouldHaveSingleItem(); - write.NodeId.ShouldBe(nodeId); + write.NodeId.ShouldBe("Plant/dev1/OverTemp"); (!write.State.Active && write.State.Acknowledged && write.State.Confirmed).ShouldBeTrue(); - // Removed as a condition node, not a value variable. - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("alarm", nodeId, AddressSpaceRealm.Uns)); - sink.ValueWrites.ShouldNotContain(w => w.NodeId == nodeId); - } - - /// R2-07 T11 — a removed child (tag/vtag/alarm) whose equipment is ALSO removed is SUBSUMED by - /// the equipment-subtree removal: only RemoveEquipmentSubtree fires, no individual child remove. - [Fact] - public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var plan = EmptyPlan with + sink.RemoveCalls.ShouldBe(new[] { - RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") }, - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - RemovedEquipmentVirtualTags = new[] - { - new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Eff", DataType: "Float", Expression: "a", DependencyRefs: new[] { "a" }), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - // ONLY the subtree removal fires — no individual var removal for the subsumed child tag/vtag. - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1", AddressSpaceRealm.Uns)); + ("var", "Plant/dev1/Speed", AddressSpaceRealm.Raw), + ("alarm", "Plant/dev1/OverTemp", AddressSpaceRealm.Raw), + }); + outcome.RemovedNodes.ShouldBe(2); } /// R2-07 T11 — if a surgical remove reports the node unknown (RemoveReturns=false), the applier @@ -220,11 +82,7 @@ public sealed class AddressSpaceApplierTests var plan = EmptyPlan with { - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null), - }, + RemovedRawTags = new[] { RawTag("tag-1", "Plant/dev1/A"), RawTag("tag-2", "Plant/dev1/B") }, }; var outcome = applier.Apply(plan); @@ -243,13 +101,7 @@ public sealed class AddressSpaceApplierTests var sink = new PlainRecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var plan = EmptyPlan with - { - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), - }, - }; + var plan = EmptyPlan with { RemovedRawTags = new[] { RawTag("tag-1", "Plant/dev1/A") } }; var outcome = applier.Apply(plan); @@ -259,7 +111,7 @@ public sealed class AddressSpaceApplierTests /// R2-07 T13 — an add+remove plan is an AddRemoveMix: the applier removes the old node IN PLACE /// (RemoveVariableNode) and does NOT rebuild; the add is materialised afterward by the publish actor's - /// passes. (Supersedes the T2 phase pin.) + /// passes. [Fact] public void Add_and_remove_is_mixed_removes_in_place_without_rebuild() { @@ -268,14 +120,8 @@ public sealed class AddressSpaceApplierTests var plan = EmptyPlan with { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null), - }, - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, + AddedRawTags = new[] { RawTag("tag-new", "Plant/dev1/New") }, + RemovedRawTags = new[] { RawTag("tag-old", "Plant/dev1/Old") }, }; var outcome = applier.Apply(plan); @@ -283,40 +129,11 @@ public sealed class AddressSpaceApplierTests outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); // The removed node is torn down in place; the add is left to the publish actor's materialise passes. - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Old"), AddressSpaceRealm.Uns)); + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", "Plant/dev1/Old", AddressSpaceRealm.Raw)); outcome.AddedNodes.ShouldBe(1); outcome.RemovedNodes.ShouldBe(1); } - /// R2-07 T13 — an id REUSED across the remove + add sets in one deploy (a tag removed at the - /// same folder-scoped NodeId another tag is added at): the applier removes the old node in place (the - /// recreate is the publish actor's job, a fresh NodeState). No rebuild. - [Fact] - public void Add_and_remove_reusing_same_node_id_removes_the_old_node_without_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var nodeId = V3NodeIds.Uns("eq-1", "Slot"); - var plan = EmptyPlan with - { - // Different TagId, but both resolve to the SAME folder-scoped NodeId (eq-1/Slot). - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "Slot", DataType: "Int32", FullName: "40002", Writable: false, Alarm: null), - }, - }; - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", nodeId, AddressSpaceRealm.Uns)); - } - /// R2-07 T13 — a remove failure inside an AddRemoveMix still trips the rebuild ratchet. [Fact] public void Add_and_remove_with_remove_returns_false_falls_back_to_rebuild() @@ -326,14 +143,8 @@ public sealed class AddressSpaceApplierTests var plan = EmptyPlan with { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-new", "eq-1", "drv", FolderPath: "", Name: "New", DataType: "Float", FullName: "40009", Writable: false, Alarm: null), - }, - RemovedEquipmentTags = new[] - { - new EquipmentTagPlan("tag-old", "eq-1", "drv", FolderPath: "", Name: "Old", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, + AddedRawTags = new[] { RawTag("tag-new", "Plant/dev1/New") }, + RemovedRawTags = new[] { RawTag("tag-old", "Plant/dev1/Old") }, }; var outcome = applier.Apply(plan); @@ -342,38 +153,28 @@ public sealed class AddressSpaceApplierTests sink.RebuildCalls.ShouldBe(1); } - /// H1a — a deploy that ONLY changes an existing equipment tag in a NON-surgical way (here the - /// driver-side FullName re-routes to a different point) must rebuild the address space. The planner - /// diffs the tag into ChangedEquipmentTags with no Added/Removed of anything else; the applier must - /// still drive exactly one rebuild so the running server drops the stale node and re-materialises it. - /// (Surgically-applicable tag changes — Writable/Historizing/DataType/array-shape — take the in-place path - /// instead; those are covered by the F10b + FB-7 surgical tests.) + /// R2-07 T11 — a removed child (tag/vtag/alarm) whose equipment is ALSO removed is SUBSUMED by + /// the equipment-subtree removal: only RemoveEquipmentSubtree fires, no individual child remove. [Fact] - public void Changed_equipment_tags_only_trigger_rebuild() + public void Removed_child_under_removed_equipment_is_subsumed_by_subtree_removal() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - // Same tag id, but the driver-side FullName flipped — a non-surgical change, so the applier rebuilds. - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - - // Guard the arrange: ONLY ChangedEquipmentTags is populated. - plan.ChangedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - plan.AddedEquipment.ShouldBeEmpty(); - plan.RemovedEquipment.ShouldBeEmpty(); + var plan = EmptyPlan with + { + RemovedEquipment = new[] { new EquipmentNode("eq-1", "One", "line-1") }, + RemovedEquipmentVirtualTags = new[] + { + new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Eff", DataType: "Float", Expression: "a", DependencyRefs: new[] { "a" }), + }, + }; var outcome = applier.Apply(plan); - outcome.RebuildCalled.ShouldBeTrue(); - outcome.ChangedNodes.ShouldBe(1); - sink.RebuildCalls.ShouldBe(1); + outcome.RebuildCalled.ShouldBeFalse(); + // ONLY the subtree removal fires — no individual var removal for the subsumed child vtag. + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("equipment", "eq-1", AddressSpaceRealm.Uns)); } /// F10b (backlog #11) — a deploy that ONLY edits an existing VirtualTag's Expression @@ -553,26 +354,18 @@ public sealed class AddressSpaceApplierTests var applier = new AddressSpaceApplier(sink, NullLogger.Instance); var previous = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) + new[] { new EquipmentNode("eq-1", "One", "line-1") }, Array.Empty(), Array.Empty()) { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", Expression: "ctx.GetTag(\"a\") * 60", DependencyRefs: new[] { "a" }), }, }; - // Expression-only vtag edit (node-irrelevant) AND a non-surgical tag change (FullName re-route). + // Expression-only vtag edit (node-irrelevant) AND a node-affecting equipment change (rename). var next = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) + new[] { new EquipmentNode("eq-1", "One renamed", "line-1") }, Array.Empty(), Array.Empty()) { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - }, EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", @@ -582,9 +375,9 @@ public sealed class AddressSpaceApplierTests var plan = AddressSpacePlanner.Compute(previous, next); - // Both a node-irrelevant vtag change AND a node-affecting tag change are present. + // Both a node-irrelevant vtag change AND a node-affecting equipment change are present. plan.ChangedEquipmentVirtualTags.Count.ShouldBe(1); - plan.ChangedEquipmentTags.Count.ShouldBe(1); + plan.ChangedEquipment.Count.ShouldBe(1); var outcome = applier.Apply(plan); @@ -670,7 +463,6 @@ public sealed class AddressSpaceApplierTests plan.ChangedEquipmentVirtualTags.Count.ShouldBe(2); plan.AddedEquipmentVirtualTags.ShouldBeEmpty(); plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); - plan.ChangedEquipmentTags.ShouldBeEmpty(); plan.ChangedEquipment.ShouldBeEmpty(); var outcome = applier.Apply(plan); @@ -732,7 +524,6 @@ public sealed class AddressSpaceApplierTests plan.RemovedDrivers.ShouldBeEmpty(); plan.ChangedEquipment.ShouldBeEmpty(); plan.ChangedAlarms.ShouldBeEmpty(); - plan.ChangedEquipmentTags.ShouldBeEmpty(); plan.ChangedEquipmentVirtualTags.ShouldBeEmpty(); var outcome = applier.Apply(plan); @@ -748,7 +539,7 @@ public sealed class AddressSpaceApplierTests /// to removedCount — AddressSpaceApplyOutcome.RemovedNodes reported 0, a misleading audit /// entry. This pins both the rebuild and the accurate count. [Fact] - public void Removed_equipment_tags_and_virtual_tags_only_rebuild_and_are_counted() + public void Removed_virtual_tags_only_remove_in_place_and_are_counted() { var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); @@ -756,10 +547,6 @@ public sealed class AddressSpaceApplierTests var previous = new AddressSpaceComposition( Array.Empty(), Array.Empty(), Array.Empty()) { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, EquipmentVirtualTags = new[] { new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "Efficiency", DataType: "Float64", @@ -771,440 +558,23 @@ public sealed class AddressSpaceApplierTests var plan = AddressSpacePlanner.Compute(previous, next); - // Guard the arrange: ONLY the two Removed sets are populated. - plan.RemovedEquipmentTags.Count.ShouldBe(1); + // Guard the arrange: ONLY the removed-vtag set is populated. plan.RemovedEquipmentVirtualTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.ChangedEquipmentTags.ShouldBeEmpty(); plan.RemovedEquipment.ShouldBeEmpty(); plan.RemovedAlarms.ShouldBeEmpty(); var outcome = applier.Apply(plan); - // R2-07 T11 — a removed-tag + removed-vtag plan is a PureRemove: both variables are torn down in - // place (RemoveVariableNode ×2), NO full rebuild; both removals are still counted. + // R2-07 T11 — a removed-vtag plan is a PureRemove: the variable is torn down in place + // (RemoveVariableNode), NO full rebuild; the removal is still counted. outcome.RebuildCalled.ShouldBeFalse(); sink.RebuildCalls.ShouldBe(0); - outcome.RemovedNodes.ShouldBe(2); // both removals counted - sink.RemoveCalls.Count.ShouldBe(2); - sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Speed"), AddressSpaceRealm.Uns)); - sink.RemoveCalls.ShouldContain(("var", V3NodeIds.Uns("eq-1", "Efficiency"), AddressSpaceRealm.Uns)); + outcome.RemovedNodes.ShouldBe(1); + sink.RemoveCalls.ShouldHaveSingleItem().ShouldBe(("var", V3NodeIds.Uns("eq-1", "Efficiency"), AddressSpaceRealm.Uns)); } // ----- F10b: surgical in-place tag-attribute writes (Writable / IsHistorized / HistorianTagname) ----- - /// F10b — a deploy that ONLY flips an existing equipment tag's Writable bit (a plain, - /// non-array, non-alarm value variable with stable identity) must SKIP the rebuild and apply the change - /// IN PLACE via ISurgicalAddressSpaceSink.UpdateTagAttributes, preserving every client's - /// subscriptions. Exactly one surgical call lands with the NEW Writable value; the edit still counts as - /// a change (ChangedNodes == 1). - [Fact] - public void Changed_tag_writable_only_skips_rebuild_and_updates_in_place() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - // Same TagId/identity; only Writable flips false → true. - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); // NO RebuildAddressSpace — subscriptions preserved - var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.Writable.ShouldBeTrue(); // the NEW Writable value - call.Historian.ShouldBeNull(); // not historized - outcome.ChangedNodes.ShouldBe(1); - } - - /// F10b — flipping IsHistorized false → true (no override) updates in place with the - /// historian tagname defaulting to FullName; flipping true → false updates in place with a null - /// historian tagname. Both skip the rebuild. - [Fact] - public void Changed_tag_is_historized_toggle_skips_rebuild_and_resolves_historian() - { - // false → true (no override) ⇒ historian defaults to FullName. - var sinkOn = new RecordingSink(); - var applierOn = new AddressSpaceApplier(sinkOn, NullLogger.Instance); - var planOn = AddressSpacePlanner.Compute( - CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null)), - CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null))); - planOn.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcomeOn = applierOn.Apply(planOn); - - outcomeOn.RebuildCalled.ShouldBeFalse(); - sinkOn.RebuildCalls.ShouldBe(0); - sinkOn.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("T.A"); // default ⇒ FullName - - // true → false ⇒ historian null. - var sinkOff = new RecordingSink(); - var applierOff = new AddressSpaceApplier(sinkOff, NullLogger.Instance); - var planOff = AddressSpacePlanner.Compute( - CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: null)), - CompositionWithTags(new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: false, HistorianTagname: null))); - planOff.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcomeOff = applierOff.Apply(planOff); - - outcomeOff.RebuildCalled.ShouldBeFalse(); - sinkOff.RebuildCalls.ShouldBe(0); - sinkOff.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBeNull(); // not historized ⇒ null - } - - /// F10b — changing ONLY the HistorianTagname override on an already-historized tag - /// skips the rebuild and updates in place, passing the NEW override verbatim. - [Fact] - public void Changed_tag_historian_tagname_only_skips_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.Old")); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", - FullName: "T.A", Writable: false, Alarm: null, IsHistorized: true, HistorianTagname: "WW.New")); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - sink.SurgicalCalls.ShouldHaveSingleItem().Historian.ShouldBe("WW.New"); // override verbatim - } - - /// FB-7 — a tag delta whose DataType changed is now surgical-eligible: the sink swaps the - /// node's DataType in place (and raises a GeneralModelChangeEvent), so the applier SKIPS the rebuild and - /// makes exactly one surgical call carrying the NEW DataType. Here Writable also flips, which the same - /// in-place update applies. Subscriptions are preserved. - [Fact] - public void Changed_tag_data_type_change_skips_rebuild_and_updates_in_place() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - // DataType flips Float → Int32 AND Writable flips false → true — both are now surgically applied. - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Int32", FullName: "40001", Writable: true, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); // NO rebuild — subscriptions preserved - var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.NodeId.ShouldBe(V3NodeIds.Uns("eq-1", "Speed")); - call.DataType.ShouldBe("Int32"); // the NEW DataType - call.Writable.ShouldBeTrue(); // the NEW Writable, applied in the same call - call.IsArray.ShouldBeFalse(); - outcome.ChangedNodes.ShouldBe(1); - } - - /// FB-7 — a tag delta whose IsArray flag flips scalar → array is now surgical-eligible: - /// the sink swaps ValueRank + ArrayDimensions in place, so the applier skips the rebuild and the surgical - /// call carries the new array shape. An array tag is forced read-only (matching EnsureVariable), so the - /// surgical Writable is false even though the tag stays non-writable here. - [Fact] - public void Changed_tag_is_array_change_skips_rebuild_and_updates_in_place() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16u)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - var call = sink.SurgicalCalls.ShouldHaveSingleItem(); - call.IsArray.ShouldBeTrue(); // the NEW array shape - call.ArrayLength.ShouldBe(16u); - call.DataType.ShouldBe("Int16"); // element type unchanged - call.Writable.ShouldBeFalse(); // array tag forced read-only - } - - /// F10b safe-default — a tag delta whose driver-side FullName changed is NOT - /// surgical-eligible (it re-routes the node to a different driver point), so the applier rebuilds. - [Fact] - public void Changed_tag_full_name_change_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40002", Writable: false, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldBeEmpty(); - } - - /// F10b safe-default — a tag delta whose Name changed is NOT surgical-eligible (the - /// folder-scoped NodeId + BrowseName derive from Name), so the applier rebuilds. - [Fact] - public void Changed_tag_name_change_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "SpeedRpm", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldBeEmpty(); - } - - /// F10b safe-default — a tag delta where an alarm appears (Alarm null → non-null) is NOT - /// surgical-eligible: the tag flips from a plain value variable to a Part 9 condition node, which only - /// a rebuild can materialise. No surgical call is made. - [Fact] - public void Changed_tag_alarm_presence_change_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", FullName: "00001", Writable: false, - Alarm: new EquipmentTagAlarmInfo("OffNormalAlarm", 700))); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldBeEmpty(); - } - - /// F10b guard — a tag that ALREADY carries an alarm (Alarm is not null on BOTH previous - /// and current, identical alarm info) is NOT surgical-eligible even when only Writable changes. - /// The explicit Alarm is null guard in TagDeltaIsSurgicalEligible prevents a false - /// positive: without it the with { Writable = … } override (which does NOT touch Alarm) - /// would leave both sides with equal Alarm values and the delta would WRONGLY look surgical. - /// An alarm-bearing tag is a Part 9 condition node, not a plain value variable, so any change to it - /// must go through a full rebuild. - [Fact] - public void Changed_alarm_bearing_tag_writable_only_still_rebuilds() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - // Both previous and current carry an identical, non-null alarm — the tag is an alarm-bearing node. - var alarm = new EquipmentTagAlarmInfo("OffNormalAlarm", 700); - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", - FullName: "00001", Writable: false, Alarm: alarm)); - // Only Writable flips; alarm is unchanged (and still non-null on both sides). - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "OverTemp", DataType: "Boolean", - FullName: "00001", Writable: true, Alarm: alarm)); - - var plan = AddressSpacePlanner.Compute(previous, next); - - // Guard the arrange: the planner sees a changed tag (Writable differs) and nothing else. - plan.ChangedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - - var outcome = applier.Apply(plan); - - // The alarm-bearing tag is NOT surgical-eligible — it must rebuild and make NO surgical call. - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - sink.SurgicalCalls.ShouldBeEmpty(); // NO surgical write on an alarm-bearing tag - } - - /// F10b multi-delta — when a plan contains TWO distinct surgical-eligible tag deltas (plain - /// value variables, no alarm, both changing only Writable), the applier must apply BOTH in - /// place (two UpdateTagAttributes calls) without triggering any rebuild. Proves the surgical - /// path iterates all eligible deltas rather than stopping after the first. - [Fact] - public void Two_surgical_eligible_tag_deltas_both_apply_in_place_no_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - // Two distinct plain (no-alarm) tags, both will flip Writable — both are surgical-eligible. - var previous = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float", FullName: "40002", Writable: false, Alarm: null), - }, - }; - // Only Writable flips on both tags; everything else is identical. - var next = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), - new EquipmentTagPlan("tag-2", "eq-1", "drv", FolderPath: "", Name: "Pressure", DataType: "Float", FullName: "40002", Writable: true, Alarm: null), - }, - }; - - var plan = AddressSpacePlanner.Compute(previous, next); - - // Guard the arrange: exactly two changed tags, nothing else. - plan.ChangedEquipmentTags.Count.ShouldBe(2); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - plan.AddedEquipment.ShouldBeEmpty(); - plan.RemovedEquipment.ShouldBeEmpty(); - - var outcome = applier.Apply(plan); - - // No rebuild — both deltas are surgical-eligible. - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - - // Exactly two surgical calls — one per eligible delta. - sink.SurgicalCalls.Count.ShouldBe(2); - - // Both expected node ids must appear (order is not guaranteed). - var nodeId1 = V3NodeIds.Uns("eq-1", "Speed"); - var nodeId2 = V3NodeIds.Uns("eq-1", "Pressure"); - sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId1 && c.Writable); - sink.SurgicalCalls.ShouldContain(c => c.NodeId == nodeId2 && c.Writable); - - outcome.ChangedNodes.ShouldBe(2); - } - - /// R2-07 T2 — a surgical-eligible tag delta MIXED with an added equipment is now a PureAdd: - /// the added equipment is created by the Materialise passes and the surgical tag change is applied IN - /// PLACE, so NO rebuild fires and exactly one surgical call lands. (Supersedes the pre-R2-07 - /// "surgical-mixed-with-add ⇒ rebuild" pin — an add no longer forces a rebuild.) - [Fact] - public void Surgical_eligible_tag_delta_mixed_with_added_equipment_is_pure_add_and_skips_rebuild() - { - var sink = new RecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - // Surgical-eligible Writable flip on the tag AND a brand-new equipment node. - var next = new AddressSpaceComposition( - new[] { new EquipmentNode("eq-new", "New", "line-1") }, Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null), - }, - }; - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - plan.AddedEquipment.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeFalse(); - sink.RebuildCalls.ShouldBe(0); - sink.SurgicalCalls.ShouldHaveSingleItem().Writable.ShouldBeTrue(); - } - - /// F10b fallback — a sink that does NOT implement cannot - /// apply the in-place update, so even a surgical-eligible (Writable-only) tag delta drives a full - /// rebuild (safe default). - [Fact] - public void Surgical_eligible_delta_on_non_surgical_sink_rebuilds() - { - var sink = new PlainRecordingSink(); - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); - } - - /// F10b fallback — when the surgical sink reports the node MISSING - /// (UpdateTagAttributes returns false), the applier falls back to a full rebuild. The surgical - /// call is still attempted (recorded once) before the fallback fires. - [Fact] - public void Surgical_sink_returning_false_node_missing_falls_back_to_rebuild() - { - var sink = new RecordingSink { SurgicalReturns = false }; - var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - - var previous = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null)); - var next = CompositionWithTags( - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: true, Alarm: null)); - - var plan = AddressSpacePlanner.Compute(previous, next); - plan.ChangedEquipmentTags.Count.ShouldBe(1); - - var outcome = applier.Apply(plan); - - outcome.RebuildCalled.ShouldBeTrue(); - sink.RebuildCalls.ShouldBe(1); // fell back to a full rebuild - sink.SurgicalCalls.ShouldHaveSingleItem(); // the surgical update was attempted first - } - // ----- OpcUaServer-001: in-place UNS Area / Line folder rename (no rebuild) ----- /// OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename must SKIP the rebuild and @@ -1337,42 +707,6 @@ public sealed class AddressSpaceApplierTests // ----- R2-07 T3: pure-add NodeAdded announcements ----- - /// Two tags added under the SAME equipment (no FolderPath) dedup to ONE announcement for the - /// equipment id. - [Fact] - public void ComputeAddAnnouncements_two_tags_under_same_equipment_dedup_to_one() - { - var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance); - - var plan = EmptyPlan with - { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null), - }, - }; - - applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { "eq-1" }); - } - - /// A tag with a FolderPath announces its SUB-FOLDER (the materialise parent), not the equipment. - [Fact] - public void ComputeAddAnnouncements_tag_with_folder_path_announces_subfolder() - { - var applier = new AddressSpaceApplier(new RecordingSink(), NullLogger.Instance); - - var plan = EmptyPlan with - { - AddedEquipmentTags = new[] - { - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "Diag", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), - }, - }; - - applier.ComputeAddAnnouncements(plan).ShouldBe(new[] { V3NodeIds.Uns("eq-1", "Diag") }); - } - /// An added scripted alarm announces its equipment folder (where its condition node parents). [Fact] public void ComputeAddAnnouncements_added_alarm_announces_equipment() @@ -1445,11 +779,11 @@ public sealed class AddressSpaceApplierTests var plan = EmptyPlan with { AddedEquipment = new[] { new EquipmentNode("eq-2", "Two", "line-7") }, - AddedEquipmentTags = new[] + AddedEquipmentVirtualTags = new[] { - new EquipmentTagPlan("t1", "eq-1", "drv", FolderPath: "", Name: "A", DataType: "Float", FullName: "1", Writable: false, Alarm: null), - new EquipmentTagPlan("t2", "eq-1", "drv", FolderPath: "", Name: "B", DataType: "Float", FullName: "2", Writable: false, Alarm: null), - new EquipmentTagPlan("t3", "eq-1", "drv", FolderPath: "Diag", Name: "C", DataType: "Float", FullName: "3", Writable: false, Alarm: null), + new EquipmentVirtualTagPlan("vt-1", "eq-1", FolderPath: "", Name: "A", DataType: "Float", Expression: "1", DependencyRefs: Array.Empty()), + new EquipmentVirtualTagPlan("vt-2", "eq-1", FolderPath: "", Name: "B", DataType: "Float", Expression: "2", DependencyRefs: Array.Empty()), + new EquipmentVirtualTagPlan("vt-3", "eq-1", FolderPath: "Diag", Name: "C", DataType: "Float", Expression: "3", DependencyRefs: Array.Empty()), }, }; @@ -1855,13 +1189,6 @@ public sealed class AddressSpaceApplierTests Array.Empty(), lines, Array.Empty(), Array.Empty(), Array.Empty()); - private static AddressSpaceComposition CompositionWithTags(params EquipmentTagPlan[] tags) => - new( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = tags, - }; - private static AddressSpaceComposition CompositionWithVirtualTags(params EquipmentVirtualTagPlan[] vtags) => new( Array.Empty(), Array.Empty(), Array.Empty()) @@ -1878,6 +1205,10 @@ public sealed class AddressSpaceApplierTests new( Array.Empty(), drivers, Array.Empty()); + private static RawTagPlan RawTag(string tagId, string rawPath, EquipmentTagAlarmInfo? alarm = null) => + new(tagId, rawPath, ParentNodeId: null, "drv", Name: rawPath[(rawPath.LastIndexOf('/') + 1)..], DataType: "Float", + Writable: false, Alarm: alarm, ReferencingEquipmentPaths: Array.Empty()); + private static AddressSpacePlan EmptyPlan => new( Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), Array.Empty(), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs index 24507eb6..ad32ead8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceChangeClassifierTests.cs @@ -35,7 +35,7 @@ public sealed class AddressSpaceChangeClassifierTests } [Fact] - public void Added_equipment_tag_only_is_PureAdd() + public void Added_raw_tag_only_is_PureAdd() { var plan = Plan(addedTags: new[] { Tag("tag-1", "eq-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); @@ -65,7 +65,7 @@ public sealed class AddressSpaceChangeClassifierTests } [Fact] - public void Removed_equipment_tag_only_is_PureRemove() + public void Removed_raw_tag_only_is_PureRemove() { var plan = Plan(removedTags: new[] { Tag("tag-1", "eq-1") }); AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureRemove); @@ -116,19 +116,6 @@ public sealed class AddressSpaceChangeClassifierTests AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); } - [Fact] - public void Non_surgical_changed_tag_is_Rebuild() - { - // FullName re-routes the node to a different driver point ⇒ NOT surgical-eligible ⇒ Rebuild. - var plan = Plan(changedTags: new[] - { - new AddressSpacePlan.EquipmentTagDelta( - Tag("tag-1", "eq-1", fullName: "40001"), - Tag("tag-1", "eq-1", fullName: "40002")), - }); - AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); - } - [Fact] public void Node_relevant_changed_vtag_is_Rebuild() { @@ -144,19 +131,6 @@ public sealed class AddressSpaceChangeClassifierTests // ----- surgical-only / rename-only / driver-only ⇒ AttributeOnly ----- - [Fact] - public void Surgical_eligible_changed_tag_only_is_AttributeOnly() - { - // Only Writable flips ⇒ surgical-eligible ⇒ AttributeOnly (no adds, no removes). - var plan = Plan(changedTags: new[] - { - new AddressSpacePlan.EquipmentTagDelta( - Tag("tag-1", "eq-1", writable: false), - Tag("tag-1", "eq-1", writable: true)), - }); - AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.AttributeOnly); - } - [Fact] public void Node_irrelevant_changed_vtag_only_is_AttributeOnly() { @@ -195,36 +169,6 @@ public sealed class AddressSpaceChangeClassifierTests // ----- mixed-with-surgical: adds still win over coincident surgical changes ----- - [Fact] - public void Adds_with_coincident_surgical_change_is_PureAdd() - { - var plan = Plan( - addedTags: new[] { Tag("tag-new", "eq-1") }, - changedTags: new[] - { - new AddressSpacePlan.EquipmentTagDelta( - Tag("tag-1", "eq-1", writable: false), - Tag("tag-1", "eq-1", writable: true)), - }); - AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.PureAdd); - } - - [Fact] - public void Adds_with_coincident_non_surgical_change_is_Rebuild() - { - // Rule 2 (node-affecting change) is evaluated BEFORE the add/remove split, so a non-surgical - // change forces Rebuild even alongside pure adds. - var plan = Plan( - addedTags: new[] { Tag("tag-new", "eq-1") }, - changedTags: new[] - { - new AddressSpacePlan.EquipmentTagDelta( - Tag("tag-1", "eq-1", fullName: "40001"), - Tag("tag-1", "eq-1", fullName: "40002")), - }); - AddressSpaceChangeClassifier.Classify(plan).ShouldBe(AddressSpaceChangeKind.Rebuild); - } - [Fact] public void Adds_with_coincident_folder_rename_is_PureAdd() { @@ -240,8 +184,9 @@ public sealed class AddressSpaceChangeClassifierTests private static ScriptedAlarmPlan Alarm(string id) => new(id, "eq-1", "scr-1", "msg"); - private static EquipmentTagPlan Tag(string id, string equipmentId, string fullName = "40001", bool writable = false) => - new(id, equipmentId, "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: fullName, Writable: writable, Alarm: null); + private static RawTagPlan Tag(string id, string device) => + new(id, $"{device}/{id}", ParentNodeId: device, "drv", Name: id, DataType: "Float", Writable: false, Alarm: null, + ReferencingEquipmentPaths: Array.Empty()); private static EquipmentVirtualTagPlan Vtag(string id, string equipmentId, string name = "Efficiency", string expression = "ctx.GetTag(\"a\")") => new(id, equipmentId, FolderPath: "", Name: name, DataType: "Float64", Expression: expression, DependencyRefs: new[] { "a" }); @@ -256,9 +201,8 @@ public sealed class AddressSpaceChangeClassifierTests IReadOnlyList? addedAlarms = null, IReadOnlyList? removedAlarms = null, IReadOnlyList? changedAlarms = null, - IReadOnlyList? addedTags = null, - IReadOnlyList? removedTags = null, - IReadOnlyList? changedTags = null, + IReadOnlyList? addedTags = null, + IReadOnlyList? removedTags = null, IReadOnlyList? addedVtags = null, IReadOnlyList? removedVtags = null, IReadOnlyList? changedVtags = null, @@ -274,9 +218,8 @@ public sealed class AddressSpaceChangeClassifierTests removedAlarms ?? Array.Empty(), changedAlarms ?? Array.Empty()) { - AddedEquipmentTags = addedTags ?? Array.Empty(), - RemovedEquipmentTags = removedTags ?? Array.Empty(), - ChangedEquipmentTags = changedTags ?? Array.Empty(), + AddedRawTags = addedTags ?? Array.Empty(), + RemovedRawTags = removedTags ?? Array.Empty(), AddedEquipmentVirtualTags = addedVtags ?? Array.Empty(), RemovedEquipmentVirtualTags = removedVtags ?? Array.Empty(), ChangedEquipmentVirtualTags = changedVtags ?? Array.Empty(), diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs index 7f01b9b3..227dba03 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpacePlannerTests.cs @@ -30,31 +30,6 @@ public sealed class AddressSpacePlannerTests plan.IsEmpty.ShouldBeTrue(); } - /// Verifies an equipment-tag-only delta (no equipment/driver/alarm/galaxy change) - /// yields a NON-empty plan, so OpcUaPublishActor.HandleRebuild does not short-circuit at the - /// IsEmpty gate before materialising the new equipment variables. - [Fact] - public void Equipment_tag_only_change_yields_non_empty_plan_with_added_tag() - { - var prev = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()); - var next = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Speed", DataType: "Float", FullName: "40001", Writable: false, Alarm: null), - }, - }; - - var plan = AddressSpacePlanner.Compute(prev, next); - - plan.IsEmpty.ShouldBeFalse(); - plan.AddedEquipmentTags.Single().TagId.ShouldBe("tag-1"); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - plan.ChangedEquipmentTags.ShouldBeEmpty(); - } - /// Verifies a VirtualTag-only delta (no equipment/driver/alarm/galaxy/tag change) /// yields a NON-empty plan with the new VirtualTag in AddedEquipmentVirtualTags, so a deploy that /// only adds VirtualTags is no longer a silent no-op at the IsEmpty gate. @@ -174,42 +149,6 @@ public sealed class AddressSpacePlannerTests plan.RemovedEquipmentVirtualTags.ShouldBeEmpty(); } - /// An equipment Tag with the same id but a toggled IsArray flag (and otherwise - /// identical fields) must route to ChangedEquipmentTags. This pins that IsArray is part of - /// (record value-equality) so an array-flag-only deploy is not - /// a silent no-op at the diff/IsEmpty gate — same posture as the Historize flag. - [Fact] - public void Same_id_with_toggled_isarray_routes_to_ChangedEquipmentTags() - { - var prev = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: false, ArrayLength: null), - }, - }; - var next = new AddressSpaceComposition( - Array.Empty(), Array.Empty(), Array.Empty()) - { - EquipmentTags = new[] - { - new EquipmentTagPlan("tag-1", "eq-1", "drv", FolderPath: "", Name: "Buffer", DataType: "Int16", - FullName: "40001", Writable: false, Alarm: null, IsArray: true, ArrayLength: 16), - }, - }; - - var plan = AddressSpacePlanner.Compute(prev, next); - - plan.IsEmpty.ShouldBeFalse(); - plan.ChangedEquipmentTags.Single().Previous.IsArray.ShouldBeFalse(); - plan.ChangedEquipmentTags.Single().Current.IsArray.ShouldBeTrue(); - plan.ChangedEquipmentTags.Single().Current.ArrayLength.ShouldBe((uint)16); - plan.AddedEquipmentTags.ShouldBeEmpty(); - plan.RemovedEquipmentTags.ShouldBeEmpty(); - } - /// Regression guard for structural equality on : /// two snapshots containing the SAME VirtualTag built from SEPARATE list instances must diff to an empty plan /// (IReadOnlyList equality is BY REFERENCE without the custom Equals override, so every VirtualTag with From deaa31479bacd0f254f8411976b5eb3fe8b0c509 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:36:34 -0400 Subject: [PATCH 06/67] perf(runtime): parse + plan a deploy off the pinned OPC UA thread (B6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 02 P5. OpcUaPublishActor ran the whole apply — DeploymentArtifact .Open, ParseComposition, AddressSpacePlanner.Compute and the sink materialisation — on the opcua-synchronized-dispatcher, so a large artifact serialised every live value batch (and, because the node manager lock is the SDK's, every client Publish) behind the parse. StartRebuild now runs Open + ParseDataPlaneAuthority + ParseComposition + Compute in Task.Run and pipes a PlanReady (or PlanFailed) back to Self; only ApplyPlan — the sink passes — runs on the actor thread. Rebuilds are applied strictly in arrival order (a plan is a diff against _lastApplied, which only the apply advances): one in flight, later ones queue in _pendingRebuilds while value/status batches keep flowing. The apply is closed (CompleteAddressSpaceApply) and the RebuildCompleted reply sent on every path, PlanFailed included. The bytes-less ConfigDb fallback (LoadArtifact / LoadLatestArtifact) is deleted: it had no production producer and would have blocked the pinned thread on central SQL; the dbFactory Props/ctor parameter is kept for wiring compatibility (F4). Tests: OpcUaPublishActorOffThreadPlanTests — a value batch sent right after a 50k-tag rebuild lands before that rebuild's first materialise call (positive control: all 50k tags still materialise); back-to-back rebuilds apply in arrival order (the second diffs against the first); unreadable bytes answer degraded with nothing torn down. The rebuild / apply-failure suites now hand the actor the artifact bytes every production producer carries (SealArtifact records them; Rebuild(dep)). Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OpcUa/OpcUaPublishActor.cs | 328 +++++++++--------- .../OpcUaPublishActorApplyFailureTests.cs | 14 +- .../OpcUaPublishActorOffThreadPlanTests.cs | 128 +++++++ .../OpcUa/OpcUaPublishActorRebuildTests.cs | 145 +++----- 4 files changed, 360 insertions(+), 255 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorOffThreadPlanTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 207585c9..cbe96919 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -86,19 +86,17 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// The namespace realm the condition lives in ( for native). public sealed record AlarmQualityUpdate(string AlarmNodeId, uint StatusCode, DateTime TimestampUtc, AddressSpaceRealm Realm); /// - /// Triggers an address-space rebuild. is the deployment - /// just applied by the host; the rebuild loads THAT artifact so materialisation matches the - /// applied config + the SubscribeBulk pass. It is null only for legacy/dev callers, which - /// fall back to the latest sealed deployment (lags a not-yet-sealed apply by one revision). + /// Triggers an address-space rebuild of the artifact carried in . + /// is the deployment just applied by the host (echoed in the reply and + /// the logs). Review 02 P5: the ConfigDb fallback for a message carrying no bytes is gone — it had no + /// production producer and would have blocked the pinned OPC UA thread on central SQL — so a message + /// without bytes is "no answer" and holds the served address space. /// /// Correlation id for tracing this rebuild. - /// The applied deployment whose artifact to materialise, or null for the latest-sealed fallback. + /// The applied deployment whose artifact this is, or null for a legacy caller. /// - /// The artifact bytes already in hand, used INSTEAD of loading them from the ConfigDb. This is - /// what makes boot-from-cache actually serve its address space: on a central-SQL outage the - /// host has the cached blob but alone would drive a ConfigDb read - /// that cannot succeed, leaving the rebuild a no-op and clients browsing an empty server. Null - /// on the normal path, where loading from the ConfigDb by id is correct. + /// The artifact bytes. Every producer (the deploy path, boot-from-cache, FetchAndCache) has them in + /// hand; null or empty is treated as no answer. /// /// /// when , the actor replies to the sender with a @@ -128,11 +126,29 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers public sealed record ServiceLevelChanged(byte ServiceLevel); + /// + /// Review 02 P5 — a rebuild whose artifact parse, cluster-scoped composition and plan were computed + /// OFF the pinned OPC UA thread (a Task.Run piped back to Self), ready for the sink passes, + /// which are the only part that must run on this actor's thread. Carries the and + /// the original so the reply reaches whoever asked. + /// + private sealed record PlanReady( + RebuildAddressSpace Request, IActorRef Requester, byte[] Artifact, DataPlaneAuthorityProjection? Authority, + AddressSpaceComposition Composition, AddressSpacePlan Plan, IReadOnlyList Inconsistencies); + + /// The off-thread parse / plan step threw (unreadable bytes, a composer fault). + private sealed record PlanFailed(RebuildAddressSpace Request, IActorRef Requester, Exception Error); + + /// True while a rebuild's parse / plan step is running off-thread. Rebuilds are applied strictly in + /// arrival order — the plan is a diff against , which only the apply advances — so a + /// rebuild arriving meanwhile waits in ; value and status batches do not. + private bool _planInFlight; + private readonly Queue<(RebuildAddressSpace Request, IActorRef Requester)> _pendingRebuilds = new(); + private readonly IOpcUaAddressSpaceSink _sink; private readonly IServiceLevelPublisher _serviceLevel; private readonly bool _subscribeRedundancyTopic; private readonly NodeId? _localNode; - private readonly IDbContextFactory? _dbFactory; private readonly AddressSpaceApplier? _applier; /// Receives the API keys + ACL grants carried by each applied artifact, so /// OPC UA session activation can authenticate. Null when nothing consumes it (tests, and any node @@ -184,9 +200,9 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// Production Props — pins the OPC UA dispatcher + subscribes to the /// redundancy-state DPS topic so cluster transitions drive the local ServiceLevel - /// publish path. When + are supplied, - /// reads the latest deployment artifact + drives the - /// applier through the sink. When is supplied the local + /// publish path. When is supplied, + /// diffs + applies the artifact bytes it carries through the sink ( is + /// accepted for wiring compatibility only — review 02 P5 removed the ConfigDb fallback). When is supplied the local /// ServiceLevel is computed via from real DB-health + /// staleness + role-leader inputs; otherwise the legacy role-only switch is used. /// The OPC UA address space sink. @@ -335,7 +351,9 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers _serviceLevel = serviceLevel; _subscribeRedundancyTopic = subscribeRedundancyTopic; _localNode = localNode; - _dbFactory = dbFactory; + // dbFactory is accepted for wiring compatibility only (review 02 P5): the bytes-less ConfigDb read the + // rebuild used to fall back to is gone — every producer carries the artifact bytes in the message. + _ = dbFactory; _applier = applier; _dbHealthProbe = dbHealthProbe; _dbLess = dbLess; @@ -350,6 +368,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers Receive(HandleAlarmUpdate); Receive(HandleAlarmQualityUpdate); Receive(HandleRebuild); + Receive(HandlePlanReady); + Receive(HandlePlanFailed); Receive(HandleServiceLevelChanged); Receive(HandleRedundancyStateChanged); Receive(HandleDbHealthStatus); @@ -480,114 +500,147 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } } - private void HandleRebuild(RebuildAddressSpace msg) + private void HandleRebuild(RebuildAddressSpace msg) => StartRebuild(msg, Sender); + + /// + /// The entry half of a rebuild, on the actor thread. The raw-sink fallback (no applier) and the + /// no-artifact case complete synchronously; otherwise the artifact parse, the cluster-scoped composition + /// and the plan — the O(artifact) work that used to serialise every live value batch and (because the + /// node manager's lock is the SDK's) every client Publish behind it — run on the thread pool and come + /// back as a . Only the sink materialisation () runs here. + /// + private void StartRebuild(RebuildAddressSpace msg, IActorRef requester) { - // Captured up front: the pipeline below is synchronous on this actor's thread today, but the reply - // must reach whoever asked even if a future edit introduces an await into it. - var requester = Sender; - (bool Degraded, string? Reason) outcome; - try - { - outcome = HandleRebuildCore(msg); - } - finally - { - // close the apply on EVERY exit, early returns and a throwing pass included: the node - // manager reports here the native conditions a rebuild carried over (after the notifier wiring the - // passes did) and discards any captured state no materialise claimed. Skipping it on a throw would - // leave that state for a later surgical re-add of the same id to resurrect. No-op when nothing is - // pending; never throws out of the handler. - try { _sink.CompleteAddressSpaceApply(); } - catch (Exception ex) - { - _log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation); - } - } - - // the outcome goes out only after the apply (including its completion step) is done. - if (msg.ReportOutcome) - requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason)); - } - - /// The rebuild pipeline. Returns whether the apply was degraded, and why. - private (bool Degraded, string? Reason) HandleRebuildCore(RebuildAddressSpace msg) - { - using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan(); - span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString()); - - // Two modes. With an applier wired, do a REAL diff-and-apply pass — driven either by the - // artifact bytes already in hand (a per-cluster-mesh driver-only / FetchAndCache node, whose - // ConfigDb is admin-only now so _dbFactory is null, hands its fetched/cached bytes in - // msg.Artifact) or, when no bytes are carried, by loading the artifact from the ConfigDb (the - // Direct path, which requires _dbFactory). Only a node with NO applier at all (the F10b/dev - // seam) falls back to the raw-sink rebuild — and that fallback WIPES the served address space - // without re-materialising, so it must NEVER be reached by a real driver node that has an - // applier but simply has no ConfigDb (that node diff-and-applies from its in-hand bytes below). + // Only a node with NO applier at all (the F10b/dev seam) falls back to the raw-sink rebuild — and that + // fallback WIPES the served address space without re-materialising, so it must NEVER be reached by a + // real driver node that has an applier (that node diff-and-applies from its in-hand bytes below). if (_applier is null) { + (bool Degraded, string? Reason) outcome; try { _sink.RebuildAddressSpace(); OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair("kind", "rebuild")); + outcome = (false, null); } catch (Exception ex) { - _log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})", - msg.Correlation); - return (true, $"address-space rebuild threw: {ex.Message}"); + _log.Error(ex, "OpcUaPublish: sink.RebuildAddressSpace threw (correlation={Correlation})", msg.Correlation); + outcome = (true, $"address-space rebuild threw: {ex.Message}"); } - return (false, null); + + FinishRebuild(msg, requester, outcome); + return; } + // An artifact we could not obtain is NOT an empty configuration. Parsing zero bytes yields an empty + // composition, which the planner diffs against the live one as a PureRemove and the applier then + // faithfully tears down. Nothing legitimate produces a zero-length blob (an operator who really deletes + // everything still deploys a JSON document with empty arrays), so the only honest reading of "no bytes" + // is "no answer" — hold the last-known-good address space and let the next deploy (or the next boot) + // supply a real one. Leaving _lastApplied intact means the retry diffs against what is materialised. + if (msg.Artifact is null or { Length: 0 }) + { + if (_hasAppliedComposition) + _log.Warning( + "OpcUaPublish: no usable artifact for deployment {Id} (correlation={Correlation}) — KEEPING the currently served address space rather than tearing it down", + msg.DeploymentId, msg.Correlation); + else + _log.Debug( + "OpcUaPublish: no artifact to materialise yet (deployment={Id}, correlation={Correlation})", + msg.DeploymentId, msg.Correlation); + FinishRebuild(msg, requester, (true, "no usable artifact reached the address-space apply; the previously served address space was kept")); + return; + } + + if (_planInFlight) + { + _pendingRebuilds.Enqueue((msg, requester)); + return; + } + + _planInFlight = true; + var artifact = msg.Artifact; + var baseComposition = _lastApplied; + var nodeId = _localNode?.Value; + var wantAuthority = _dataPlaneAuthority is not null; + Task.Run(() => + { + // Open() throws on bytes that are not valid JSON, where the static wrappers swallowed to an EMPTY + // result — and an empty composition is not a benign no-op here (see the no-bytes guard above). The + // throw lands in PlanFailed: logged at Error, _lastApplied untouched, last-known-good still served. + using var parsed = DeploymentArtifact.Open(artifact); + var authority = wantAuthority ? parsed.ParseDataPlaneAuthority(nodeId ?? string.Empty) : null; + var inconsistencies = new List(); + var composition = nodeId is not null + ? parsed.ParseComposition(nodeId, inconsistencies.Add) + : parsed.ParseComposition(); + var plan = AddressSpacePlanner.Compute(baseComposition, composition); + return new PlanReady(msg, requester, artifact, authority, composition, plan, inconsistencies); + }) + .PipeTo(Self, failure: ex => new PlanFailed(msg, requester, ex)); + } + + private void HandlePlanReady(PlanReady ready) + { + (bool Degraded, string? Reason) outcome; + try + { + outcome = ApplyPlan(ready); + } + finally + { + _planInFlight = false; + } + + FinishRebuild(ready.Request, ready.Requester, outcome); + StartNextPendingRebuild(); + } + + private void HandlePlanFailed(PlanFailed failed) + { + _planInFlight = false; + _log.Error(failed.Error, "OpcUaPublish: rebuild pipeline threw (correlation={Correlation})", failed.Request.Correlation); + FinishRebuild(failed.Request, failed.Requester, (true, $"address-space apply threw: {failed.Error.Message}")); + StartNextPendingRebuild(); + } + + private void StartNextPendingRebuild() + { + if (_pendingRebuilds.TryDequeue(out var next)) StartRebuild(next.Request, next.Requester); + } + + /// + /// Closes an apply on EVERY exit — early returns and a throwing pass included: the node manager reports + /// here the native conditions a rebuild carried over (after the notifier wiring the passes did) and + /// discards any captured state no materialise claimed. Skipping it on a throw would leave that state for a + /// later surgical re-add of the same id to resurrect. No-op when nothing is pending; never throws. The + /// outcome goes out only after the apply (including its completion step) is done. + /// + private void FinishRebuild(RebuildAddressSpace msg, IActorRef requester, (bool Degraded, string? Reason) outcome) + { + try { _sink.CompleteAddressSpaceApply(); } + catch (Exception ex) + { + _log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation); + } + + if (msg.ReportOutcome) + requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason)); + } + + /// The actor-thread half of a rebuild: install the artifact's authority, apply the plan through the + /// sink passes, announce. Returns whether the apply was degraded, and why. + private (bool Degraded, string? Reason) ApplyPlan(PlanReady ready) + { + var msg = ready.Request; + using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan(); + span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString()); + try { - // An in-hand artifact (boot-from-cache / FetchAndCache) wins: the host already has the - // cached/fetched bytes, and the ConfigDb read the DeploymentId path would do is exactly what - // is unreachable during the outage this exists to survive — and is simply absent on a - // driver-only node with no ConfigDb. Otherwise, when a ConfigDb is present (Direct mode), - // prefer the artifact of the deployment the host just applied — at apply time it is not yet - // Sealed, so LoadLatestArtifact would return the PREVIOUS revision and materialise a stale - // composition (variables that don't match the SubscribeBulk refs); fall back to latest-sealed - // only for legacy callers that carry neither. With NO bytes AND no ConfigDb there is nothing - // to load, so yield Array.Empty() — the guard below reads that as "no answer" and - // abandons the rebuild, holding last-known-good rather than wiping the address space. - var artifact = msg.Artifact - ?? (_dbFactory is not null - ? (msg.DeploymentId is { } depId ? LoadArtifact(depId) : LoadLatestArtifact()) - : Array.Empty()); - - // An artifact we could not obtain is NOT an empty configuration. Parsing zero - // bytes yields an empty composition, which the planner diffs against the live one as a - // PureRemove and the applier then faithfully tears down: a transient ConfigDb blip empties the - // served address space. Nothing legitimate produces a zero-length blob (an operator who really - // deletes everything still deploys a JSON document with empty arrays), so the only honest - // reading of "no bytes" is "no answer" — hold the last-known-good address space and let the - // next deploy (or the next boot) supply a real one. Returning here also leaves _lastApplied - // intact, so the retry diffs against what is actually materialised. - if (artifact is null or { Length: 0 }) - { - if (_hasAppliedComposition) - _log.Warning( - "OpcUaPublish: no usable artifact for deployment {Id} (correlation={Correlation}) — KEEPING the currently served address space rather than tearing it down", - msg.DeploymentId, msg.Correlation); - else - _log.Debug( - "OpcUaPublish: no artifact to materialise yet (deployment={Id}, correlation={Correlation})", - msg.DeploymentId, msg.Correlation); - return (true, "no usable artifact reached the address-space apply; the previously served address space was kept"); - } - - // Parse the artifact ONCE for this rebuild. Every consumer below (data-plane authority, then the - // composition) reads this one JsonDocument instead of rebuilding it per question — the rebuild - // used to pay a full parse-and-throw-away per static call. Disposed when the handler returns. - // - // Open() throws on bytes that are not valid JSON, where the static wrappers swallowed to an - // EMPTY result — and an empty composition is not a benign no-op here: the planner diffs it as a - // PureRemove and the applier tears the served address space down. So the throw is the honest - // outcome, and it lands in this method's catch below: logged at Error, _lastApplied untouched, - // last-known-good still served. Same "no answer ≠ empty configuration" rule as the guard - // immediately above, applied to bytes that arrived unreadable rather than absent. - using var parsed = DeploymentArtifact.Open(artifact); + foreach (var inconsistency in ready.Inconsistencies) + _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", _localNode, inconsistency); // install this artifact's API keys + ACL grants BEFORE the address-space work // below, and deliberately ahead of the `plan.IsEmpty` early-return: an artifact that revokes a @@ -595,13 +648,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // feeding this after that return would leave a revoked key authenticating until some unrelated // structural change happened to redeploy. Authorization and topology change on their own // schedules. - FeedDataPlaneAuthority(parsed, artifact); + if (ready.Authority is not null) FeedDataPlaneAuthority(ready.Authority, ready.Artifact); - var composition = _localNode is { } ln - ? parsed.ParseComposition(ln.Value, - inconsistency => _log.Warning("OpcUaPublish {Node}: cross-cluster binding — {Message}", ln, inconsistency)) - : parsed.ParseComposition(); - var plan = AddressSpacePlanner.Compute(_lastApplied, composition); + var composition = ready.Composition; + var plan = ready.Plan; if (plan.IsEmpty) { @@ -610,7 +660,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // The composition is unchanged but the last apply of it was a degraded // rebuild: re-ensure every node (each Ensure* no-ops on a node that stands) so the // re-deploy actually repairs what that rebuild could not place. - var repairFailed = _applier.MaterialiseHierarchy(composition) + var repairFailed = _applier!.MaterialiseHierarchy(composition) + MaterialiseNodes(plan, composition, fullComposition: true); if (repairFailed > 0) { @@ -632,7 +682,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers return (false, null); } - var outcome = _applier.Apply(plan); + var outcome = _applier!.Apply(plan); // Sum swallowed per-node materialise failures across every pass together with Apply's own // removal-pass tally (archreview 01/S-1). A degraded apply used to vanish into per-node @@ -867,17 +917,15 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// the safe direction for availability but means a revocation carried by an artifact this node /// could not decode has not taken effect. It is logged at Error for exactly that reason. /// - /// The rebuild's single parse of the artifact, read for the keys + grants. + /// The keys + grants decoded (off-thread) from the rebuild's single parse of the artifact. /// The RAW artifact bytes — hashed for the revision id, which must be /// SHA-256 of the exact blob (see below); never re-parsed here. - private void FeedDataPlaneAuthority(ParsedDeploymentArtifact parsed, byte[] artifactBytes) + private void FeedDataPlaneAuthority(DataPlaneAuthorityProjection projection, byte[] artifactBytes) { if (_dataPlaneAuthority is null) return; try { - var nodeId = _localNode?.Value ?? string.Empty; - var projection = parsed.ParseDataPlaneAuthority(nodeId); // The revision hash is SHA-256 of the artifact bytes — the same derivation ConfigComposer uses, // which is what lets a node recognise a re-applied revision without the hash riding the wire. // Hashed off the RAW bytes for exactly that reason: a re-serialisation of the parsed document @@ -922,46 +970,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } } - /// Read a specific deployment's artifact blob from ConfigDb (the one just applied, - /// which may not be Sealed yet). Empty array on any failure — treats that as - /// "no answer" and abandons the rebuild, leaving the served address space untouched. - private byte[] LoadArtifact(DeploymentId deploymentId) - { - try - { - using var db = _dbFactory!.CreateDbContext(); - return db.Deployments.AsNoTracking() - .Where(d => d.DeploymentId == deploymentId.Value) - .Select(d => d.ArtifactBlob) - .FirstOrDefault() ?? Array.Empty(); - } - catch (Exception ex) - { - _log.Warning(ex, "OpcUaPublish: failed to load artifact for deployment {Id}; the rebuild is abandoned", deploymentId); - return Array.Empty(); - } - } - - /// Read the most recent Sealed deployment's artifact blob from ConfigDb. - /// Empty array on any failure — see for how that is handled. - private byte[] LoadLatestArtifact() - { - try - { - using var db = _dbFactory!.CreateDbContext(); - return db.Deployments.AsNoTracking() - .Where(d => d.Status == Configuration.Enums.DeploymentStatus.Sealed) - .OrderByDescending(d => d.SealedAtUtc) - .Select(d => d.ArtifactBlob) - .FirstOrDefault() ?? Array.Empty(); - } - catch (Exception ex) - { - _log.Warning(ex, "OpcUaPublish: failed to load latest deployment artifact; the rebuild is abandoned"); - return Array.Empty(); - } - } - private void HandleServiceLevelChanged(ServiceLevelChanged msg) { // Always publish the FIRST computed level, even if it equals the byte-default 0. Otherwise a diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs index 8d1fd67e..6c07ca0f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorApplyFailureTests.cs @@ -50,13 +50,13 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); // First deploy: PureAdd — no rebuild, so the throwing sink is not hit yet. - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); Thread.Sleep(200); recorder.Total.ShouldBe(0); // Second deploy: eq-1 RENAMED ⇒ ChangedEquipment ⇒ Rebuild ⇒ the sink's RebuildAddressSpace throws. var dep2 = SeedEquipmentDeployment(db, ("eq-1", "eq-1-renamed")); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); // A PRESENCE budget, so it is an upper bound before giving up rather than a wait: the helper polls // and returns the instant the meter fires, so a generous value costs nothing on the happy path and @@ -78,10 +78,10 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase var db = NewInMemoryDbFactory(); var sink = new NoopSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - SeedEquipmentDeployment(db, ("eq-1", "eq-1")); + var dep = SeedEquipmentDeployment(db, ("eq-1", "eq-1")); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + actor.Tell(Rebuild(dep)); // Give the actor time to process, then confirm the failed meter never fired. Thread.Sleep(400); @@ -100,6 +100,11 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase if (last is not null) throw last; } + private static readonly System.Collections.Concurrent.ConcurrentDictionary Artifacts = new(); + + private static OpcUaPublishActor.RebuildAddressSpace Rebuild(Guid deploymentId) => + new(CorrelationId.NewId(), new DeploymentId(deploymentId), Artifacts[deploymentId]); + private static Guid SeedEquipmentDeployment(IDbContextFactory dbFactory, params (string Id, string Name)[] equipment) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new @@ -116,6 +121,7 @@ public sealed class OpcUaPublishActorApplyFailureTests : RuntimeActorTestBase }); var id = Guid.NewGuid(); + Artifacts[id] = artifact; using var ctx = dbFactory.CreateDbContext(); ctx.Deployments.Add(new Deployment { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorOffThreadPlanTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorOffThreadPlanTests.cs new file mode 100644 index 00000000..734edbba --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorOffThreadPlanTests.cs @@ -0,0 +1,128 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Akka.Actor; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa; + +/// +/// Review 02 P5 — a rebuild's artifact parse + plan no longer run on the pinned OPC UA thread. The +/// observable: value batches sent right after a large rebuild land on the sink BEFORE that rebuild's first +/// materialise call, because the actor is free while the thread pool parses and plans; two rebuilds sent +/// back to back still apply in arrival order; a plan that throws still answers the requester degraded. +/// +public sealed class OpcUaPublishActorOffThreadPlanTests : RuntimeActorTestBase +{ + [Fact] + public void Value_batches_keep_flowing_while_a_large_plan_computes() + { + var sink = new OrderedSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier)); + // A value node the batch names; the sink is a recorder, so it need not exist. + var batch = new OpcUaPublishActor.AttributeValueBatchUpdate( + [new ValueWrite("Plant/Modbus/dev1/live", 1, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw)]); + + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), RawTagArtifact(50_000), ReportOutcome: true)); + actor.Tell(batch); + + var completed = ExpectMsg(TimeSpan.FromSeconds(120)); + completed.Degraded.ShouldBeFalse(completed.DegradedReason); + + var calls = sink.Calls.ToList(); + var batchIndex = calls.IndexOf("WV:Plant/Modbus/dev1/live"); + var firstMaterialise = calls.FindIndex(c => c.StartsWith("EV:", StringComparison.Ordinal) || c.StartsWith("EF:", StringComparison.Ordinal)); + batchIndex.ShouldBeGreaterThanOrEqualTo(0, "the batch must reach the sink"); + firstMaterialise.ShouldBeGreaterThanOrEqualTo(0, "the plan must still be applied (positive control)"); + batchIndex.ShouldBeLessThan(firstMaterialise, "the batch sent after the rebuild must land before the rebuild's materialisation — the parse/plan step no longer holds the actor thread"); + calls.Count(c => c.StartsWith("EV:", StringComparison.Ordinal)).ShouldBe(50_000); + } + + [Fact] + public void Back_to_back_rebuilds_apply_in_arrival_order() + { + var sink = new OrderedSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier)); + + // The first (large) plan is still computing when the second (tiny) arrives; the second must wait for + // it — its plan is a diff against what the first applies. + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), RawTagArtifact(20_000), ReportOutcome: true)); + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), RawTagArtifact(2), ReportOutcome: true)); + + var first = ExpectMsg(TimeSpan.FromSeconds(120)); + var second = ExpectMsg(TimeSpan.FromSeconds(120)); + first.Degraded.ShouldBeFalse(first.DegradedReason); + second.Degraded.ShouldBeFalse(second.DegradedReason); + + // The second composition is a strict subset (2 of the same tags), so it diffs as a PureRemove of the + // other 19,998 — which only works if it was planned AFTER the first apply, against its composition. + sink.Calls.Count(c => c.StartsWith("RV:", StringComparison.Ordinal)).ShouldBe(19_998); + } + + [Fact] + public void Unreadable_bytes_answer_degraded_and_keep_the_served_space() + { + var sink = new OrderedSink(); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, applier: applier)); + + actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), "not json"u8.ToArray(), ReportOutcome: true)); + + var completed = ExpectMsg(PresenceBudget); + completed.Degraded.ShouldBeTrue(); + completed.DegradedReason.ShouldNotBeNull().ShouldContain("threw"); + sink.Calls.ShouldBeEmpty("nothing may be torn down for bytes that could not be read"); + sink.Completed.ShouldBe(1, "the apply is still closed"); + } + + private static byte[] RawTagArtifact(int tagCount) => + JsonSerializer.SerializeToUtf8Bytes(new + { + UnsAreas = Array.Empty(), + UnsLines = Array.Empty(), + Equipment = Array.Empty(), + RawFolders = new[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } }, + Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, + TagGroups = Array.Empty(), + Tags = Enumerable.Range(0, tagCount).Select(i => new + { + TagId = $"t-{i}", DeviceId = "dev-1", TagGroupId = (string?)null, Name = $"T{i}", + DataType = "Float", AccessLevel = 0, TagConfig = "{}", + }).ToArray(), + UnsTagReferences = Array.Empty(), + ScriptedAlarms = Array.Empty(), + }); + + private sealed class OrderedSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink + { + public ConcurrentQueue Calls { get; } = new(); + public int Completed; + + public void WriteValue(string nodeId, object? value, uint statusCode, DateTime ts, AddressSpaceRealm realm) => Calls.Enqueue($"WV:{nodeId}"); + public void WriteValues(IReadOnlyList values) { foreach (var v in values) Calls.Enqueue($"WV:{v.NodeId}"); } + public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime ts, AddressSpaceRealm realm) { } + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm) => Calls.Enqueue($"WA:{alarmNodeId}"); + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) => Calls.Enqueue($"MA:{alarmNodeId}"); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) => Calls.Enqueue($"EF:{folderNodeId}"); + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) => Calls.Enqueue($"EV:{variableNodeId}"); + public void RebuildAddressSpace() => Calls.Enqueue("RB"); + public void CompleteAddressSpaceApply() => Interlocked.Increment(ref Completed); + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) => Calls.Enqueue($"NA:{affectedNodeId}"); + public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } + public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) { Calls.Enqueue($"UT:{variableNodeId}"); return true; } + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm) => true; + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm) { Calls.Enqueue($"RV:{variableNodeId}"); return true; } + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm) { Calls.Enqueue($"RA:{alarmNodeId}"); return true; } + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm) { Calls.Enqueue($"RE:{equipmentNodeId}"); return true; } + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs index 2e0d239d..fd05748c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorRebuildTests.cs @@ -41,14 +41,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" }); + var dep = SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" }); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + actor.Tell(Rebuild(dep)); AwaitAssert(() => { @@ -72,10 +72,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" }); + var dep = SeedDeployment(db, equipmentIds: new[] { "eq-1", "eq-2" }, driverIds: new[] { "drv-1" }); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + actor.Tell(Rebuild(dep)); AwaitAssert(() => sink.CompletedAfterCallCount.Count.ShouldBe(1), duration: PresenceBudget); sink.CompletedAfterCallCount.Single().ShouldBe(sink.Calls.Count, "nothing may follow the completion"); @@ -126,17 +126,17 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty()); + var dep = SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty()); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + actor.Tell(Rebuild(dep)); AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); // PureAdd — no full rebuild var callsAfterFirst = sink.Calls.Count; - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + actor.Tell(Rebuild(dep)); Thread.Sleep(200); // Same composition ⇒ plan IsEmpty ⇒ applier + materialise passes not run again. sink.Calls.Count.ShouldBe(callsAfterFirst); @@ -232,7 +232,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// and drives the applier. The guard split must leave the Direct path byte-identical. /// [Fact] - public void Rebuild_direct_mode_with_dbFactory_and_deploymentId_still_loads_and_applies() + public void Rebuild_with_deploymentId_and_in_hand_bytes_applies() { var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); @@ -242,7 +242,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep))); + actor.Tell(Rebuild(dep)); AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); @@ -267,7 +267,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dbA = NewInMemoryDbFactory(); var sinkA = new RecordingSink(); var applierA = new AddressSpaceApplier(sinkA, NullLogger.Instance); - SeedMultiClusterDeployment(dbA); + var depA = SeedMultiClusterDeployment(dbA); var siteActor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( sink: sinkA, @@ -275,7 +275,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase applier: applierA, localNode: NodeId.Parse("site-a-1:4053"))); - siteActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + siteActor.Tell(Rebuild(depA)); // PureAdd ⇒ no full rebuild; the materialise passes still run the cluster slice. AwaitAssert(() => sinkA.Calls.ShouldContain("EV:SA/Modbus/dev-sa/S1"), duration: PresenceBudget); @@ -287,7 +287,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dbM = NewInMemoryDbFactory(); var sinkM = new RecordingSink(); var applierM = new AddressSpaceApplier(sinkM, NullLogger.Instance); - SeedMultiClusterDeployment(dbM); + var depM = SeedMultiClusterDeployment(dbM); var mainActor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( sink: sinkM, @@ -295,7 +295,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase applier: applierM, localNode: NodeId.Parse("central-1:4053"))); - mainActor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + mainActor.Tell(Rebuild(depM)); AwaitAssert(() => sinkM.Calls.ShouldContain("EV:Main/Modbus/dev-main/M1"), duration: PresenceBudget); sinkM.RebuildCalls.ShouldBe(0); @@ -308,7 +308,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase /// tag chain plus its UNS area/line/equipment. Used by /// . /// - private static void SeedMultiClusterDeployment(IDbContextFactory dbFactory) + private static Guid SeedMultiClusterDeployment(IDbContextFactory dbFactory) { var artifact = JsonSerializer.SerializeToUtf8Bytes(new { @@ -358,17 +358,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase ScriptedAlarms = Array.Empty(), }); - using var ctx = dbFactory.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = Guid.NewGuid(), - RevisionHash = new string('b', 64), - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); + return SealArtifact(dbFactory, artifact); } /// R2-07 T4b — a PureAdd deploy (here a single new equipment) does NOT rebuild, and the @@ -381,10 +371,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var db = NewInMemoryDbFactory(); var sink = new RecordingSink(); var applier = new AddressSpaceApplier(sink, NullLogger.Instance); - SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty()); + var dep = SeedDeployment(db, equipmentIds: new[] { "eq-1" }, driverIds: Array.Empty()); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId())); + actor.Tell(Rebuild(dep)); AwaitAssert(() => sink.Calls.ShouldContain("NA:line-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); @@ -410,7 +400,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep1 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1")); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.Count(c => c.StartsWith("NA:")).ShouldBe(1), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); var naAfterFirst = sink.Calls.Count(c => c.StartsWith("NA:")); @@ -418,7 +408,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase // Second deploy: eq-1 RENAMED (ChangedEquipment ⇒ Rebuild) AND a new eq-2 (an add). The rebuild // fires, so the announce hook is skipped despite eq-2 being added. var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1-RENAMED"), ("eq-2", "Pump-2")); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); // No new NodeAdded announcement was raised for the rebuild-kind deploy. @@ -442,7 +432,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep1 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"), ("eq-2", "Pump-2")); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: PresenceBudget); var callsAfterFirst = sink.Calls.Count; @@ -458,7 +448,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase // …and the actor still remembers WHAT is applied: once the database is back, re-applying the same // composition diffs to an empty plan. A clobbered _lastApplied would re-materialise everything. db.Fail = false; - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); Thread.Sleep(200); sink.Calls.Count.ShouldBe(callsAfterFirst); } @@ -477,12 +467,12 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep1 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1"), ("eq-2", "Pump-2")); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EF:eq-2"), duration: PresenceBudget); // eq-2 really is gone from the next artifact — that IS a configuration change, so it must apply. var dep2 = SeedNamedEquipmentDeployment(db, ("eq-1", "Pump-1")); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => sink.Calls.ShouldContain("RE:eq-2"), duration: PresenceBudget); } @@ -534,24 +524,10 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase IDbContextFactory dbFactory, params (string Id, string Name)[] equipment) { - var artifact = BuildNamedEquipmentArtifact(equipment); - - var id = Guid.NewGuid(); - using var ctx = dbFactory.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id, - RevisionHash = new string('d', 64), - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; + return SealArtifact(dbFactory, BuildNamedEquipmentArtifact(equipment)); } - private static void SeedDeployment( + private static Guid SeedDeployment( IDbContextFactory dbFactory, string[] equipmentIds, string[] driverIds) @@ -575,17 +551,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase ScriptedAlarms = Array.Empty(), }); - using var ctx = dbFactory.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = Guid.NewGuid(), - RevisionHash = new string('a', 64), - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); + return SealArtifact(dbFactory, artifact); } /// OpcUaServer-001 — a deploy whose ONLY change is a UNS Area rename (the area Name differs; @@ -606,14 +572,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase // First deploy: the area folder is materialised with the OLD name. R2-07 — this is now a PureAdd // (area + line + equipment all added), so NO full rebuild; the folder is materialised directly. - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EF:area-1"), duration: PresenceBudget); sink.RebuildCalls.ShouldBe(0); // Second deploy: ONLY the area Name changed — a rename. The actor must reach the apply path and // drive a surgical in-place folder rename (NOT a rebuild, NOT a no-op). var dep2 = SeedAreaDeployment(db, areaName: "Plant South"); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => { @@ -639,19 +605,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase ScriptedAlarms = Array.Empty(), }); - var id = Guid.NewGuid(); - using var ctx = dbFactory.CreateDbContext(); - ctx.Deployments.Add(new Deployment - { - DeploymentId = id, - RevisionHash = new string('c', 64), - Status = DeploymentStatus.Sealed, - CreatedBy = "test", - SealedAtUtc = DateTime.UtcNow, - ArtifactBlob = artifact, - }); - ctx.SaveChanges(); - return id; + return SealArtifact(dbFactory, artifact); } // --------------------------------------------------------------------------------------------------- @@ -677,13 +631,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep1 = SeedRawTagDeployment(db, new[] { "A", "B", "C" }); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/C"), duration: PresenceBudget); var afterFirst = sink.Calls.Count; // One new tag on an otherwise identical configuration. var dep2 = SeedRawTagDeployment(db, new[] { "A", "B", "C", "D" }); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/D"), duration: PresenceBudget); Thread.Sleep(200); // let any (unwanted) extra pass finish before counting @@ -707,13 +661,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a"); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/b"), duration: PresenceBudget); var afterFirst = sink.Calls.Count; // Renaming the equipment is a ChangedEquipment delta ⇒ the classifier's default-closed Rebuild. var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a"); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); AwaitAssert( () => sink.Calls.Skip(afterFirst).ShouldContain("EV:Plant/Modbus/dev1/b"), @@ -746,13 +700,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a"); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EV:filling/line1/station1/a"), duration: PresenceBudget); var afterFirst = sink.Calls.Count; // Re-point the SAME reference row at raw tag "b". var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "b"); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => sink.Calls.ShouldContain("EV:filling/line1/station1/b"), duration: PresenceBudget); var second = sink.Calls.Skip(afterFirst).ToList(); @@ -784,13 +738,13 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a"); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EV:filling/line1/station1/a"), duration: PresenceBudget); var afterFirst = sink.Calls.Count; var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a", historianTagnameForA: "WW.A"); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); // The mirror now carries the backing tag's historian tagname — the repair itself, unchanged by I2. AwaitAssert( @@ -818,12 +772,12 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep1 = SeedRawTagDeployment(db, new[] { "A", "B" }); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/B"), duration: PresenceBudget); var afterFirst = sink.Calls.Count; var dep2 = SeedRawTagDeployment(db, new[] { "A", "B" }, historizedTag: "A"); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => sink.Calls.ShouldContain("UT:Plant/Modbus/dev1/A"), duration: PresenceBudget); Thread.Sleep(200); // let any (unwanted) extra pass finish before counting @@ -855,7 +809,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep1 = SeedRawTagDeployment(db, new[] { "A", "B" }); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/B"), duration: PresenceBudget); Thread.Sleep(200); sink.RebuildCalls.ShouldBe(0); // genuinely a non-rebuild (PureAdd) apply @@ -867,7 +821,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase // EMPTY plan and A would be gone from the served address space forever. sink.FailEnsureVariableFor = null; var dep2 = SeedRawTagDeployment(db, new[] { "A", "B" }); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); AwaitAssert( () => sink.Calls.Skip(afterFirst).ShouldContain("EV:Plant/Modbus/dev1/A"), @@ -894,14 +848,14 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase var dep1 = SeedUnsRawDeployment(db, equipmentName: "station1", referencedTag: "a"); var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests(sink: sink, dbFactory: db, applier: applier)); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep1))); + actor.Tell(Rebuild(dep1)); AwaitAssert(() => sink.Calls.ShouldContain("EV:Plant/Modbus/dev1/b"), duration: PresenceBudget); // Renaming the equipment is ChangedEquipment ⇒ the classifier's default-closed Rebuild; the sink // faults on one variable of the full re-materialise that follows. sink.FailEnsureVariableFor = "Plant/Modbus/dev1/b"; var dep2 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a"); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep2))); + actor.Tell(Rebuild(dep2)); AwaitAssert(() => sink.RebuildCalls.ShouldBe(1), duration: PresenceBudget); Thread.Sleep(200); var afterSecond = sink.Calls.Count; @@ -911,7 +865,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase // rebuild again) — but the degraded rebuild is remembered, so the failed variable is re-ensured. sink.FailEnsureVariableFor = null; var dep3 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a"); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep3))); + actor.Tell(Rebuild(dep3)); AwaitAssert( () => sink.Calls.Skip(afterSecond).ShouldContain("EV:Plant/Modbus/dev1/b"), duration: PresenceBudget); @@ -921,7 +875,7 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase Thread.Sleep(200); var afterRepair = sink.Calls.Count; var dep4 = SeedUnsRawDeployment(db, equipmentName: "station1-renamed", referencedTag: "a"); - actor.Tell(new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), new DeploymentId(dep4))); + actor.Tell(Rebuild(dep4)); Thread.Sleep(300); sink.Calls.Count.ShouldBe(afterRepair); sink.RebuildCalls.ShouldBe(1); @@ -1017,10 +971,19 @@ public sealed class OpcUaPublishActorRebuildTests : RuntimeActorTestBase return SealArtifact(dbFactory, artifact); } + /// The artifact bytes behind each sealed deployment id, so a test can hand the actor the bytes its + /// production producers always carry (review 02 P5 removed the actor's ConfigDb fallback). + private static readonly System.Collections.Concurrent.ConcurrentDictionary Artifacts = new(); + + /// The rebuild message a producer sends for deployment : its id AND its bytes. + private static OpcUaPublishActor.RebuildAddressSpace Rebuild(Guid deploymentId) => + new(CorrelationId.NewId(), new DeploymentId(deploymentId), Artifacts[deploymentId]); + /// Seal as a new deployment and return its id. private static Guid SealArtifact(IDbContextFactory dbFactory, byte[] artifact) { var id = Guid.NewGuid(); + Artifacts[id] = artifact; using var ctx = dbFactory.CreateDbContext(); ctx.Deployments.Add(new Deployment { From d90f35b6bede758dd76c5b90afd7bff0ebdaed97 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:43:21 -0400 Subject: [PATCH 07/67] fix(opcua): refresh historian capability bits on late source/updater assignment (B7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 01 U3. EnsureFolderIsEventNotifier granted the EventNotifiers .HistoryRead bit (and registered the event-history source) only when a real HistorianDataSource was wired at promotion time, and EnsureVariable composed the AccessLevels.HistoryWrite bit from whether a HistorianDataUpdater existed at materialise time — so an assignment after the first apply left existing folders without event history and existing nodes refused by the SDK's HistoryUpdate admission until a full rebuild, and a source reset to Null never took the bit back. The two setters now re-evaluate: HistorianDataSource walks every promoted folder (bit + source registration, both directions), HistorianDataUpdater recomposes the AccessLevel of every historized variable (Writable read back off the node's own CurrentWrite bit). The Host's boot ordering is no longer load-bearing. Tests: NodeManagerCapabilityRefreshTests (late source lights the bit on an already-promoted folder and a reset takes it back, live-event subscription untouched; late updater grants HistoryWrite to historized nodes only and a reset takes it back, authored Writable preserved). Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OtOpcUaNodeManager.cs | 95 ++++++++++--- .../NodeManagerCapabilityRefreshTests.cs | 125 ++++++++++++++++++ 2 files changed, 205 insertions(+), 15 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 8f03cd75..4edeac81 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -368,9 +368,73 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { _historianDataSource = value ?? NullHistorianDataSource.Instance; SyncAdvertisedAggregates(); + RefreshEventHistoryCapability(); } } + /// + /// Review 01 U3 — re-evaluates the bit (and the event-history + /// source registration resolves against) on every folder already promoted + /// to an event notifier, so a assigned AFTER the first apply advertises + /// event history without a full rebuild — and a source reset to the Null default takes the bit back + /// instead of leaving folders answering BadHistoryOperationUnsupported. Folders promoted later go + /// through , which reads the same source. + /// + private void RefreshEventHistoryCapability() + { + var wired = _historianDataSource is not NullHistorianDataSource; + lock (Lock) + { + foreach (var (nodeId, folder) in _notifierFolders) + { + var has = (folder.EventNotifier & EventNotifiers.HistoryRead) != 0; + if (has == wired) continue; + var key = MapKey(nodeId); + if (wired) + { + folder.EventNotifier = (byte)(folder.EventNotifier | EventNotifiers.HistoryRead); + _eventNotifierSources[key] = nodeId.Identifier?.ToString() ?? string.Empty; + } + else + { + folder.EventNotifier = (byte)(folder.EventNotifier & ~EventNotifiers.HistoryRead); + _eventNotifierSources.TryRemove(key, out _); + } + + folder.ClearChangeMasks(SystemContext, includeChildren: false); + } + } + } + + /// + /// Review 01 U3 — re-evaluates the bit on every historized + /// variable, so a assigned (or cleared) after nodes exist changes what + /// the SDK base admits to HistoryUpdate without a full rebuild. Writable is read back off the node's + /// own CurrentWrite bit — the authored value was given. + /// + private void RefreshHistoryWriteCapability() + { + var historyWritable = _historianDataUpdater is not null; + lock (Lock) + { + foreach (var (key, variable) in _variables) + { + if (!_historizedTagnames.ContainsKey(key)) continue; + var writable = (variable.AccessLevel & AccessLevels.CurrentWrite) != 0; + var access = ComposeAccessLevel(writable, historized: true, historyWritable); + if (variable.AccessLevel == access && variable.UserAccessLevel == access) continue; + variable.AccessLevel = access; + variable.UserAccessLevel = access; + variable.ClearChangeMasks(SystemContext, includeChildren: false); + } + } + } + + /// Test observability: whether is registered as an event-history + /// source (what accepts). + internal bool IsEventHistorySource(string folderNodeId, AddressSpaceRealm realm = AddressSpaceRealm.Uns) => + _eventNotifierSources.ContainsKey(MapKey(realm, folderNodeId)); + /// /// Trim HistoryServerCapabilities/AggregateFunctions (i=11201) to the current source's /// (null ⇒ all, ). @@ -408,20 +472,23 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// delete of historized samples). null (the default) means the selected historian backend /// cannot modify history: every HistoryUpdate is reported BadHistoryOperationUnsupported. /// - /// ⚠️ Assign it before any node materialises. Whether an updater is present decides the - /// HistoryWrite AccessLevel bit a historized node is created with - /// (), and the SDK base admits a node to HistoryUpdate only when - /// that bit is set — so the bit is dispatch-load-bearing, not cosmetic. Setting the updater after - /// nodes exist does NOT retro-update their AccessLevel. The Host assigns it in - /// OtOpcUaServerHostedService.StartAsync before installing the address-space sink, which - /// precedes every deploy apply. Volatile for the same start-thread / request-thread handoff as - /// . + /// Whether an updater is present decides the HistoryWrite AccessLevel bit a historized node + /// carries (), and the SDK base admits a node to HistoryUpdate only + /// when that bit is set — so the bit is dispatch-load-bearing, not cosmetic. Assigning the updater + /// after nodes exist re-evaluates the bit on every historized node (review 01 U3 — + /// ), so the Host's ordering (assigned in + /// OtOpcUaServerHostedService.StartAsync before the sink is installed) is no longer load-bearing. + /// Volatile for the same start-thread / request-thread handoff as . /// /// public IHistorianDataUpdater? HistorianDataUpdater { get => _historianDataUpdater; - set => _historianDataUpdater = value; + set + { + _historianDataUpdater = value; + RefreshHistoryWriteCapability(); + } } /// @@ -2914,12 +2981,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 /// ), the folder ALSO gets the /// bit OR-ed in (keeping SubscribeToEvents) and registers /// its NodeId identifier as an event-history source so the override - /// accepts it. The HistoryRead-events bit is therefore only advertised when a historian is wired at - /// the moment of promotion: the Host wires the source at StartAsync — BEFORE any deployment - /// materialises alarms — so the normal boot ordering promotes folders with the bit set. A folder - /// promoted while the source is still Null advertises live-event subscription but NOT event history - /// until the next re-promotes it (acceptable, documented). The - /// HistoryRead bit + source registration happen inside the same first-time + /// accepts it. A folder promoted while the source is still Null advertises live-event subscription + /// only; a later assignment re-evaluates every promoted folder + /// (review 01 U3 — ), so the Host's boot ordering is no + /// longer load-bearing. The HistoryRead bit + source registration happen inside the same first-time /// block so the idempotency guard covers them too. /// private void EnsureFolderIsEventNotifier(FolderState folder) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs new file mode 100644 index 00000000..428f74a2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerCapabilityRefreshTests.cs @@ -0,0 +1,125 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Review 01 U3 — the historian capability bits were fixed at promotion / materialise time, so a +/// or +/// assigned after the first apply left existing folders without event history (and existing nodes +/// without HistoryUpdate) until a full rebuild, and a source reset to Null never took the bit back. +/// Both setters now re-evaluate the bits on every existing node. +/// +public sealed class NodeManagerCapabilityRefreshTests : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + + private readonly string _pkiRoot = Path.Combine(Path.GetTempPath(), $"otopcua-caprefresh-{Guid.NewGuid():N}"); + + [Fact] + public async Task A_late_historian_source_grants_event_history_to_already_promoted_folders_and_a_reset_takes_it_back() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureFolder("eq-1", null, "Equipment 1", AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alm-1", "eq-1", "HighTemp", "OffNormalAlarm", 700, AddressSpaceRealm.Uns); + var folder = nm.TryGetFolder("eq-1")!; + + // Promoted while the source is Null: live events only (non-vacuity for the refresh). + (folder.EventNotifier & EventNotifiers.SubscribeToEvents).ShouldNotBe((byte)0); + (folder.EventNotifier & EventNotifiers.HistoryRead).ShouldBe((byte)0); + nm.IsEventHistorySource("eq-1").ShouldBeFalse(); + + nm.HistorianDataSource = new StubSource(); + + (folder.EventNotifier & EventNotifiers.HistoryRead).ShouldNotBe((byte)0, "a late source must light the bit on the existing folder"); + nm.IsEventHistorySource("eq-1").ShouldBeTrue(); + + nm.HistorianDataSource = null!; // the setter maps null to the Null source + + (folder.EventNotifier & EventNotifiers.HistoryRead).ShouldBe((byte)0, "a reset source must take the bit back"); + (folder.EventNotifier & EventNotifiers.SubscribeToEvents).ShouldNotBe((byte)0, "live-event subscription is untouched"); + nm.IsEventHistorySource("eq-1").ShouldBeFalse(); + } + finally { await host.DisposeAsync(); } + } + + [Fact] + public async Task A_late_updater_grants_history_write_to_historized_nodes_only_and_a_reset_takes_it_back() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureVariable("Plant/dev1/hist", null, "hist", "Float", writable: true, realm: AddressSpaceRealm.Raw, historianTagname: "Hist.A"); + nm.EnsureVariable("Plant/dev1/plain", null, "plain", "Float", writable: false, realm: AddressSpaceRealm.Raw); + var hist = nm.TryGetVariable("Plant/dev1/hist", AddressSpaceRealm.Raw)!; + var plain = nm.TryGetVariable("Plant/dev1/plain", AddressSpaceRealm.Raw)!; + (hist.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0, "no updater at materialise time"); + + nm.HistorianDataUpdater = new StubUpdater(); + + (hist.AccessLevel & AccessLevels.HistoryWrite).ShouldNotBe((byte)0); + (hist.UserAccessLevel & AccessLevels.HistoryWrite).ShouldNotBe((byte)0); + (hist.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe((byte)0, "the authored Writable survives the refresh"); + (hist.AccessLevel & AccessLevels.HistoryRead).ShouldNotBe((byte)0); + (plain.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0, "a non-historized node never gets HistoryWrite"); + + nm.HistorianDataUpdater = null; + + (hist.AccessLevel & AccessLevels.HistoryWrite).ShouldBe((byte)0); + (hist.AccessLevel & AccessLevels.CurrentWrite).ShouldNotBe((byte)0); + } + finally { await host.DisposeAsync(); } + } + + private async Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync() => + await OpcUaHostStart.StartAsync( + port => new OpcUaApplicationHost( + new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.CapRefreshTest", + ApplicationUri = $"urn:OtOpcUa.CapRefreshTest:{Guid.NewGuid():N}", + OpcUaPort = port, + PublicHostname = "localhost", + PkiStoreRoot = _pkiRoot, + }, + NullLogger.Instance), + () => new OtOpcUaSdkServer(), Ct); + + public void Dispose() + { + if (Directory.Exists(_pkiRoot)) Directory.Delete(_pkiRoot, recursive: true); + } + + private sealed class StubSource : IHistorianDataSource + { + public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct); + public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); + public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); + public Task ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, ct); + public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); + public void Dispose() { } + } + + private sealed class StubUpdater : IHistorianDataUpdater + { + public Task> UpdateDataAsync(string tag, HistoryUpdateMode mode, IReadOnlyList values, HistoryUpdateContext context, CancellationToken ct) => + throw new NotSupportedException(); + public Task DeleteRawAsync(string tag, DateTime startUtc, DateTime endUtc, HistoryUpdateContext context, CancellationToken ct) => + throw new NotSupportedException(); + public Task> DeleteAtTimeAsync(string tag, IReadOnlyList timesUtc, HistoryUpdateContext context, CancellationToken ct) => + throw new NotSupportedException(); + } +} From 8a1116e658e601fa9412c7339804ef03e413b3be Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:43:21 -0400 Subject: [PATCH 08/67] fix(opcua): fit the raw end bound against the page's effective cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit H1 fuzz finding (routed to Lane B because AppendEndBoundAsync lives in OtOpcUaNodeManager.cs): a bounded HistoryRead-Raw page sized BELOW NumValuesPerNode by the backend's row ceiling (RawPageLimit.Plan) took the end bound whenever its data fitted, because the fit was judged against NumValuesPerNode instead of the page's effective cap — a full ceiling-sized page came back with cap + 1 values. The bound is now fitted against the page allowance (the ceiling-adjusted data cap plus the start bound a first page already carries); when the page is full the bound goes to the next page through the continuation point. Test: NodeManagerHistoryReadBoundsTests .A_full_ceiling_sized_page_carries_the_end_bound_on_the_next_page (ceiling 4 ⇒ 3 data rows; first page = start bound + 3 with a CP, second page = the end bound alone; control: no ceiling ⇒ one page of 5). Refs #732 #736 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OtOpcUaNodeManager.cs | 16 ++++++--- .../NodeManagerHistoryReadBoundsTests.cs | 34 ++++++++++++++++++- 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 4edeac81..9e6091e9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -6781,13 +6781,17 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // The data is drained — the end bound ends this page when it has room (Part 11: bounds count toward // NumValuesPerNode), else it is the whole next page. Returns the continuation point to emit, if any. - async Task AppendEndBoundAsync(List page, DateTime resumedAtUtc) + // `pageAllowance` is the EFFECTIVE cap of this page — NumValuesPerNode, less whatever the backend's row + // ceiling (RawPageLimit) took off it, plus the start bound a first page already carries — not + // NumValuesPerNode itself: against N a ceiling-sized page that was full still took the bound, returning + // cap + 1 values (#736 fuzz). 0 = unbounded. + async Task AppendEndBoundAsync(List page, DateTime resumedAtUtc, uint pageAllowance) { // The previous returned value stamps an open bound's placeholder (Part 11 Table 1 footnotes a/b): this // page's last, else the last of an earlier page (carried in the cursor), else where the read resumed. var previous = (page.Count > 0 ? page[^1].SourceTimestampUtc : null) ?? endBound!.LastReturnedUtc ?? resumedAtUtc; - if (numValuesPerNode == 0 || page.Count < numValuesPerNode) + if (pageAllowance == 0 || page.Count < pageAllowance) { page.Add(await HistoryBounds.EndBoundAsync( HistorianDataSource, tagname, endBound!, previous, MaxTieClusterOverfetch, nowUtc, ct).ConfigureAwait(false)); @@ -6832,7 +6836,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 && (endBound.DataExhausted || HistoryBounds.DataWindowDrained(startUtc, endBound, resolution))) { var onlyBound = new List(1); - var afterCp = await AppendEndBoundAsync(onlyBound, startUtc).ConfigureAwait(false); + var afterCp = await AppendEndBoundAsync(onlyBound, startUtc, numValuesPerNode).ConfigureAwait(false); results[handle.Index] = new SdkHistoryReadResult { StatusCode = StatusCodes.Good, @@ -7052,7 +7056,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 else if (endBound is not null) { // The data is drained — the end bound ends this page, or is the next one when this one is full. - clusterCp = await AppendEndBoundAsync(slice, startUtc).ConfigureAwait(false); + clusterCp = await AppendEndBoundAsync(slice, startUtc, readCap).ConfigureAwait(false); } results[handle.Index] = new SdkHistoryReadResult @@ -7105,7 +7109,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // The data is drained — the end bound ends this page, or is the next one when this one is full. var page = new List(samples.Count + 1); page.AddRange(samples); - outboundCp = await AppendEndBoundAsync(page, startUtc).ConfigureAwait(false); + // This page's allowance: the (ceiling-adjusted) data cap, plus the start bound it already leads with. + var allowance = readCap == 0 ? 0u : readCap + (startBound is not null ? 1u : 0u); + outboundCp = await AppendEndBoundAsync(page, startUtc, allowance).ConfigureAwait(false); samples = page; historyData = ToHistoryDataFromSamples(samples); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs index 8b618b9e..74096a4f 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs @@ -292,6 +292,35 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : Render(values, T("5:00"), T("5:05")).ShouldBe("5:00, 5:02, 5:03, 5:05"); } + /// + /// #736 (H1 fuzz) — a backend row ceiling sizes the page BELOW NumValuesPerNode (RawPageLimit: ceiling 4 ⇒ 3 data + /// rows per page). When the data exactly fills that page, the end bound must go to the NEXT page through the + /// continuation point, not be appended to a page that is already at its effective cap: the fit used to be + /// judged against NumValuesPerNode (10 here), so the page came back with cap + 1 values. + /// + [Fact] + public async Task A_full_ceiling_sized_page_carries_the_end_bound_on_the_next_page() + { + var source = new SeriesSource(TableData(), reverse: true, inclusiveForwardEnd: false, resolution: 1, ceiling: 4); + var (first, error, cp) = await ReadAsync(source, T("5:01"), T("5:06"), max: 10, bounds: true, cp: null); + error.StatusCode.Code.ShouldBe(StatusCodes.Good); + + // Start bound 5:00 + the three data rows the ceiling allows = the page's effective cap, exactly. + Render(first, T("5:01"), T("5:06")).ShouldBe("5:00, 5:02, 5:03, 5:05"); + cp.ShouldNotBeNull("the page is full, so the end bound is owed on the next page"); + + var (second, error2, cp2) = await ReadAsync(source, T("5:01"), T("5:06"), max: 10, bounds: true, cp); + error2.StatusCode.Code.ShouldBe(StatusCodes.Good); + Render(second, T("5:01"), T("5:06")).ShouldBe("5:06"); + cp2.ShouldBeNull(); + + // Control: without a ceiling the same read fits on ONE page of NumValuesPerNode — the fit keys on the + // effective cap, not on a smaller constant. + var (whole, _, cpWhole) = await ReadAsync(Source(Shape.Reverse, TableData()), T("5:01"), T("5:06"), max: 10, bounds: true, cp: null); + Render(whole, T("5:01"), T("5:06")).ShouldBe("5:00, 5:02, 5:03, 5:05, 5:06"); + cpWhole.ShouldBeNull(); + } + /// /// #691 review: no data AND no bound found ⇒ GoodNoData with no values — the answer a tag with no history (and a /// disabled historian) gave before bounds (§6.5.3.2: bounds make the result Good only "if one or both exist"). Only @@ -468,7 +497,7 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : /// a point read is the instant. > 1 truncates every window bound to it first (QuestDB /// µs); the series must already be stored at that resolution. /// - private sealed class SeriesSource(IReadOnlyList series, bool reverse, bool inclusiveForwardEnd, long resolution) + private sealed class SeriesSource(IReadOnlyList series, bool reverse, bool inclusiveForwardEnd, long resolution, int ceiling = 0) : IHistorianDataSource { public int ReverseWindowsOnForwardOnly; @@ -477,6 +506,9 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : public long TimestampResolutionTicks => resolution; + /// A strict row ceiling (0 = none), so a page is sized below NumValuesPerNode (RawPageLimit). + public int MaxRawValuesPerRead => ceiling; + private DateTime Trunc(DateTime t) => new(t.Ticks - t.Ticks % resolution, DateTimeKind.Utc); public Task ReadRawAsync( From be22b48b1d00ba12421a41f14d7b57c64abc7b8b Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 10:45:24 -0400 Subject: [PATCH 09/67] feat(opcua): advertise the historian raw cap as MaxReturnDataValues on source sync Lane H1 follow-through: SyncAdvertisedAggregates now also calls HistoryPaging.AdvertiseMaxReturnDataValues(Server, source .MaxRawValuesPerRead) so HistoryServerCapabilities/MaxReturnDataValues (i=11273) follows the historian source on every assignment (the Null default advertises 0 = no limit); a false return logs a Warning (the cap is enforced by HistoryRead regardless). Test: NodeManagerMaxReturnDataValuesSyncTests reads i=11273 back through the SDK master node manager after setting a capped source (7000, 250) and after resetting to the Null source (0). Refs #732 #736 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OtOpcUaNodeManager.cs | 9 +++ ...NodeManagerMaxReturnDataValuesSyncTests.cs | 67 +++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 9e6091e9..6f536cb0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -458,6 +458,15 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 advertised.Count, source.GetType().Name, string.Join(", ", advertised.Select(id => AggregateFunctionMap.Map(id)?.ToString() ?? id.ToString()))); } + + // #736: HistoryServerCapabilities/MaxReturnDataValues (i=11273) carries the source's raw-page cap + // (0 = unlimited), re-synced on every source assignment like the aggregate set above. + if (!HistoryPaging.AdvertiseMaxReturnDataValues(Server, source.MaxRawValuesPerRead)) + { + _logger.LogWarning( + "OtOpcUaNodeManager: could not advertise HistoryServerCapabilities/MaxReturnDataValues ({Cap}) for historian {Source}; the cap is still enforced by HistoryRead", + source.MaxRawValuesPerRead, source.GetType().Name); + } } catch (Exception ex) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs new file mode 100644 index 00000000..ea47eedd --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerMaxReturnDataValuesSyncTests.cs @@ -0,0 +1,67 @@ +using Opc.Ua; +using Opc.Ua.Server; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using HistorianRead = ZB.MOM.WW.OtOpcUa.Core.Abstractions.HistoryReadResult; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// #736 — assigning re-syncs +/// HistoryServerCapabilities/MaxReturnDataValues (i=11273) to the source's +/// , read back the way a client would. The Null +/// source (the historian-disabled default) advertises Part 11's "no limit". +/// +public sealed class NodeManagerMaxReturnDataValuesSyncTests(BoundsServerFixture fx) : IClassFixture +{ + [Fact] + public async Task Setting_the_historian_source_advertises_its_raw_cap() + { + await fx.Gate.WaitAsync(TestContext.Current.CancellationToken); + try + { + fx.Nm.HistorianDataSource = new CappedSource(7_000); + (await ReadAdvertisedAsync()).ShouldBe(7_000u); + + fx.Nm.HistorianDataSource = new CappedSource(250); + (await ReadAdvertisedAsync()).ShouldBe(250u); + + fx.Nm.HistorianDataSource = null!; // the Null default: no cap + (await ReadAdvertisedAsync()).ShouldBe(0u); + } + finally + { + fx.Nm.HistorianDataSource = null!; + fx.Gate.Release(); + } + } + + private async Task ReadAdvertisedAsync() + { + var context = new OperationContext(new RequestHeader(), secureChannelContext: null, RequestType.Read, identity: null); + var nodesToRead = new ReadValueIdCollection + { + new ReadValueId { NodeId = VariableIds.HistoryServerCapabilities_MaxReturnDataValues, AttributeId = Attributes.Value }, + }; + var (values, _) = await fx.Nm.Server.NodeManager.ReadAsync(context, 0, TimestampsToReturn.Neither, nodesToRead, TestContext.Current.CancellationToken); + values.Count.ShouldBe(1); + StatusCode.IsGood(values[0].StatusCode).ShouldBeTrue(values[0].StatusCode.ToString()); + return (uint)values[0].Value; + } + + private sealed class CappedSource(int cap) : IHistorianDataSource + { + public int MaxRawValuesPerRead => cap; + public Task ReadRawAsync(string r, DateTime s, DateTime e, uint m, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadRawAsync(r, s, e, m, ct); + public Task ReadProcessedAsync(string r, DateTime s, DateTime e, TimeSpan i, HistoryAggregateType a, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadProcessedAsync(r, s, e, i, a, ct); + public Task ReadAtTimeAsync(string r, IReadOnlyList t, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadAtTimeAsync(r, t, ct); + public Task ReadEventsAsync(string? n, DateTime s, DateTime e, int m, CancellationToken ct) => + NullHistorianDataSource.Instance.ReadEventsAsync(n, s, e, m, ct); + public HistorianHealthSnapshot GetHealthSnapshot() => NullHistorianDataSource.Instance.GetHealthSnapshot(); + public void Dispose() { } + } +} From 0117e1501b2b1926b5263a1b68a0219e0d273467 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 13:29:07 -0400 Subject: [PATCH 10/67] fix(opcua): trace every rebuild path; align history tests with the B7 contract B6 moved the address-space-rebuild span into ApplyPlan, so the two rebuilds that complete synchronously on the actor thread (no applier -> raw-sink rebuild; no artifact bytes -> held) stopped emitting it (OtOpcUaTelemetryHookTests.RebuildAddressSpace_starts_an_address_space_rebuild_span). Both now open their own span. B7 (late HistorianDataSource / HistorianDataUpdater re-evaluate the capability bits) reversed three tests that pinned the old "fixed at materialisation" contract. Re-expressed against the new one, end to end: - events: a folder promoted without a source is unsupported, then served once a source is wired (source reached); - in-process HistoryUpdate: clearing the updater retracts HistoryWrite and the old updater is never reached; - over the wire: a late updater lights HistoryWrite and an insert reaches it; a reset retracts the bit and the insert is BadHistoryOperationUnsupported. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OpcUa/OpcUaPublishActor.cs | 6 ++++ .../HistoryUpdateOverTheWireTests.cs | 29 ++++++++++++++----- .../History/HistoryUpdateTests.cs | 18 ++++++++---- .../NodeManagerHistoryReadEventsTests.cs | 28 +++++++++--------- 4 files changed, 55 insertions(+), 26 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index cbe96919..702396a8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -516,6 +516,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // real driver node that has an applier (that node diff-and-applies from its in-hand bytes below). if (_applier is null) { + // Every rebuild is traced, whichever path completes it: the applier path opens its span in ApplyPlan + // (the actor-thread half), the two synchronous completions below open their own. + using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan(); + span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString()); (bool Degraded, string? Reason) outcome; try { @@ -541,6 +545,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // supply a real one. Leaving _lastApplied intact means the retry diffs against what is materialised. if (msg.Artifact is null or { Length: 0 }) { + using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan(); + span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString()); if (_hasAppliedComposition) _log.Warning( "OpcUaPublish: no usable artifact for deployment {Id} (correlation={Correlation}) — KEEPING the currently served address space rather than tearing it down", diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs index 43fa05e2..e3eb2acc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/HistoryUpdateOverTheWireTests.cs @@ -215,11 +215,13 @@ public sealed class HistoryUpdateOverTheWireTests } /// - /// Control (no retro-update): materialising the node BEFORE the updater is installed leaves it without - /// the HistoryWrite bit, so the SDK base answers BadHistoryOperationUnsupported — even for an editor. + /// Review 01 U3 (B7): an updater installed AFTER the node materialised re-composes the HistoryWrite bit on + /// the existing node (no rebuild), so an editor's insert reaches the updater over the wire; clearing the + /// updater again retracts the bit and the SDK base answers BadHistoryOperationUnsupported. (Before B7 the + /// bit was fixed at materialisation and a late install stayed unsupported.) /// [Fact] - public async Task An_updater_installed_after_the_node_materialised_is_unsupported() + public async Task An_updater_installed_after_the_node_materialised_grants_history_write_and_a_reset_takes_it_back() { var ct = TestContext.Current.CancellationToken; var store = new SharedStore(); @@ -228,16 +230,27 @@ public sealed class HistoryUpdateOverTheWireTests { rig.Enforce(true, [Key()], ClusterGrant(KeyPrincipal, "acl-editor", Editor)); var id = rig.EnsureRawVariable("Speed", "Double", writable: false, Tagname); - rig.Nm.HistorianDataUpdater = store; using var session = await rig.ConnectAsync(anonymous: false, ct); var node = RawNode(session, id); (await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite) - .ShouldBe(0, "the bit is fixed at materialisation"); + .ShouldBe(0, "no updater at materialisation (non-vacuity for the refresh)"); - var result = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.0)); - result.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - store.UpdateCalls.ShouldBe(0); + rig.Nm.HistorianDataUpdater = store; + + (await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite) + .ShouldBe(AccessLevels.HistoryWrite, "a late updater lights the bit on the existing node"); + var ok = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T1, 1.0)); + ok.OperationResults.Select(c => c.Code).ShouldBe([StatusCodes.GoodEntryInserted]); + store.UpdateCalls.ShouldBe(1); + + rig.Nm.HistorianDataUpdater = null; + + (await ReadByteAsync(session, node, Attributes.AccessLevel, ct) & AccessLevels.HistoryWrite) + .ShouldBe(0, "a reset updater takes the bit back"); + var refused = await UpdateAsync(session, node, PerformUpdateType.Insert, ct, (T2, 2.0)); + refused.StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + store.UpdateCalls.ShouldBe(1, "the reset updater is never reached"); } finally { await rig.DisposeAsync(); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs index 12665523..da4f1a57 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs @@ -223,8 +223,10 @@ public sealed class HistoryUpdateTests : IDisposable } /// - /// An updater removed AFTER materialisation (AccessLevel is not retro-updated) still yields unsupported - /// rather than a null dereference — the override re-checks the updater per request. + /// An updater removed AFTER materialisation retracts the node's HistoryWrite bit (review 01 U3 / B7), so + /// the SDK base refuses the insert with BadHistoryOperationUnsupported before our override runs and the + /// old updater is never reached. (Only errors is asserted: on the SDK-base refusal path the + /// per-node result is left for the master node manager to fill from it.) /// [Fact] public async Task An_updater_cleared_after_materialisation_is_unsupported() @@ -233,15 +235,21 @@ public sealed class HistoryUpdateTests : IDisposable try { var nm = server.NodeManager!; - nm.HistorianDataUpdater = new FakeUpdater(); + var updater = new FakeUpdater(); + nm.HistorianDataUpdater = updater; WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + (nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite) + .ShouldNotBe((byte)0, "granted while the updater was wired (non-vacuity)"); + nm.HistorianDataUpdater = null; - var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); + (nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!.AccessLevel & AccessLevels.HistoryWrite) + .ShouldBe((byte)0, "clearing the updater retracts the bit"); + var (_, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + updater.UpdateCalls.ShouldBeEmpty(); } finally { await host.DisposeAsync(); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs index 36679959..1984fdb8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs @@ -211,25 +211,21 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable } /// A folder promoted to an event notifier while NO historian was wired (Null source at - /// materialise time) is NOT registered as an event-history source ⇒ a HistoryReadEvents over it yields - /// BadHistoryOperationUnsupported, and the (later-wired) source is never invoked. + /// materialise time) is not an event-history source ⇒ a HistoryReadEvents over it yields + /// BadHistoryOperationUnsupported. Wiring a real source AFTER promotion re-evaluates the folder + /// (review 01 U3 / B7 — before B7 it stayed unsupported until a rebuild), so the same read then reaches + /// the source. [Fact] - public async Task Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported() + public async Task Events_folder_promoted_without_source_is_unsupported_until_a_source_is_wired() { var (host, server) = await BootAsync(); var nm = server.NodeManager!; - // Materialise the alarm while the source is still the Null default — the folder is promoted to - // SubscribeToEvents but DOES NOT get the HistoryRead bit / source registration. const string equipmentId = "eq-nosrc"; nm.EnsureFolder(equipmentId, parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); nm.MaterialiseAlarmCondition("alarm-4", equipmentId, "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns); var notifierNodeId = nm.TryGetFolder(equipmentId)!.NodeId; - // Wire a real source AFTER promotion — it must NOT retroactively make the folder a source. - var fake = new RecordingHistorianDataSource(); - nm.HistorianDataSource = fake; - var details = new ReadEventDetails { StartTime = DateTime.UtcNow.AddHours(-1), @@ -238,11 +234,17 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable Filter = SelectFilter("EventId"), }; + var (_, unsupported) = InvokeHistoryRead(server, nm, details, notifierNodeId); + unsupported[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + + var fake = new RecordingHistorianDataSource(); + nm.HistorianDataSource = fake; + var (_, errors) = InvokeHistoryRead(server, nm, details, notifierNodeId); - errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); - fake.LastSourceName.ShouldBeNull(); // source never reached - fake.EventsCalled.ShouldBeFalse(); + errors[0].StatusCode.Code.ShouldBe(StatusCodes.Good); + fake.EventsCalled.ShouldBeTrue(); + fake.LastSourceName.ShouldBe(equipmentId); await host.DisposeAsync(); } @@ -258,7 +260,7 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable /// the Bad result comes from the base's pre-seeding, not from our source-guard. This test pins /// that base-level rejection of variable nodes for event reads. /// The override's own source-guard (miss in _eventNotifierSources) is exercised by the - /// Events_folder_promoted_without_source_yields_BadHistoryOperationUnsupported test instead. + /// Events_folder_promoted_without_source_is_unsupported_until_a_source_is_wired test instead. /// /// [Fact] From 20cb2a52d644322755d42b1726f533f9b803504e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:02:00 -0400 Subject: [PATCH 11/67] fix(abcip): build-and-swap device tables, volatile health, async-only dispose H13: _devices/_devicesByRoutingKey are published by reference swap under a lifecycle lock and never mutated after publish, so the retained poll engine, discovery and the actor's GetHostStatuses pull never read a table mid-Clear. Every retire (init's own release, shutdown, dispose) takes the table it replaces, so ShutdownAsync racing DisposeAsync tears each state down once, and a generation check makes an InitializeAsync overtaken by a shutdown publish nothing (it throws OperationCanceledException) instead of resurrecting probe loops behind the shutdown. Probe join is one bounded 5 s wait independent of the caller token. H21: _health is volatile and read-modify-write updates go through a CAS. F10: the sync Dispose() (sync-over-async) is removed; DisposeAsync is the complete, idempotent teardown the runtime calls after ShutdownAsync. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipDriver.cs | 260 +++++++++++------- .../AbCipLifecycleConcurrencyTests.cs | 172 ++++++++++++ 2 files changed, 333 insertions(+), 99 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 9f1d4fec..02c5f055 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -27,7 +27,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// handles GC-collected tags. /// public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable, - IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable + IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IAsyncDisposable { /// Authored tags this driver could not build into its poll table. private readonly SkippedTagLedger _skippedTags = new(); @@ -98,12 +98,21 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, private readonly PollGroupEngine _poll; // Devices keyed by host address — the primary registry (GetDeviceState, discovery, probe, diagnostics). - private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); + // BUILD-AND-SWAP, never mutated after publish: the retained poll engine, the alarm projection, discovery and + // the actor's GetHostStatuses pull all read it while a re-init / shutdown runs. Each teardown takes the table + // it replaces, so concurrent teardowns (ShutdownAsync racing DisposeAsync) own disjoint device states. + private volatile Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); // Tag-routing index: a tag's device routing key (DeviceName ?? HostAddress, plus the HostAddress alias) // → its DeviceState. A tag resolves to its device by DeviceName-or-host so multi-device routing keys on // the RawPath's device segment. The HostAddress alias is deliberate (Client CLI + tests route by host); - // _options.Devices is already the merged per-device DeviceConfig. - private readonly Dictionary _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase); + // _options.Devices is already the merged per-device DeviceConfig. Swapped with _devices, same rule. + private volatile Dictionary _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase); + + // Serialises table publish/retire. _lifecycleGeneration is bumped by every retire (an init's own release, a + // shutdown, a dispose); an init publishes only if no retire happened after its own, so an abandoned init that a + // shutdown overtook can never resurrect devices or probe loops after that shutdown returned. + private readonly object _lifecycleLock = new(); + private int _lifecycleGeneration; // v3 authored RawPath → definition table (parents + fanned-out UDT members), keyed case-sensitive // ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper. private volatile Dictionary _tagsByRawPath = new(StringComparer.Ordinal); @@ -133,7 +142,24 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// when a definition exists for . internal bool TryGetTagDefinition(string rawPath, out AbCipTagDefinition definition) => _tagsByRawPath.TryGetValue(rawPath, out definition!); - private DriverHealth _health = new(DriverState.Unknown, null, null); + // volatile: written from the read batch, the write path, the poll engine's error hook and the lifecycle, and + // read from the actor thread (GetHealth). Read-modify-write updates go through UpdateHealth (CAS) so a + // concurrent update is never lost. + private volatile DriverHealth _health = new(DriverState.Unknown, null, null); + + /// Atomically replaces with applied to its current value. + /// Computes the new health from the current one. + private void UpdateHealth(Func update) + { + while (true) + { + var current = _health; + var next = update(current); +#pragma warning disable CS0420 // a volatile field passed by ref to Interlocked keeps its full-fence semantics + if (ReferenceEquals(Interlocked.CompareExchange(ref _health, next, current), current)) return; +#pragma warning restore CS0420 + } + } /// Occurs when a subscribed tag's value changes. public event EventHandler? OnDataChange; @@ -270,9 +296,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, internal void HandlePollError(Exception ex) { _logger.LogWarning(ex, "AbCip poll reader failed. Driver={DriverInstanceId}", _driverInstanceId); - var current = _health; - if (current.State != DriverState.Faulted) - _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message); + UpdateHealth(current => current.State == DriverState.Faulted + ? current + : new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message)); } /// @@ -364,48 +390,58 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { // InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract - // allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + handles before - // the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions. - await ReleaseDeviceStatesAsync(cancellationToken).ConfigureAwait(false); - _health = new DriverHealth(DriverState.Initializing, null, null); + // allows it, and a reconnect retry can overlap a running attempt). Retire the previous device states' probe + // loops + handles before building new ones. Deliberately NOT ShutdownAsync: that also drops _poll and the + // subscriptions. The generation this release produced is what the publish below is checked against. + var generation = await ReleaseDeviceStatesAsync().ConfigureAwait(false); + lock (_lifecycleLock) + { + if (generation == _lifecycleGeneration) + _health = new DriverHealth(DriverState.Initializing, null, null); + } _logger.LogInformation( "AbCip driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}", _driverInstanceId, _transportDescription, _tagFactory.GetType().Name); + Dictionary? builtDevices = null; try { + var options = _options; + var adoptedConfig = false; if (!string.IsNullOrWhiteSpace(driverConfigJson)) { var parsed = AbCipDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); if (parsed.Devices.Count > 0 || parsed.RawTags.Count > 0) { - _options = parsed; - _alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger); + options = parsed; + adoptedConfig = true; } } - foreach (var device in _options.Devices) + builtDevices = new Dictionary(StringComparer.OrdinalIgnoreCase); + var builtRoutes = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var device in options.Devices) { var addr = AbCipHostAddress.TryParse(device.HostAddress) ?? throw new InvalidOperationException( $"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'."); var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily); var state = new DeviceState(addr, device, profile); - _devices[device.HostAddress] = state; + builtDevices[device.HostAddress] = state; // Route index: a tag reaches this device by matching its DeviceName routing key OR its // host address. Devices with no explicit DeviceName are identified purely by host today. - _devicesByRoutingKey[device.HostAddress] = state; + builtRoutes[device.HostAddress] = state; if (!string.IsNullOrEmpty(device.DeviceName)) - _devicesByRoutingKey[device.DeviceName] = state; + builtRoutes[device.DeviceName] = state; } // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig blob // is mapped by the pure factory; the entry's DeviceName (routing key) + WriteIdempotent flag are // threaded onto the def (they live on the RawTagEntry, not inside the blob). A mapper miss is // skipped (logged), never thrown — a bad tag must not fail the whole driver init. - _skippedTags.BeginRebuild(_options.RawTags.Count); + _skippedTags.BeginRebuild(options.RawTags.Count); var builtTagsByRawPath = new Dictionary(StringComparer.Ordinal); var builtDeclaredTags = new List(); - foreach (var entry in _options.RawTags) + foreach (var entry in options.RawTags) { if (!AbCipTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var mapped)) { @@ -457,24 +493,43 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } } } - // Publish the finished tables by reference swap — readers see the old or the new, never a half-built one. - _tagsByRawPath = builtTagsByRawPath; - _declaredTags = builtDeclaredTags; - _skippedTags.Commit(); - // Probe loops — one per device when enabled + a ProbeTagPath is configured. - if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeTagPath)) + // Publish everything under the lifecycle lock, and only if no shutdown / other init retired the tables + // after this init's own release — readers see the old tables or the new ones, never a half-built one, + // and an overtaken init leaves nothing running behind the shutdown that overtook it. + lock (_lifecycleLock) { - foreach (var state in _devices.Values) + if (generation != _lifecycleGeneration) + throw new OperationCanceledException( + $"AbCip driver {_driverInstanceId} initialization was superseded by a shutdown or a newer initialization."); + + _options = options; + if (adoptedConfig) + _alarmProjection = new AbCipAlarmProjection(this, options.AlarmPollInterval, _logger); + _devices = builtDevices; + _devicesByRoutingKey = builtRoutes; + _tagsByRawPath = builtTagsByRawPath; + _declaredTags = builtDeclaredTags; + _skippedTags.Commit(); + + // Probe loops — one per device when enabled + a ProbeTagPath is configured. Started inside the lock so + // a retire that follows this publish always sees (and joins) them. + if (options.Probe.Enabled && !string.IsNullOrWhiteSpace(options.Probe.ProbeTagPath)) { - state.ProbeCts = new CancellationTokenSource(); - var ct = state.ProbeCts.Token; - // Keep the loop Task so ShutdownAsync can await its clean exit before - // disposing the CTS / handles the loop is still using. - state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct); + foreach (var state in builtDevices.Values) + { + state.ProbeCts = new CancellationTokenSource(); + var ct = state.ProbeCts.Token; + // Keep the loop Task so a teardown can await its clean exit before + // disposing the CTS / handles the loop is still using. + state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct); + } } + _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); } - else if (_options.Probe.Enabled && _devices.Count > 0) + builtDevices = null; // published: owned by the next retire now + + if (options.Probe.Enabled && string.IsNullOrWhiteSpace(options.Probe.ProbeTagPath) && options.Devices.Count > 0) { // Probe is Enabled but no ProbeTagPath is configured. Without a // tag path the loop has nothing to read, so HostState would stay Unknown forever @@ -486,51 +541,84 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, "as Unknown until a ProbeTagPath is set or Probe.Enabled is set to false.", _driverInstanceId); } - _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); } catch (Exception ex) { - _health = new DriverHealth(DriverState.Faulted, null, ex.Message); + // Never-published states hold no probe loop and no runtime yet; dispose for symmetry. + if (builtDevices is not null) + foreach (var state in builtDevices.Values) state.DisposeHandles(); + if (ex is OperationCanceledException) + { + _logger.LogInformation("AbCip driver {DriverInstanceId} initialization abandoned: {Reason}", + _driverInstanceId, ex.Message); + throw; + } + lock (_lifecycleLock) + { + // A superseded init must not overwrite the health of whatever superseded it. + if (generation == _lifecycleGeneration) + _health = new DriverHealth(DriverState.Faulted, null, ex.Message); + } _logger.LogError(ex, "AbCip driver {DriverInstanceId} failed to initialize", _driverInstanceId); throw; } } + /// Bound on joining the retired probe loops — matches the fleet's "≤ 5 s join" teardown rule. + private static readonly TimeSpan ProbeJoinTimeout = TimeSpan.FromSeconds(5); + /// - /// Cancels, joins (bounded) and disposes every existing device state's probe loop + handles, then - /// clears the device/route tables. Device part of only. + /// Retires the published device states: swaps empty device/route/tag tables in under the lifecycle lock + /// (bumping the generation), then cancels, joins (bounded, independent of any caller token — a cancelled + /// caller must not dispose handles under a probe loop that is still running) and disposes the states it took. + /// Concurrent callers each take a disjoint table, so every state is torn down exactly once. The device part of + /// and the first step of . /// - private async Task ReleaseDeviceStatesAsync(CancellationToken cancellationToken) + /// The lifecycle generation this retire produced. + private async Task ReleaseDeviceStatesAsync() { - // The projection is replaced (not reused) when a config body is adopted; dispose the old one so its - // poll loop does not outlive it and raise duplicate alarm events through this driver. - await _alarmProjection.DisposeAsync().ConfigureAwait(false); - foreach (var state in _devices.Values) + Dictionary retired; + AbCipAlarmProjection projection; + int generation; + lock (_lifecycleLock) + { + generation = ++_lifecycleGeneration; + retired = _devices; + _devices = new Dictionary(StringComparer.OrdinalIgnoreCase); + _devicesByRoutingKey = new Dictionary(StringComparer.OrdinalIgnoreCase); + // Rebuilt wholesale by Initialize; swap, never Clear(): the retained poll engine / discovery may be enumerating. + _tagsByRawPath = new(StringComparer.Ordinal); + _declaredTags = new(); + projection = _alarmProjection; + } + + // The projection is replaced (not reused) when a config body is adopted; dispose it so its poll loop does + // not outlive it and raise duplicate alarm events through this driver. Idempotent, and reusable afterwards. + await projection.DisposeAsync().ConfigureAwait(false); + + foreach (var state in retired.Values) { try { state.ProbeCts?.Cancel(); } catch (ObjectDisposedException) { } } - foreach (var state in _devices.Values) + var loops = retired.Values.Select(s => s.ProbeTask).OfType().ToArray(); + if (loops.Length > 0) { - var probeTask = state.ProbeTask; - if (probeTask is null) continue; - // Own bounded timeout, independent of the caller token: cancelling init must not abort the join - // and dispose handles under a probe loop that is still running. - try { await probeTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); } - catch (TimeoutException) { } - catch (OperationCanceledException) { } + try { await Task.WhenAll(loops).WaitAsync(ProbeJoinTimeout).ConfigureAwait(false); } + catch (TimeoutException) + { + _logger.LogWarning("AbCip driver {DriverInstanceId}: a probe loop did not exit within {Timeout}; disposing anyway", + _driverInstanceId, ProbeJoinTimeout); + } + catch (Exception) { /* a loop never faults by design; nothing to add here */ } } - foreach (var state in _devices.Values) + foreach (var state in retired.Values) { state.ProbeCts?.Dispose(); state.ProbeCts = null; state.ProbeTask = null; state.DisposeHandles(); } - _devices.Clear(); - _devicesByRoutingKey.Clear(); - // Rebuilt wholesale by Initialize; stale entries would trip its member fan-out collision check. - _tagsByRawPath = new(StringComparer.Ordinal); // swap, never Clear(): the retained poll engine / discovery may be enumerating - _declaredTags = new(); + return generation; } /// @@ -541,46 +629,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } /// + /// + /// A complete teardown: the poll engine (and every subscription), the alarm projection, every probe loop + /// (joined ≤ 5 s) and every runtime handle. Safe to run concurrently with and with + /// an it overtakes (that init then publishes nothing). + /// public async Task ShutdownAsync(CancellationToken cancellationToken) { - await _alarmProjection.DisposeAsync().ConfigureAwait(false); await _poll.DisposeAsync().ConfigureAwait(false); - - // Phase 1: signal every probe loop to stop. - foreach (var state in _devices.Values) - { - try { state.ProbeCts?.Cancel(); } catch (ObjectDisposedException) { } - } - - // Phase 2: wait for each probe loop to observe cancellation and exit. The loop never - // throws on cancellation (it catches OperationCanceledException internally), but guard - // anyway so one slow device can't wedge the whole shutdown. - foreach (var state in _devices.Values) - { - var probeTask = state.ProbeTask; - if (probeTask is null) continue; - try - { - await probeTask.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false); - } - catch (TimeoutException) { } - catch (OperationCanceledException) { } - } - - // Phase 3: now the loops are gone, dispose the CTS + native handles with no live reader. - foreach (var state in _devices.Values) - { - state.ProbeCts?.Dispose(); - state.ProbeCts = null; - state.ProbeTask = null; - state.DisposeHandles(); - } - _devices.Clear(); - _devicesByRoutingKey.Clear(); - _tagsByRawPath = new(StringComparer.Ordinal); - _declaredTags = new(); + await ReleaseDeviceStatesAsync().ConfigureAwait(false); _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry - _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + UpdateHealth(h => new DriverHealth(DriverState.Unknown, h.LastSuccessfulRead, null)); } // ---- ISubscribable (polling overlay via shared engine) ---- @@ -875,10 +934,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // exactly as the per-item writes did before. if (worst is not { } worstState) return; - _health = new DriverHealth( + UpdateHealth(current => new DriverHealth( worstState, - anySuccess ? now : _health.LastSuccessfulRead, - worstState == DriverState.Healthy ? null : worstError); + anySuccess ? now : current.LastSuccessfulRead, + worstState == DriverState.Healthy ? null : worstError)); } private async Task ReadSingleAsync( @@ -1896,10 +1955,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, internal DeviceState? GetDeviceState(string hostAddress) => _devices.TryGetValue(hostAddress, out var s) ? s : null; - /// Releases all resources used by the driver. - public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult(); - - /// Asynchronously releases all resources used by the driver. + /// + /// Asynchronously releases all resources used by the driver — the same complete teardown as + /// . Idempotent, and safe CONCURRENTLY with a still-running + /// (the runtime's final stop abandons a shutdown that overruns its budget and + /// disposes anyway): each teardown retires a disjoint device table. There is deliberately no synchronous + /// Dispose() — it could only block on this teardown (sync-over-async). + /// /// A value task representing the asynchronous disposal. public async ValueTask DisposeAsync() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs new file mode 100644 index 00000000..00cb921a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs @@ -0,0 +1,172 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Lifecycle overlap the runtime can produce (review 07 H13/F10, Lane A): the final stop runs +/// ShutdownAsync then DisposeAsync under one bounded budget, so a slow shutdown is abandoned and +/// the dispose runs CONCURRENTLY with it; an abandoned InitializeAsync can overlap the next attempt's +/// shutdown. Device tables are build-and-swapped, so each teardown owns a disjoint set of device states. +/// +[Trait("Category", "Unit")] +public sealed class AbCipLifecycleConcurrencyTests +{ + private const string Host = "ab://10.0.0.5/1,0"; + + /// A fake whose reads ignore cancellation for a while, so a teardown waits on the probe loop. + private sealed class SlowTag(AbCipTagCreateParams p, TimeSpan readDelay) : FakeAbCipTag(p) + { + private int _disposeCount; + + public int DisposeCount => Volatile.Read(ref _disposeCount); + + public override async Task ReadAsync(CancellationToken cancellationToken) + { + await base.ReadAsync(cancellationToken); + if (readDelay > TimeSpan.Zero) await Task.Delay(readDelay, CancellationToken.None); + } + + public override void Dispose() + { + Interlocked.Increment(ref _disposeCount); + base.Dispose(); + } + } + + private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(TimeSpan probeReadDelay) + { + var factory = new FakeAbCipTagFactory + { + Customise = p => new SlowTag(p, p.TagName == "ProbeTag" ? probeReadDelay : TimeSpan.Zero) { Value = 1 }, + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Host, "Motor1.Speed", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions + { + Enabled = true, + ProbeTagPath = "ProbeTag", + Interval = TimeSpan.FromMilliseconds(10), + Timeout = TimeSpan.FromMilliseconds(200), + }, + }, "abcip-lifecycle", factory); + return (drv, factory); + } + + private static async Task WaitUntilAsync(Func condition, string what) + { + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!condition()) + { + if (DateTime.UtcNow > deadline) throw new TimeoutException(what); + await Task.Delay(5); + } + } + + /// + /// Shutdown and Dispose racing each other tear the device down exactly once: the data runtime is + /// disposed once (not once per teardown), neither call throws, and the probe loop has exited. + /// + [Fact] + public async Task Dispose_concurrent_with_a_running_Shutdown_tears_each_state_down_exactly_once() + { + var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(300)); + await drv.InitializeAsync("{}", CancellationToken.None); + (await drv.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started"); + var state = drv.GetDeviceState(Host)!; + var probeLoop = state.ProbeTask!; + var data = (SlowTag)factory.Tags["Motor1.Speed"]; + + var shutdown = drv.ShutdownAsync(CancellationToken.None); + var dispose = drv.DisposeAsync().AsTask(); + await Task.WhenAll(shutdown, dispose); + + data.DisposeCount.ShouldBe(1); + probeLoop.IsCompleted.ShouldBeTrue(); + drv.GetDeviceState(Host).ShouldBeNull(); + } + + /// + /// An InitializeAsync that a ShutdownAsync overtakes publishes nothing: no device state and no probe + /// loop survive the shutdown. Positive control: an init that nothing overtakes still publishes. + /// + [Fact] + public async Task An_init_overtaken_by_Shutdown_publishes_no_device_and_starts_no_probe() + { + var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(300)); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started"); + var firstProbe = factory.Tags["ProbeTag"]; + + // The second init's own release waits on the slow first probe loop; the shutdown overtakes it there. + var init = drv.InitializeAsync("{}", CancellationToken.None); + await Task.Delay(50); + await drv.ShutdownAsync(CancellationToken.None); + await Should.ThrowAsync(() => init); + + drv.GetDeviceState(Host).ShouldBeNull(); + drv.GetHostStatuses().ShouldBeEmpty(); + await Task.Delay(100); + factory.Tags["ProbeTag"].ShouldBeSameAs(firstProbe); // no second probe loop ever created a runtime + drv.GetHealth().State.ShouldNotBe(DriverState.Healthy); + + // Positive control: a later init that nothing overtakes publishes the device again. + await drv.InitializeAsync("{}", CancellationToken.None); + drv.GetDeviceState(Host).ShouldNotBeNull(); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + await drv.DisposeAsync(); + } + + /// + /// Readers of the device/routing tables (the retained poll engine, the actor's host-status pull) never + /// see a table mid-mutation while the driver re-initialises underneath them. + /// + [Fact] + public async Task Readers_survive_repeated_reinitialisation() + { + var (drv, _) = Build(probeReadDelay: TimeSpan.Zero); + await drv.InitializeAsync("{}", CancellationToken.None); + using var stop = new CancellationTokenSource(); + var reader = Task.Run(async () => + { + var reads = 0; + while (!stop.IsCancellationRequested) + { + _ = drv.GetHostStatuses(); + _ = drv.ResolveHost("Speed"); + await drv.ReadAsync(["Speed"], CancellationToken.None); + reads++; + } + return reads; + }); + + for (var i = 0; i < 50; i++) + await drv.InitializeAsync("{}", CancellationToken.None); + stop.Cancel(); + + (await reader).ShouldBeGreaterThan(0); // positive control: the reader actually ran + await drv.DisposeAsync(); + } + + /// F10: the driver exposes only an async dispose; a sync Dispose() that blocked on the async + /// teardown (sync-over-async) is gone, and the runtime takes the path. + [Fact] + public async Task Driver_is_async_disposable_only_and_DisposeAsync_stops_the_probe() + { + typeof(IDisposable).IsAssignableFrom(typeof(AbCipDriver)).ShouldBeFalse(); + var (drv, factory) = Build(probeReadDelay: TimeSpan.Zero); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started"); + var loop = drv.GetDeviceState(Host)!.ProbeTask!; + + await drv.DisposeAsync(); + await drv.DisposeAsync(); // idempotent + + loop.IsCompleted.ShouldBeTrue(); + factory.Tags["ProbeTag"].Disposed.ShouldBeTrue(); + } +} From 92171a01ec4ca2428c0ce6b2801c791ab1764a7e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:04:51 -0400 Subject: [PATCH 12/67] fix(ablegacy): build-and-swap device/tag tables; teardown safe vs Dispose and abandoned init Review 07 H13: _devices and _tagsByRawPath were plain dictionaries cleared and refilled in place while the retained poll loop, the actor's GetHostStatuses and server reads enumerated them. Both are now built locally and published by reference under a lifecycle lock; teardown swaps in empty tables. Lane A contract: ShutdownAsync and Dispose each retire a disjoint set of device states, so Dispose running concurrently with a still-joining ShutdownAsync is safe. A lifecycle epoch makes an InitializeAsync that the runtime abandoned (overlapping the next ShutdownAsync) refuse to publish its tables, start probe loops or write health. A runtime cached on an already-retired device state by an in-flight read is disposed instead of leaked, and late reads/writes no longer overwrite shutdown's health. Per-key semaphores are no longer disposed under callers that may still hold them. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriver.cs | 362 +++++++++++------- .../AbLegacyBitRmwTests.cs | 2 +- .../AbLegacyLifecycleConcurrencyTests.cs | 255 ++++++++++++ .../AbLegacyTestLogger.cs | 23 ++ 4 files changed, 504 insertions(+), 138 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyLifecycleConcurrencyTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTestLogger.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 236720f9..66db900d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -23,16 +23,30 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// Mutable so can adopt a re-parsed config on reinitialize. /// Only ever written on the init path, before any reader/session uses it. - private AbLegacyDriverOptions _options; + private volatile AbLegacyDriverOptions _options; private readonly string _driverInstanceId; private readonly IAbLegacyTagFactory _tagFactory; private readonly ILogger _logger; private readonly PollGroupEngine _poll; - private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); + + // Device table, keyed by HostAddress. BUILD-AND-SWAP (review 07 H13): InitializeAsync builds a fresh + // dictionary and publishes it by reference; teardown swaps in an empty one. A published dictionary is never + // mutated, so the retained poll loop, the actor's GetHostStatuses and a server read can enumerate whatever + // snapshot they hold without "Collection was modified". volatile = the publish is visible to every reader. + private volatile Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); // v3 authored-tag table, keyed by RawPath (the tag identity + driver wire reference). RawPath is - // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper. - private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal); + // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper, and published + // by reference alongside _devices (same build-and-swap rule). + private volatile Dictionary _tagsByRawPath = new(StringComparer.Ordinal); + + // Serialises every PUBLISH/RETIRE of the two tables with the lifecycle epoch. Never held across an await. + private readonly object _lifecycleLock = new(); + + // Bumped by every InitializeAsync start and every teardown. An initialise only publishes if the epoch is still + // the one it took — so an InitializeAsync the runtime abandoned (Lane A: it times the attempt out and calls + // ShutdownAsync) cannot resurrect its tables and probe loops behind a driver the runtime believes stopped. + private long _lifecycleEpoch; // Resolves a read/write/subscribe RawPath reference to its tag definition via a single hit on the // authored _tagsByRawPath table (v3: the reference is always a RawPath; a miss is a miss). @@ -121,9 +135,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover internal void HandlePollError(Exception ex) { _logger.LogWarning(ex, "AbLegacy poll reader failed. Driver={DriverInstanceId}", _driverInstanceId); - var current = _health; - if (current.State != DriverState.Faulted) - _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message); + lock (_lifecycleLock) + { + var current = _health; + if (current.State != DriverState.Faulted) + _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message); + } } /// @@ -145,53 +162,62 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { + long epoch; + lock (_lifecycleLock) epoch = ++_lifecycleEpoch; + // InitializeAsync can run on a live driver without a teardown first (the IDriver contract // allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + runtimes BEFORE the - // overwrite below, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT + // new table is published, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT // ShutdownAsync: that also drops _poll and every subscription, which are owned by the shutdown path (the actor re-subscribes after every successful init). - await ReleaseDeviceStatesAsync().ConfigureAwait(false); - _health = new DriverHealth(DriverState.Initializing, null, null); + await ReleaseDeviceStatesAsync(RetireTables(bumpEpoch: false), "re-initialise").ConfigureAwait(false); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Initializing, null, null)); _logger.LogInformation( "AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}", _driverInstanceId, _transportDescription, _tagFactory.GetType().Name); + + // Built locally and published by reference at the end (build-and-swap): nothing a concurrent reader holds + // is ever mutated. + var devices = new Dictionary(StringComparer.OrdinalIgnoreCase); + var tags = new Dictionary(StringComparer.Ordinal); try { // re-parse the supplied DriverConfig so a config change delivered through the IDriver // contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED // with, so an operator's edit is discarded while the deployment still seals green. - if (HasConfigBody(driverConfigJson)) - _options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); + var options = HasConfigBody(driverConfigJson) + ? AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson) + : _options; // Native is opt-in for every family (the default is libplctag); an operator may pick it explicitly // even for PLC-5 / LogixPccc. Allowed, but the limitation is client-visible, so name it ONCE per instance — // not on every init, which includes every comms-loss reconnect retry. if (!_limitedFamilyWarned && _transport == AbLegacyTransport.Native - && AbLegacyTransportResolver.NativeLimitedFamilies(_options.Devices) is { Count: > 0 } limited) + && AbLegacyTransportResolver.NativeLimitedFamilies(options.Devices) is { Count: > 0 } limited) { _limitedFamilyWarned = true; _logger.LogWarning( "AbLegacy driver {DriverInstanceId} runs the Native transport against {Families} device(s); native " + - "support is limited there (PLC-5: N-file reads only; LogixPccc: framing unverified). Set " + + "support is limited there (PLC-5: N-file reads only, every write refused; LogixPccc: framing unverified). Set " + "\"transport\": \"Libplctag\" to use the fallback stack.", _driverInstanceId, string.Join(", ", limited)); } - foreach (var device in _options.Devices) + foreach (var device in options.Devices) { var addr = AbLegacyHostAddress.TryParse(device.HostAddress) ?? throw new InvalidOperationException( $"AbLegacy device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'."); var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily); - _devices[device.HostAddress] = new DeviceState(addr, device, profile); + devices[device.HostAddress] = new DeviceState(addr, device, profile); } // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig // blob is mapped by the pure factory; the entry's WriteIdempotent flag is threaded onto the // def (it lives on the RawTagEntry, not inside the blob), and its DeviceName is resolved to // the owning device's host so the existing DeviceHostAddress-keyed routing continues to work. // A mapper miss is skipped (logged), never thrown — a bad tag must not fail the whole init. - _skippedTags.BeginRebuild(_options.RawTags.Count); - foreach (var entry in _options.RawTags) + _skippedTags.BeginRebuild(options.RawTags.Count); + foreach (var entry in options.RawTags) { if (!AbLegacyTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def)) { @@ -201,10 +227,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover _driverInstanceId, entry.RawPath); continue; } - _tagsByRawPath[entry.RawPath] = def with + tags[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent, - DeviceHostAddress = ResolveDeviceHost(entry.DeviceName), + DeviceHostAddress = ResolveDeviceHost(options, entry.DeviceName), }; } _skippedTags.Commit(); @@ -213,9 +239,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover // and String (ST-file) are not supported by all PCCC families; reject them early // so a misconfigured tag fails at init time with a clear message rather than // surfacing an opaque comms error at runtime. - foreach (var tag in _tagsByRawPath.Values) + foreach (var tag in tags.Values) { - if (!_devices.TryGetValue(tag.DeviceHostAddress, out var deviceForTag)) continue; + if (!devices.TryGetValue(tag.DeviceHostAddress, out var deviceForTag)) continue; var profile = deviceForTag.Profile; if (tag.DataType == AbLegacyDataType.Long && !profile.SupportsLongFile) throw new InvalidOperationException( @@ -227,68 +253,107 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover $"(family {deviceForTag.Options.PlcFamily}) does not support ST-files."); } - // Probe loops — one per device when enabled + probe address configured. - if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress)) + // Publish, start the probe loops and go Healthy as ONE step under the lifecycle lock, and only if no + // teardown (or newer initialise) ran since this attempt began. Starting the loops inside the lock + // means a teardown that swaps this table out always sees their tasks and joins them. + lock (_lifecycleLock) { - foreach (var state in _devices.Values) + if (epoch != _lifecycleEpoch) + throw new OperationCanceledException( + $"AbLegacy driver {_driverInstanceId} initialise was superseded by a shutdown or a newer initialise."); + _options = options; + _tagsByRawPath = tags; + _devices = devices; + _resolver.Clear(); // drop transient parses made against the previous table + if (options.Probe.Enabled && !string.IsNullOrWhiteSpace(options.Probe.ProbeAddress)) { - state.ProbeCts = new CancellationTokenSource(); - var ct = state.ProbeCts.Token; - state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct); + foreach (var state in devices.Values) + { + state.ProbeCts = new CancellationTokenSource(); + var ct = state.ProbeCts.Token; + var probe = options.Probe; + state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, probe, ct), ct); + } } + _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); } - _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); } catch (Exception ex) { - _health = new DriverHealth(DriverState.Faulted, null, ex.Message); + // Release what this attempt built. Nothing was published (the publish is the last statement of the + // try and runs entirely under the lock), so no loop is running against these states. + foreach (var state in devices.Values) state.Retire(); + if (ex is OperationCanceledException && epoch != Volatile.Read(ref _lifecycleEpoch)) + { + _logger.LogInformation( + "AbLegacy driver {DriverInstanceId} initialise abandoned: superseded before it could publish.", + _driverInstanceId); + throw; + } + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Faulted, null, ex.Message)); _logger.LogError(ex, "AbLegacy driver initialise failed. Driver={DriverInstanceId}", _driverInstanceId); - // Release cached state created before the failure so a caller who catches and abandons - // (rather than retrying via ReinitializeAsync) doesn't leave CancellationTokenSources and - // libplctag handles alive. No probe-loop join here: the loops start as the LAST statement of - // the try and nothing after that can throw, so a failure never has a running loop to join. - foreach (var state in _devices.Values) - { - state.ProbeCts?.Dispose(); - state.ProbeCts = null; - state.ProbeTask = null; - state.DisposeRuntimes(); - } - _devices.Clear(); - _tagsByRawPath.Clear(); - _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses throw; } } - /// - /// Cancels, bounded-joins and disposes every existing device state, then clears the table. - /// Device-state part of only (no poll / subscription teardown). - /// - private async Task ReleaseDeviceStatesAsync() + /// Writes only while is still the current lifecycle + /// epoch, so a superseded initialise never overwrites the health of the teardown that superseded it. + private void SetHealthIfCurrent(long epoch, DriverHealth health) { - if (_devices.Count == 0) return; - foreach (var state in _devices.Values) + lock (_lifecycleLock) + { + if (epoch == _lifecycleEpoch) _health = health; + } + } + + /// The current lifecycle epoch; a read/write captures it so a late result cannot rewrite health after a teardown. + private long CurrentEpoch => Volatile.Read(ref _lifecycleEpoch); + + /// + /// Atomically swaps the device table (and, for a teardown, the tag table) for an empty one and returns the + /// device states that were published, for the caller to release. Concurrent teardowns (ShutdownAsync racing + /// Dispose) each get a DISJOINT set, so no state is released twice and neither enumerates a table the other + /// is clearing. + /// + /// True for a teardown: supersedes any in-flight initialise. + /// The device states that were published. + private List RetireTables(bool bumpEpoch) + { + lock (_lifecycleLock) + { + if (bumpEpoch) + { + _lifecycleEpoch++; + _tagsByRawPath = new Dictionary(StringComparer.Ordinal); + } + var retired = _devices.Values.ToList(); + _devices = new Dictionary(StringComparer.OrdinalIgnoreCase); + return retired; + } + } + + /// + /// Cancels, bounded-joins and retires the given (already unpublished) device states. + /// + /// The states to release. + /// "shutdown" / "re-initialise", for the join-timeout warning. + private async Task ReleaseDeviceStatesAsync(List states, string phase) + { + if (states.Count == 0) return; + foreach (var state in states) try { state.ProbeCts?.Cancel(); } catch { } - var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToList(); + var loops = states.Select(d => d.ProbeTask).OfType().ToList(); if (loops.Count > 0) { var all = Task.WhenAll(loops); var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false); if (finished != all) _logger.LogWarning( - "AbLegacy re-initialise: {Count} probe loop(s) did not stop within {Timeout}; proceeding. Driver={DriverInstanceId}", - loops.Count, LoopJoinTimeout, _driverInstanceId); + "AbLegacy {Phase}: {Count} probe loop(s) did not stop within {Timeout}; proceeding. Driver={DriverInstanceId}", + phase, loops.Count, LoopJoinTimeout, _driverInstanceId); } - foreach (var state in _devices.Values) - { - state.ProbeCts?.Dispose(); - state.ProbeCts = null; - state.ProbeTask = null; - state.DisposeRuntimes(); - } - _devices.Clear(); + foreach (var state in states) state.Retire(); } /// @@ -305,44 +370,35 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// Synchronous bounded join of the (already-cancelled) probe loops for the sync teardown paths. /// The loop body uses ConfigureAwait(false) throughout, so blocking here cannot deadlock. /// - private void JoinProbeLoopsSync() + /// The retired states whose loops to join. + private static void JoinProbeLoopsSync(List states) { - var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToArray(); + var loops = states.Select(d => d.ProbeTask).OfType().ToArray(); if (loops.Length == 0) return; try { Task.WaitAll(loops, LoopJoinTimeout); } catch (AggregateException) { /* loops observe cancellation; a fault is not actionable at teardown */ } } - /// + /// + /// Complete teardown: stops every poll subscription, cancels and (bounded) joins the probe loops, disposes + /// every cached runtime and unpublishes both tables. Safe to run concurrently with / + /// (each teardown retires a disjoint set of states) and with an in-flight + /// , which it supersedes: that attempt will not publish. + /// + /// Unused; teardown is bounded by . + /// A task that completes when teardown is done. public async Task ShutdownAsync(CancellationToken cancellationToken) { + // Unpublish FIRST, so an in-flight initialise is superseded and new reads resolve nothing. + var retired = RetireTables(bumpEpoch: true); await _poll.DisposeAsync().ConfigureAwait(false); // Cancel the probe loops, JOIN them (bounded), then dispose the CTSs and runtimes. // The loop used to be fire-and-forget, so one parked in a probe-runtime create/read could - // outlive shutdown and keep a libplctag handle alive past DisposeRuntimes. - foreach (var state in _devices.Values) - try { state.ProbeCts?.Cancel(); } catch { } - var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToList(); - if (loops.Count > 0) - { - var all = Task.WhenAll(loops); - var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false); - if (finished != all) - _logger.LogWarning( - "AbLegacy shutdown: {Count} probe loop(s) did not stop within {Timeout}; proceeding with teardown. Driver={DriverInstanceId}", - loops.Count, LoopJoinTimeout, _driverInstanceId); - } - foreach (var state in _devices.Values) - { - state.ProbeCts?.Dispose(); - state.ProbeCts = null; - state.ProbeTask = null; - state.DisposeRuntimes(); - } - _devices.Clear(); - _tagsByRawPath.Clear(); + // outlive shutdown and keep a libplctag handle alive past retirement. + await ReleaseDeviceStatesAsync(retired, "shutdown").ConfigureAwait(false); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses - _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + lock (_lifecycleLock) + _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } /// @@ -369,6 +425,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover IReadOnlyList fullReferences, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(fullReferences); + var epoch = CurrentEpoch; var now = DateTime.UtcNow; var results = new DataValueSnapshot[fullReferences.Count]; @@ -404,8 +461,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { results[i] = new DataValueSnapshot(null, res.Status, null, now); if (res.TransportFault) - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, - $"transport fault resolving indirect address for {reference}"); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, + $"transport fault resolving indirect address for {reference}")); continue; } def = res.Def; @@ -443,8 +500,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover EvictRuntime(device, def.Name); results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(status), null, now); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, - $"transport status {status} reading {reference}"); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, + $"transport status {status} reading {reference}")); if (!device.FirstNonZeroStatusLogged) { device.FirstNonZeroStatusLogged = true; @@ -459,7 +516,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover device.FirstNonZeroStatusLogged = false; results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now); - _health = new DriverHealth(DriverState.Healthy, now, null); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Healthy, now, null)); } catch (OperationCanceledException) { throw; } catch (AbTransportException ex) when (IsLocalRefusal(ex)) @@ -474,7 +531,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover EvictRuntime(device, def.Name); results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message)); } } @@ -488,6 +545,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover IReadOnlyList writes, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(writes); + var epoch = CurrentEpoch; var results = new WriteResult[writes.Count]; for (var i = 0; i < writes.Count; i++) @@ -530,8 +588,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { results[i] = new WriteResult(res.Status); if (res.TransportFault) - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, - $"transport fault resolving indirect address for {w.FullReference}"); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, + $"transport fault resolving indirect address for {w.FullReference}")); continue; } def = res.Def; @@ -597,7 +655,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover catch (NotSupportedException nse) { results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message)); } catch (Exception ex) when (ex is FormatException or InvalidCastException) { @@ -618,7 +676,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover // Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip). EvictRuntime(device, def.Name); results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message)); } } @@ -684,7 +742,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover public IReadOnlyList GetHostStatuses() => [.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))]; - private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct) + private async Task ProbeLoopAsync(DeviceState state, AbLegacyProbeOptions probe, CancellationToken ct) { var probeParams = new AbLegacyTagCreateParams( Gateway: state.ParsedAddress.Gateway, @@ -693,9 +751,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute, // Normalised exactly like tag paths (PLC-5 octal I/O -> decimal); an unparseable probe address is passed through // so the transport reports the failure as it always did. - TagName: AbLegacyProbeAddress.Normalise(_options.Probe.ProbeAddress, state.Options.PlcFamily) - ?? _options.Probe.ProbeAddress!, - Timeout: _options.Probe.Timeout); + TagName: AbLegacyProbeAddress.Normalise(probe.ProbeAddress, state.Options.PlcFamily) + ?? probe.ProbeAddress!, + Timeout: probe.Timeout); IAbLegacyTagRuntime? probeRuntime = null; while (!ct.IsCancellationRequested) @@ -722,7 +780,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); - try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); } + try { await Task.Delay(probe.Interval, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } } try { probeRuntime?.Dispose(); } catch { } @@ -768,21 +826,22 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// HostAddress as the connection key. The HostAddress fallback is deliberate: the Client CLI /// passes the host as the DeviceName. /// + /// The options being initialised (their device list is the routing table). /// The owning device name delivered on the . /// The matched device's HostAddress; empty when unroutable. - private string ResolveDeviceHost(string deviceName) + private static string ResolveDeviceHost(AbLegacyDriverOptions options, string deviceName) { if (!string.IsNullOrEmpty(deviceName)) { - foreach (var d in _options.Devices) + foreach (var d in options.Devices) if (string.Equals(d.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase)) return d.HostAddress; - foreach (var d in _options.Devices) + foreach (var d in options.Devices) if (string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase)) return d.HostAddress; } // Best-available fallback: a single-device driver routes every tag to its sole device. - return _options.Devices.Count == 1 ? _options.Devices[0].HostAddress : ""; + return options.Devices.Count == 1 ? options.Devices[0].HostAddress : ""; } // ---- IPerCallHostResolver ---- @@ -1011,7 +1070,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: parentName, Timeout: _options.Timeout)); - await InitializeOrKeepRefusedAsync(runtime, () => device.ParentRuntimes[parentName] = runtime, ct) + await InitializeOrKeepRefusedAsync(runtime, () => device.Cache(device.ParentRuntimes, parentName, runtime), ct) .ConfigureAwait(false); return runtime; } @@ -1117,7 +1176,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}."); var runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed)); - await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct) + await InitializeOrKeepRefusedAsync(runtime, () => device.Cache(device.Runtimes, def.Name, runtime), ct) .ConfigureAwait(false); return runtime; } @@ -1158,6 +1217,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// public void Dispose() { + var retired = RetireTables(bumpEpoch: true); // ValueTask.ConfigureAwait(false).GetAwaiter().GetResult() — drains the poll engine // shutdown on the current thread without capturing the SynchronizationContext. The // engine cancels every loop CTS up-front then either completes immediately (no @@ -1165,20 +1225,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover // already-shutting-down loop tasks. We swallow exceptions so a buggy poll-loop teardown // can't poison the rest of the disposal chain. try { _poll.DisposeAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } catch { } - foreach (var state in _devices.Values) + foreach (var state in retired) try { state.ProbeCts?.Cancel(); } catch { } - JoinProbeLoopsSync(); - foreach (var state in _devices.Values) - { - state.ProbeCts?.Dispose(); - state.ProbeCts = null; - state.ProbeTask = null; - state.DisposeRuntimes(); - } - _devices.Clear(); - _tagsByRawPath.Clear(); + JoinProbeLoopsSync(retired); + foreach (var state in retired) state.Retire(); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses - _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + lock (_lifecycleLock) + _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } /// @@ -1244,7 +1297,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// Per-key creation locks for and . /// A caller holds this before the TryGetValue + Create + InitializeAsync + TryAdd /// sequence so that a concurrent caller waits rather than creating a duplicate runtime - /// that would be leaked on . + /// that would be leaked on . /// private readonly System.Collections.Concurrent.ConcurrentDictionary _creationLocks = new(StringComparer.OrdinalIgnoreCase); @@ -1326,25 +1379,60 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// Tags already warned about for a malformed address (once-per-tag log). public System.Collections.Concurrent.ConcurrentDictionary MalformedAddressLogged { get; } = new(); + private volatile bool _retired; + + /// True once this state has been unpublished and released; nothing may be cached on it any more. + public bool IsRetired => _retired; + /// - /// Disposes all cached tag runtimes. + /// Caches a freshly initialised runtime under . A concurrent creator that won the + /// race keeps its runtime (the loser is disposed, never overwritten-and-leaked). A runtime cached AFTER + /// drained the table — a read that was already in flight when shutdown ran — is + /// removed and disposed here, because nothing would ever release it otherwise. /// - public void DisposeRuntimes() + /// The runtime table ( or ). + /// The runtime key. + /// The initialised runtime. + public void Cache(System.Collections.Concurrent.ConcurrentDictionary table, + string key, IAbLegacyTagRuntime runtime) { - foreach (var r in Runtimes.Values) r.Dispose(); - Runtimes.Clear(); - foreach (var r in ParentRuntimes.Values) r.Dispose(); - ParentRuntimes.Clear(); + if (!table.TryAdd(key, runtime)) + { + if (!ReferenceEquals(table.GetValueOrDefault(key), runtime)) + { + try { runtime.Dispose(); } catch { /* best-effort */ } + return; + } + } + // The ConcurrentDictionary write above is a full fence, so this read cannot be satisfied before it: + // either Retire's drain sees the entry, or this check sees the retirement. + if (_retired && table.TryRemove(new KeyValuePair(key, runtime))) + { + try { runtime.Dispose(); } catch { /* best-effort */ } + } + } + + /// + /// Retires the state: marks it, disposes the probe CTS and every cached runtime. Idempotent. The + /// per-key gates are deliberately NOT disposed: an operation that resolved + /// this state before it was unpublished may still be waiting on or releasing one, and disposing it under + /// that caller turned a clean teardown into s. A SemaphoreSlim whose + /// AvailableWaitHandle was never touched holds no unmanaged resource; the gates go with the state. + /// + public void Retire() + { + _retired = true; + var cts = ProbeCts; + ProbeCts = null; + ProbeTask = null; + try { cts?.Dispose(); } catch { /* best-effort */ } + foreach (var key in Runtimes.Keys) + if (Runtimes.TryRemove(key, out var r)) + try { r.Dispose(); } catch { /* best-effort */ } + foreach (var key in ParentRuntimes.Keys) + if (ParentRuntimes.TryRemove(key, out var r)) + try { r.Dispose(); } catch { /* best-effort */ } IndirectLastName.Clear(); - // Dispose + clear the per-parent RMW gates and the per-runtime/creation locks so - // ReinitializeAsync cycles don't orphan their SemaphoreSlim instances (each leaks a - // wait handle once contended). - foreach (var sem in _rmwLocks.Values) sem.Dispose(); - _rmwLocks.Clear(); - foreach (var sem in _creationLocks.Values) sem.Dispose(); - _creationLocks.Clear(); - foreach (var sem in _runtimeLocks.Values) sem.Dispose(); - _runtimeLocks.Clear(); } } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs index 3229fb53..6d7c1a1b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs @@ -205,7 +205,7 @@ public sealed class AbLegacyBitRmwTests var firstResult = await drv.WriteAsync([new WriteRequest("Bit2", true)], CancellationToken.None); firstResult.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); - // Teardown — DisposeRuntimes must dispose + clear the RMW lock map. + // Teardown — retirement drops the device state (and its RMW lock map) with it. await drv.DisposeAsync(); // Second session: re-init on a fresh driver instance (mirrors ReinitializeAsync behavior). diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyLifecycleConcurrencyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyLifecycleConcurrencyTests.cs new file mode 100644 index 00000000..58f4f95d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyLifecycleConcurrencyTests.cs @@ -0,0 +1,255 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// Review 07 H13 + the Lane A runtime contract: the device and tag tables are build-and-swapped (never cleared +/// in place under a reader), Dispose may run concurrently with a still-running ShutdownAsync, and +/// an abandoned InitializeAsync that overlaps the next ShutdownAsync must not resurrect state. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyLifecycleConcurrencyTests +{ + private const string Host = "ab://10.0.0.5/1,0"; + + /// A probe tag whose read parks until released, ignoring cancellation (a wedged device call). + private sealed class ParkingProbeTag(AbLegacyTagCreateParams p, Task release) : FakeAbLegacyTag(p) + { + public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override async Task ReadAsync(CancellationToken ct) + { + Entered.TrySetResult(); + await release.ConfigureAwait(false); + await base.ReadAsync(ct).ConfigureAwait(false); + } + } + + private static async Task WaitUntilAsync(Func condition, string what) + { + var deadline = DateTime.UtcNow.AddSeconds(10); + while (!condition()) + { + if (DateTime.UtcNow > deadline) throw new TimeoutException(what); + await Task.Delay(10); + } + } + + /// + /// Re-initialising in a loop while other threads read the tables never throws "Collection was modified": + /// the readers see either the old or the new table, never one being cleared. + /// + [Fact] + public async Task Reinit_under_concurrent_readers_never_throws() + { + var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host), new AbLegacyDeviceOptions("ab://10.0.0.6/1,0")], + RawTags = AbLegacyRawTags.Entries( + new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int), + new AbLegacyTagDefinition("Y", "ab://10.0.0.6/1,0", "N7:1", AbLegacyDataType.Int)), + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-swap", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + using var stop = new CancellationTokenSource(); + var failures = new System.Collections.Concurrent.ConcurrentQueue(); + var reads = 0; + var readers = Enumerable.Range(0, 3).Select(n => Task.Run(async () => + { + while (!stop.IsCancellationRequested) + { + try + { + _ = drv.GetHostStatuses(); + _ = drv.ResolveHost("X"); + _ = await drv.ReadAsync(["X", "Y"], CancellationToken.None); + Interlocked.Increment(ref reads); + } + catch (Exception ex) { failures.Enqueue(ex); } + } + })).ToArray(); + + // Keep re-initialising until the readers have demonstrably overlapped many swaps. + var deadline = DateTime.UtcNow.AddSeconds(10); + for (var i = 0; i < 100 || Volatile.Read(ref reads) < 200; i++) + { + if (DateTime.UtcNow > deadline) break; + await drv.InitializeAsync("{}", CancellationToken.None); + } + stop.Cancel(); + await Task.WhenAll(readers); + + failures.ShouldBeEmpty(); + reads.ShouldBeGreaterThan(0); // positive control: the readers really ran against the swapping tables + await drv.ShutdownAsync(CancellationToken.None); + } + + /// A re-init publishes a NEW device state; the old one is retired, not mutated in place. + [Fact] + public async Task Reinit_publishes_a_new_table_and_retires_the_old_state() + { + var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host)], + RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)), + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-swap", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var old = drv.GetDeviceState(Host)!; + var oldTag = factory.Tags["N7:0"]; + + await drv.InitializeAsync("{}", CancellationToken.None); + + var fresh = drv.GetDeviceState(Host)!; + fresh.ShouldNotBeSameAs(old); + old.IsRetired.ShouldBeTrue(); + oldTag.Disposed.ShouldBeTrue(); + (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + await drv.ShutdownAsync(CancellationToken.None); + } + + /// + /// A runtime created by a read that was already in flight when shutdown retired its device state is disposed + /// instead of being cached on the retired state (where nothing would ever release it). + /// + [Fact] + public async Task Runtime_created_after_retirement_is_disposed_not_cached() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + FakeAbLegacyTag? created = null; + var factory = new FakeAbLegacyTagFactory + { + Customise = p => created = new InitParkingTag(p, entered, release.Task) { Value = 1 }, + }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host)], + RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)), + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-late", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + var state = drv.GetDeviceState(Host)!; + + var read = drv.ReadAsync(["X"], CancellationToken.None); + await entered.Task; + await drv.ShutdownAsync(CancellationToken.None); + release.SetResult(); + _ = await read; + + created.ShouldNotBeNull(); + created!.Disposed.ShouldBeTrue(); + state.Runtimes.ShouldBeEmpty(); + drv.GetHealth().State.ShouldBe(DriverState.Unknown); // the late read did not overwrite shutdown's health + } + + private sealed class InitParkingTag(AbLegacyTagCreateParams p, TaskCompletionSource entered, Task release) + : FakeAbLegacyTag(p) + { + public override async Task InitializeAsync(CancellationToken ct) + { + entered.TrySetResult(); + await release.ConfigureAwait(false); + await base.InitializeAsync(ct).ConfigureAwait(false); + } + } + + /// + /// Lane A's final stop calls ShutdownAsync and then DisposeAsync/Dispose; under the 5 s bound Dispose can run + /// while ShutdownAsync is still joining a wedged probe loop. Both complete without throwing and every runtime + /// is released. + /// + [Fact] + public async Task Dispose_concurrent_with_running_shutdown_is_safe() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + ParkingProbeTag? probe = null; + var factory = new FakeAbLegacyTagFactory + { + Customise = p => p.TagName == "S:0" + ? probe = new ParkingProbeTag(p, release.Task) + : new FakeAbLegacyTag(p) { Value = 1 }, + }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host)], + RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)), + Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) }, + }, "drv-dispose", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + await WaitUntilAsync(() => probe is not null, "probe never created"); + await probe!.Entered.Task; + + var shutdown = drv.ShutdownAsync(CancellationToken.None); // parks joining the wedged probe loop + await Task.Delay(50); + shutdown.IsCompleted.ShouldBeFalse(); + + Should.NotThrow(() => drv.Dispose()); + await Should.NotThrowAsync(async () => await drv.DisposeAsync()); + release.SetResult(); + await shutdown; + + factory.Tags["N7:0"].Disposed.ShouldBeTrue(); + probe.Disposed.ShouldBeTrue(); + drv.DeviceCount.ShouldBe(0); + } + + /// + /// An InitializeAsync the runtime abandoned (it is parked releasing a wedged prior probe loop) overlaps the next + /// ShutdownAsync. When it resumes it must NOT publish its tables or start probe loops — that state would sit + /// behind a driver the runtime believes is stopped. A later Initialize publishes normally (positive control). + /// + [Fact] + public async Task Abandoned_initialize_overlapping_shutdown_does_not_resurrect_state() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var probes = new System.Collections.Concurrent.ConcurrentQueue(); + ParkingProbeTag? first = null; + var factory = new FakeAbLegacyTagFactory + { + Customise = p => + { + FakeAbLegacyTag t = first is null ? first = new ParkingProbeTag(p, release.Task) : new FakeAbLegacyTag(p); + probes.Enqueue(t); + return t; + }, + }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)], + Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) }, + }, "drv-abandon", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitUntilAsync(() => first is not null, "probe never created"); + await first!.Entered.Task; + + var abandoned = drv.InitializeAsync("{}", CancellationToken.None); // parks joining the wedged loop + await Task.Delay(50); + abandoned.IsCompleted.ShouldBeFalse(); + await drv.ShutdownAsync(CancellationToken.None); + + release.SetResult(); + await Should.ThrowAsync(() => abandoned); + var createdAfter = probes.Count; + await Task.Delay(150); // several probe intervals + + drv.DeviceCount.ShouldBe(0); + drv.GetHealth().State.ShouldBe(DriverState.Unknown); + probes.Count.ShouldBe(createdAfter); + + // Positive control: a fresh Initialize after the stop publishes and probes as normal. + await drv.InitializeAsync("{}", CancellationToken.None); + drv.DeviceCount.ShouldBe(1); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + await WaitUntilAsync(() => probes.Count > createdAfter, "fresh init never probed"); + await drv.ShutdownAsync(CancellationToken.None); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTestLogger.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTestLogger.cs new file mode 100644 index 00000000..fd946ffb --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTestLogger.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Logging; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// Thread-safe capturing logger shared by the remediation tests (poll loops log from pool threads). +internal sealed class AbLegacyTestLogger : ILogger +{ + /// Every entry logged, in order. + public System.Collections.Concurrent.ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new(); + + /// The Warning-level messages logged. + public IReadOnlyList Warnings => [.. Entries.Where(e => e.Level == LogLevel.Warning).Select(e => e.Message)]; + + /// + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => true; + + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => Entries.Enqueue((logLevel, formatter(state, exception))); +} From f087f01d2bdfa1a04428926f5ea20a7292fb464e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:07:50 -0400 Subject: [PATCH 13/67] fix(abcip): classify every controller refusal as per-tag Bad, keep the runtime H1/L4: one classification (AbCipRefusals.IsDeviceRefusal) of the statuses that prove the controller answered - NotFound, OutOfBounds, NotAllowed, TooLarge, TooSmall, TypeMismatch, Unsupported - shared by the data path, the Test-Connect probe (IsReachableStatus widened) and the native runtime. - A refused read/write (thrown or as a status word) is per-tag Bad with a once-per-tag Warning, keeps the runtime (no evict, so no lease/Forward-Open churn for a sole lease holder), writes no Degraded and, since the device answered, advances LastSuccessfulRead without touching the state. - A refused cold create records the backoff as reachable, so siblings are no longer throttled for up to 30 s by one Arr[999]. - The native runtime completes InitializeAsync on any refusal (it used to on TypeMismatch/Unsupported only) and reports it on every later read. - A refused READ of NotAllowed reads BadNotReadable, not BadNotWritable. - Test Connect names the refusal ("probe tag refused: OutOfBounds"). 0x1C/0x20/0xFF-0x2104 still fold into BadReply inside Driver.Ab.Transport (Lane K follow-up); they remain comms faults until mapped there. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipDriver.cs | 93 +++++++-- .../AbCipDriverProbe.cs | 28 ++- .../AbCipRefusals.cs | 40 ++++ .../AbCipStatusMapper.cs | 11 ++ .../NativeAbCipTagRuntime.cs | 17 +- .../AbCipAlmaProjectionTests.cs | 9 +- .../AbCipDeviceRefusalTests.cs | 181 ++++++++++++++++++ .../AbCipTypeMismatchTests.cs | 23 ++- .../NativeAbCipTagRuntimeTests.cs | 31 ++- 9 files changed, 386 insertions(+), 47 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDeviceRefusalTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 02c5f055..3ddd7e82 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -266,6 +266,21 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, _logger.LogWarning(message, args); } + // Tags whose device-refusal Warning has already been logged, per operation ("read"/"write"/…): a refused tag is + // polled (or retried) forever, so the Warning is once per tag per operation per driver instance. + private readonly System.Collections.Concurrent.ConcurrentDictionary _refusalWarned = + new(StringComparer.OrdinalIgnoreCase); + + /// Logs a device-refusal Warning the first time is refused for . + private void WarnRefusalOnce(string tagName, string operation, string reference, string device, string reason) + { + if (_refusalWarned.TryAdd($"{operation}\0{tagName}", 0)) + _logger.LogWarning( + "AbCip {Operation} of {Tag} on device {Device} refused by the controller: {Reason}; reporting it Bad " + + "for this tag only — the device and its other tags are unaffected (logged once per tag)", + operation, reference, device, reason); + } + // Tags whose malformed-name Warning has already been logged — polled forever, warned once. private readonly System.Collections.Concurrent.ConcurrentDictionary _configErrorWarned = new(StringComparer.OrdinalIgnoreCase); @@ -884,11 +899,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// A null means "no health signal" — a config-level miss such as an /// unknown reference or unknown device, which never touched the wire and never touched health. /// - private readonly record struct ReadOutcome(DriverState? State, string? Error) + private readonly record struct ReadOutcome(DriverState? State, string? Error, bool Answered = false) { /// The item produced no health signal at all. public static ReadOutcome None => new(null, null); + /// + /// The controller answered with a deterministic refusal: no health STATE signal (a refusal is not a comms + /// fault), but proof the device is reachable, so LastSuccessfulRead advances. + /// + public static ReadOutcome Refused => new(null, null, Answered: true); + /// The item read successfully. public static ReadOutcome Success => new(DriverState.Healthy, null); @@ -917,10 +938,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, private void ApplyBatchHealth(ReadOnlySpan outcomes, DateTime now) { var anySuccess = false; + var anyAnswered = false; DriverState? worst = null; string? worstError = null; foreach (var outcome in outcomes) { + if (outcome.Answered) anyAnswered = true; if (outcome.State is not { } state) continue; if (state == DriverState.Healthy) anySuccess = true; if (worst is null || HealthRank(state) > HealthRank(worst.Value)) @@ -930,13 +953,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } } - // Nothing reached the wire (every reference was unknown / unroutable) — leave health alone, - // exactly as the per-item writes did before. - if (worst is not { } worstState) return; + if (worst is not { } worstState) + { + // No state signal. If a controller answered (a refusal), that still proves it reachable: advance + // LastSuccessfulRead and leave the state alone. Otherwise nothing reached the wire (every reference was + // unknown / unroutable / misconfigured) — leave health alone entirely. + if (anyAnswered) + UpdateHealth(current => current with { LastSuccessfulRead = now }); + return; + } UpdateHealth(current => new DriverHealth( worstState, - anySuccess ? now : current.LastSuccessfulRead, + anySuccess || anyAnswered ? now : current.LastSuccessfulRead, worstState == DriverState.Healthy ? null : worstError)); } @@ -987,7 +1016,14 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, "AbCip read of {Tag} on device {Device}: the controller's type does not match the authored " + "{DataType}; reporting BadTypeMismatch until the tag's dataType is corrected (logged once per tag)", reference, def.DeviceHostAddress, def.DataType); - return ReadOutcome.None; + return ReadOutcome.Refused; + } + if (AbCipRefusals.IsDeviceRefusal(status)) + { + // The controller answered with a deterministic refusal: per-tag Bad, keep the handle. + results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.MapReadStatus((AbStatus)status), null, now); + WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ((AbStatus)status).ToString()); + return ReadOutcome.Refused; } if (status != 0) { @@ -1022,7 +1058,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, WarnTypeMismatchOnce(def.Name, "AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType (logged once per tag)", reference, def.DeviceHostAddress, size, def.DataType, width); - return ReadOutcome.None; + return ReadOutcome.Refused; } // Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an // element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array @@ -1063,7 +1099,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now); WarnTypeMismatchOnce(def.Name, "AbCip read of {Tag} on device {Device} refused: {Reason} (logged once per tag)", reference, def.DeviceHostAddress, ate.Message); - return ReadOutcome.None; + return ReadOutcome.Refused; + } + catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate.Status)) + { + // H1/L4: the controller answered "no" (renamed tag, Arr[999], read-protected …) — a per-tag config + // error, not a comms fault. Keep the handle (no evict → no lease / Forward-Open churn), no Degraded, + // no backoff (EnsureTagRuntimeAsync already recorded the answer as reachable), warn once per tag. + results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.MapReadStatus(ate.Status), null, now); + WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ate.Message); + return ReadOutcome.Refused; } catch (Exception ex) { @@ -1119,7 +1164,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, "AbCip whole-UDT read of {Parent} on device {Device}: the controller's type does not match " + "the authored type; {MemberCount} members report BadTypeMismatch (logged once per tag)", group.ParentName, parent.DeviceHostAddress, group.Members.Count); - return ReadOutcome.None; + return ReadOutcome.Refused; + } + if (AbCipRefusals.IsDeviceRefusal(status)) + { + StampGroupStatus(group, results, now, AbCipStatusMapper.MapReadStatus((AbStatus)status)); + WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, ((AbStatus)status).ToString()); + return ReadOutcome.Refused; } if (status != 0) { @@ -1167,7 +1218,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, WarnTypeMismatchOnce(parent.Name, "AbCip whole-UDT read of {Parent} on device {Device} refused: {Reason} (logged once per tag)", group.ParentName, parent.DeviceHostAddress, ate.Message); - return ReadOutcome.None; + return ReadOutcome.Refused; + } + catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate.Status)) + { + StampGroupStatus(group, results, now, AbCipStatusMapper.MapReadStatus(ate.Status)); + WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, ate.Message); + return ReadOutcome.Refused; } catch (Exception ex) { @@ -1298,6 +1355,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, "AbCip write to {Tag} on device {Device} refused: the controller's type does not match the authored {DataType} (logged once per tag)", w.FullReference, def.DeviceHostAddress, def.DataType); } + else if (AbCipRefusals.IsDeviceRefusal(status)) + { + // The controller refused this write (read-only, out of bounds …): keep the handle, no health. + results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status)); + WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ((AbStatus)status).ToString()); + } else if (status != 0) { EvictRuntime(device, def.Name); @@ -1378,6 +1441,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, "AbCip write to {Tag} on device {Device} refused: {Reason} (logged once per tag)", w.FullReference, def.DeviceHostAddress, ate.Message); } + catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate.Status)) + { + // H1: the controller answered "no" to this write — keep the handle, leave health alone, warn once. + results[i] = new WriteResult(AbCipStatusMapper.MapStatus(ate.Status)); + WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ate.Message); + } catch (Exception ex) { // Transport / wire error — evict so the next write creates a fresh handle. @@ -1538,9 +1607,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // not answer. "Tag not found" means the controller replied over a working session; it // is a config error on this one tag, and counting it as a failed Forward-Open throttled // every sibling tag on the device (seen live: one typo blacked out the whole device). - if (ex is AbTransportException ate - && ate.Status != AbStatus.Ok - && AbCipDriverProbe.IsReachableStatus(ate.Status)) + if (AbCipRefusals.IsDeviceRefusal(ex)) device.Backoff.RecordSuccess(); else device.Backoff.RecordFailure(DateTime.UtcNow); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs index e9bedb8f..52bb0f46 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs @@ -18,10 +18,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// non-CIP server) returns Ok = false with a "handshake failed" message instead of /// a false-positive green tick. /// -/// Status-code discrimination: AbStatus.Ok and AbStatus.NotFound mean the controller -/// answered the CIP session (a non-existent probe tag name still proves CIP connectivity) and are -/// treated as "reachable - Ok = true". Every other status (timeout, bad connection, bad reply, -/// partial, abort, ...) is a transport or session failure: "handshake failed - Ok = false". +/// Status-code discrimination: AbStatus.Ok and every device refusal (not found, out of bounds, +/// not allowed, too large/small, type mismatch, unsupported) mean the controller answered the CIP session +/// (a non-existent probe tag name still proves CIP connectivity) and are treated as "reachable - Ok = true". +/// Every other status (timeout, bad connection, bad reply, partial, abort, ...) is a transport or session +/// failure: "handshake failed - Ok = false". /// /// public sealed class AbCipDriverProbe : IDriverProbe @@ -147,7 +148,7 @@ public sealed class AbCipDriverProbe : IDriverProbe { var msg = statusCode == AbStatus.Ok ? "CIP session OK" - : "CIP session OK (controller reachable; probe tag not found)"; + : RefusedProbeMessage(statusCode); return new(true, msg, sw.Elapsed); } @@ -164,7 +165,7 @@ public sealed class AbCipDriverProbe : IDriverProbe if (IsReachableException(ex)) { sw.Stop(); - return new(true, "CIP session OK (controller reachable; probe tag not found)", sw.Elapsed); + return new(true, RefusedProbeMessage(ex.Status), sw.Elapsed); } return new(false, $"Reachable at {host}:{port} but CIP handshake failed: {ex.Message}", null); @@ -180,13 +181,20 @@ public sealed class AbCipDriverProbe : IDriverProbe } /// - /// Returns true for transport statuses that indicate the CIP controller answered - /// but the probe tag name was not found or did not match — sufficient to confirm CIP - /// reachability. Returns true for Ok (tag was found). + /// Returns true for transport statuses that indicate the CIP controller answered — Ok, or any + /// device refusal (: not found, out of bounds, not + /// allowed, too large/small, type mismatch, unsupported) — sufficient to confirm CIP reachability. /// /// The transport status to classify. /// if the status confirms CIP reachability. - internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound; + internal static bool IsReachableStatus(AbStatus s) => s == AbStatus.Ok || AbCipRefusals.IsDeviceRefusal(s); + + /// The Test-Connect message for a controller that answered but refused the probe tag. + /// The refusal. + /// The message. + private static string RefusedProbeMessage(AbStatus status) => status == AbStatus.NotFound + ? "CIP session OK (controller reachable; probe tag not found)" + : $"CIP session OK (controller reachable; probe tag refused: {status})"; /// /// Decides whether an is a "controller answered CIP but the diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs new file mode 100644 index 00000000..fa82e31b --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs @@ -0,0 +1,40 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// The ONE classification of a transport status into "the controller answered" vs "the wire failed", shared by +/// the data path, the probe loop, the Test-Connect probe and the native runtime's initialisation. +/// +/// +/// A device refusal is deterministic: the session works and the controller said no to this one tag (a renamed +/// tag, Arr[999], a read-protected tag, a type the authored config does not match). It is per-tag Bad, +/// keeps the tag runtime (evicting re-creates it — and, for a sole lease holder, re-Forward-Opens — on every poll), +/// never writes Degraded, never opens the device-wide connect backoff, and warns once per tag. Everything +/// else (timeout, bad connection, a reply that failed to parse, partial, abort, …) is a comms fault. +/// +/// The native transport currently folds CIP general statuses 0x1C, 0x20 and 0xFF/0x2104 into +/// (AbStatusCodes.FromCip, owned by Driver.Ab.Transport), which +/// this classification cannot tell from a garbled reply; until the transport maps them explicitly they stay +/// comms faults. +/// +/// +internal static class AbCipRefusals +{ + /// True when proves the controller answered but refused this tag. + /// The transport status. + /// for a deterministic device refusal. + public static bool IsDeviceRefusal(AbStatus status) => status is AbStatus.NotFound or AbStatus.OutOfBounds + or AbStatus.NotAllowed or AbStatus.TooLarge or AbStatus.TooSmall or AbStatus.TypeMismatch + or AbStatus.Unsupported; + + /// True when (an value) is a device refusal. + /// The runtime status word. + /// for a deterministic device refusal. + public static bool IsDeviceRefusal(int status) => IsDeviceRefusal((AbStatus)status); + + /// True when is an carrying a device refusal. + /// The exception. + /// for a deterministic device refusal. + public static bool IsDeviceRefusal(Exception ex) => ex is AbTransportException ate && IsDeviceRefusal(ate.Status); +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs index 18296c88..2988a685 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs @@ -15,6 +15,7 @@ public static class AbCipStatusMapper public const uint GoodMoreData = 0x00A60000u; public const uint BadInternalError = 0x80020000u; public const uint BadNodeIdUnknown = 0x80340000u; + public const uint BadNotReadable = 0x803A0000u; public const uint BadNotWritable = 0x803B0000u; public const uint BadOutOfRange = 0x803C0000u; public const uint BadNotSupported = 0x803D0000u; @@ -51,4 +52,14 @@ public static class AbCipStatusMapper /// The OPC UA status code. public static uint MapException(Exception ex) => AbOpcUaStatusMap.MapException(ex); + + /// + /// Status for a READ the controller refused: as , except that a + /// answer to a read is (the shared table's + /// BadNotWritable describes a refused write). + /// + /// The refusal status. + /// The OPC UA status code. + public static uint MapReadStatus(AbStatus status) => + status == AbStatus.NotAllowed ? BadNotReadable : MapStatus(status); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs index af1ce7e6..8bf65cca 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs @@ -94,16 +94,15 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime /// The trailing bit selector split off the tag name (Tag.5), if any. internal int? ImplicitBit => _implicitBit; - // Performs one read to prove the tag exists: libplctag's InitializeAsync errors for an unknown - // tag, and the driver's backoff classification depends on create throwing AbStatus.NotFound. + // Performs one read to prove the session works and learn the tag's type. // // A name the encoder refuses throws AbCipTagConfigurationException before any lease: it is this // tag's configuration, not the device, and must not black out the device's other tags. A read the - // controller answers with a deterministic refusal (AbStatus.TypeMismatch / AbStatus.Unsupported) - // proves the tag exists, so it completes the initialization with that status rather than throwing: - // a throw here makes the driver dispose the runtime and re-create it - and, for a sole lease - // holder, re-Forward-Open - on every poll (mistype churn). The driver's own read reports the - // refusal. + // controller answers with a deterministic refusal (AbCipRefusals.IsDeviceRefusal: NotFound, OutOfBounds, + // NotAllowed, TooLarge/TooSmall, TypeMismatch, Unsupported) proves the session works, so it completes the + // initialization with that status rather than throwing: a throw here makes the driver dispose the runtime + // and re-create it - and, for a sole lease holder, re-Forward-Open - on every poll (a renamed tag or a + // mistype churned the lease). The driver's own read reports the refusal, per tag, on the kept runtime. /// public async Task InitializeAsync(CancellationToken cancellationToken) { @@ -137,9 +136,9 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime { await ReadAsync(cancellationToken).ConfigureAwait(false); } - catch (AbTransportException ex) when (ex.Status is AbStatus.TypeMismatch or AbStatus.Unsupported) + catch (AbTransportException ex) when (AbCipRefusals.IsDeviceRefusal(ex.Status)) { - // The controller answered — the tag exists. _status already holds the refusal. + // The controller answered. _status already holds the refusal. } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlmaProjectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlmaProjectionTests.cs index c6012a88..d93616e8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlmaProjectionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipAlmaProjectionTests.cs @@ -311,8 +311,9 @@ public sealed class AbCipAlmaProjectionTests // A non-zero libplctag status ⇒ Bad on every member read; the driver evicts the handle and re-creates it, so // every re-created runtime must fail too (a fresh fake would read as "all clear"). var recreations = 0; - r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; }; - foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.NotFound; + // A comms status (not a device refusal, which keeps the handle since review 07 H1) so the handle is evicted. + r.Factory.Customise = p => { Interlocked.Increment(ref recreations); return new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }; }; + foreach (var t in r.Factory.Tags.Values.ToList()) t.Status = (int)AbStatus.BadConnection; await WaitFor(() => Volatile.Read(ref recreations) >= 6); await r.StopAsync(); Snapshot(r).Any(x => x.Kind == AlarmTransitionKind.Clear).ShouldBeFalse(); @@ -334,9 +335,9 @@ public sealed class AbCipAlmaProjectionTests { if (!p.TagName.EndsWith("HAcked", StringComparison.Ordinal)) return null!; Interlocked.Increment(ref bad); - return new FakeAbCipTag(p) { Status = (int)AbStatus.NotFound }; + return new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }; // comms ⇒ evicted + re-created }; - r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.NotFound; + r.Factory.Tags[$"{Tag}.HAcked"].Status = (int)AbStatus.BadConnection; await WaitFor(() => Volatile.Read(ref bad) >= 4); // The link recovers and HAcked reads true again. r.Factory.Customise = p => new FakeAbCipTag(p) { Value = true }; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDeviceRefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDeviceRefusalTests.cs new file mode 100644 index 00000000..2cb690f5 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDeviceRefusalTests.cs @@ -0,0 +1,181 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Review 07 H1 / L4: a status that proves the controller ANSWERED (NotFound, OutOfBounds, NotAllowed, +/// TooLarge, TooSmall, TypeMismatch, Unsupported) is a deterministic per-tag refusal, not a comms fault. It +/// keeps the tag runtime (no evict → no lease / Forward-Open churn), writes no Degraded, opens no device-wide +/// backoff window for siblings, warns once, and — since the device answered — advances LastSuccessfulRead. +/// +[Trait("Category", "Unit")] +public sealed class AbCipDeviceRefusalTests +{ + private const string Device = "ab://10.0.0.5/1,0"; + + /// A fake whose reads/writes throw a chosen transport status (init succeeds unless told otherwise). + private sealed class RefusingTag(AbCipTagCreateParams p) : FakeAbCipTag(p) + { + public AbStatus? ReadThrows { get; set; } + public AbStatus? WriteThrows { get; set; } + + public override async Task ReadAsync(CancellationToken cancellationToken) + { + await base.ReadAsync(cancellationToken); + if (ReadThrows is { } s) throw new AbTransportException(s, s.ToString()); + } + + public override async Task WriteAsync(CancellationToken cancellationToken) + { + await base.WriteAsync(cancellationToken); + if (WriteThrows is { } s) throw new AbTransportException(s, s.ToString()); + } + } + + private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build( + Func make) + { + var factory = new FakeAbCipTagFactory { Customise = make }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device)], + RawTags = AbCipRawTags.From( + new AbCipTagDefinition("Bad", Device, "Arr[999]", AbCipDataType.DInt), + new AbCipTagDefinition("Good", Device, "Real1", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions { Enabled = false }, + MaxConcurrentReads = 1, + }, "abcip-refusal", factory); + return (drv, factory); + } + + /// The classification itself: every answered refusal is reachable; transport failures are not. + /// The transport status. + /// Whether it is a device refusal. + [Theory] + [InlineData(AbStatus.NotFound, true)] + [InlineData(AbStatus.OutOfBounds, true)] + [InlineData(AbStatus.NotAllowed, true)] + [InlineData(AbStatus.TooLarge, true)] + [InlineData(AbStatus.TooSmall, true)] + [InlineData(AbStatus.TypeMismatch, true)] + [InlineData(AbStatus.Unsupported, true)] + [InlineData(AbStatus.Ok, false)] + [InlineData(AbStatus.Timeout, false)] + [InlineData(AbStatus.BadConnection, false)] + [InlineData(AbStatus.BadReply, false)] + [InlineData(AbStatus.Partial, false)] + [InlineData(AbStatus.Abort, false)] + public void Answered_statuses_are_device_refusals(AbStatus status, bool refusal) + { + AbCipRefusals.IsDeviceRefusal(status).ShouldBe(refusal); + AbCipDriverProbe.IsReachableStatus(status).ShouldBe(refusal || status == AbStatus.Ok); + } + + /// A read the controller refuses keeps its runtime, stays Healthy, and advances LastSuccessfulRead. + /// The refusal the controller answers with. + /// The OPC UA status the tag reads. + [Theory] + [InlineData(AbStatus.NotFound, AbCipStatusMapper.BadNodeIdUnknown)] + [InlineData(AbStatus.OutOfBounds, AbCipStatusMapper.BadOutOfRange)] + [InlineData(AbStatus.TooLarge, AbCipStatusMapper.BadOutOfRange)] + [InlineData(AbStatus.TooSmall, AbCipStatusMapper.BadOutOfRange)] + [InlineData(AbStatus.NotAllowed, AbCipStatusMapper.BadNotReadable)] + public async Task A_refused_read_keeps_the_runtime_and_writes_no_Degraded(AbStatus status, uint expected) + { + var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? status : null, Value = 1 }); + await drv.InitializeAsync("{}", CancellationToken.None); + var before = drv.GetHealth().LastSuccessfulRead!.Value; + await Task.Delay(20); + + for (var i = 0; i < 3; i++) + { + var r = await drv.ReadAsync(["Bad"], CancellationToken.None); + r[0].StatusCode.ShouldBe(expected); + } + + var bad = factory.Tags["Arr[999]"]; + bad.InitializeCount.ShouldBe(1); // never evicted → never re-created + bad.Disposed.ShouldBeFalse(); + bad.ReadCount.ShouldBe(3); + var health = drv.GetHealth(); + health.State.ShouldBe(DriverState.Healthy); + health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before); // the device answered + } + + /// Positive control: a genuine transport failure on the same tag still evicts and degrades. + [Fact] + public async Task A_transport_failure_still_evicts_and_degrades() + { + var (drv, factory) = Build(p => new RefusingTag(p) { ReadThrows = p.TagName == "Arr[999]" ? AbStatus.Timeout : null }); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync(["Bad"], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTimeout); + factory.Tags["Arr[999]"].Disposed.ShouldBeTrue(); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + } + + /// A refusal returned as a status word (no throw) is classified the same way. + [Fact] + public async Task A_refusal_status_word_keeps_the_runtime() + { + var (drv, factory) = Build(p => new FakeAbCipTag(p) { Status = p.TagName == "Arr[999]" ? (int)AbStatus.OutOfBounds : 0, Value = 1 }); + await drv.InitializeAsync("{}", CancellationToken.None); + + await drv.ReadAsync(["Bad"], CancellationToken.None); + var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); + r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good); + factory.Tags["Arr[999]"].InitializeCount.ShouldBe(1); + factory.Tags["Arr[999]"].Disposed.ShouldBeFalse(); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// + /// A cold create the controller refuses with OutOfBounds proves the session works: it must not open the + /// device-wide backoff window, so a sibling cold tag still connects and reads Good. + /// + [Fact] + public async Task A_refused_create_does_not_throttle_sibling_cold_creates() + { + var (drv, _) = Build(p => p.TagName == "Arr[999]" + ? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new AbTransportException(AbStatus.OutOfBounds, "OutOfBounds") } + : new FakeAbCipTag(p) { Value = 42 }); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); + r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good); + r[1].Value.ShouldBe(42); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// A write the controller refuses keeps the runtime and writes no health. + [Fact] + public async Task A_refused_write_keeps_the_runtime_and_writes_no_Degraded() + { + var factory = new FakeAbCipTagFactory { Customise = p => new RefusingTag(p) { WriteThrows = AbStatus.NotAllowed } }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Ro", Device, "ReadOnlyTag", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions { Enabled = false }, + }, "abcip-refusal", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var w1 = await drv.WriteAsync([new WriteRequest("Ro", 5)], CancellationToken.None); + var w2 = await drv.WriteAsync([new WriteRequest("Ro", 6)], CancellationToken.None); + + w1[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable); + w2[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable); + factory.Tags["ReadOnlyTag"].InitializeCount.ShouldBe(1); + factory.Tags["ReadOnlyTag"].Disposed.ShouldBeFalse(); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTypeMismatchTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTypeMismatchTests.cs index 1bd4cbe5..65171947 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTypeMismatchTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipTypeMismatchTests.cs @@ -73,7 +73,7 @@ public sealed class AbCipTypeMismatchTests creates().ShouldBe(1); factory.Tags["T"].Disposed.ShouldBeFalse(); factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime - drv.GetHealth().ShouldBe(health); + AssertStateUnchanged(drv.GetHealth(), health); MismatchWarnings(log).ShouldBe(1); } @@ -104,7 +104,7 @@ public sealed class AbCipTypeMismatchTests Volatile.Read(ref creates).ShouldBe(1); factory.Tags["T"].Disposed.ShouldBeFalse(); factory.Tags["T"].ReadCount.ShouldBe(Polls); // positive control: every poll did reach the runtime - drv.GetHealth().ShouldBe(health); + AssertStateUnchanged(drv.GetHealth(), health); drv.GetHealth().State.ShouldNotBe(DriverState.Degraded); MismatchWarnings(log).ShouldBe(1); } @@ -121,7 +121,7 @@ public sealed class AbCipTypeMismatchTests creates().ShouldBe(1); factory.Tags["T"].Disposed.ShouldBeFalse(); - drv.GetHealth().ShouldBe(health); + AssertStateUnchanged(drv.GetHealth(), health); MismatchWarnings(log).ShouldBe(1); } @@ -179,7 +179,7 @@ public sealed class AbCipTypeMismatchTests target.ForwardOpens.ShouldBe(1); target.ForwardCloses.ShouldBe(0); target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire - drv.GetHealth().ShouldBe(health); + AssertStateUnchanged(drv.GetHealth(), health); MismatchWarnings(log).ShouldBe(1); } finally @@ -226,7 +226,7 @@ public sealed class AbCipTypeMismatchTests target.ForwardOpens.ShouldBe(1); target.ForwardCloses.ShouldBe(0); target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(Polls); // positive control: polls hit the wire - drv.GetHealth().ShouldBe(health); + AssertStateUnchanged(drv.GetHealth(), health); } finally { @@ -267,12 +267,23 @@ public sealed class AbCipTypeMismatchTests Volatile.Read(ref creates).ShouldBe(1); factory.Tags["T"].Disposed.ShouldBeFalse(); factory.Tags["T"].ReadCount.ShouldBe(Polls - 1); // positive control: later polls reached the kept runtime - drv.GetHealth().ShouldBe(health); + AssertStateUnchanged(drv.GetHealth(), health); } private sealed class CountingFactory(Func create) : IAbCipTagFactory { public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams); } + /// + /// A refusal leaves the health STATE and error alone; since the controller answered, LastSuccessfulRead may + /// only move forward (review 07 H1 — any data proving the device answered advances it). + /// + private static void AssertStateUnchanged(DriverHealth actual, DriverHealth before) + { + actual.State.ShouldBe(before.State); + actual.LastError.ShouldBe(before.LastError); + (actual.LastSuccessfulRead ?? DateTime.MinValue).ShouldBeGreaterThanOrEqualTo(before.LastSuccessfulRead ?? DateTime.MinValue); + } + #pragma warning restore OTOPCUA0001 } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs index 91a1a428..35f42bd3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs @@ -129,14 +129,35 @@ public sealed class NativeAbCipTagRuntimeTests rt.GetStatus().ShouldBe((int)AbStatus.Ok); } - [Fact] - public async Task Unknown_tag_fails_initialize_with_NotFound() + /// + /// Review 07 L4: a controller refusal on the existence read completes the initialization (the session works — + /// throwing made the driver dispose + re-create the runtime, re-leasing the connection, every poll); the + /// refusal stays readable and every later read reports it again. + /// + [Theory] + [InlineData("D2000400", AbStatus.NotFound)] + [InlineData("D2000500", AbStatus.NotFound)] + [InlineData("D2000A00", AbStatus.OutOfBounds)] + [InlineData("D2000F00", AbStatus.NotAllowed)] + [InlineData("D200FF010521", AbStatus.OutOfBounds)] + public async Task A_refused_tag_completes_initialize_and_reports_the_refusal_on_read(string reply, AbStatus expected) { - var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000400")) }; + var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString(reply)) }; + using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt)); + await rt.InitializeAsync(CancellationToken.None); + rt.GetStatus().ShouldBe((int)expected); + (await Should.ThrowAsync(() => rt.ReadAsync(CancellationToken.None))) + .Status.ShouldBe(expected); + } + + /// Positive control: a reply the transport cannot classify as a refusal still fails initialize. + [Fact] + public async Task An_unclassified_reply_still_fails_initialize() + { + var ch = new FakeCipChannel { Respond = _ => Task.FromResult(Convert.FromHexString("D2000100")) }; using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Nope", AbCipDataType.DInt)); (await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None))) - .Status.ShouldBe(AbStatus.NotFound); - rt.GetStatus().ShouldBe((int)AbStatus.NotFound); + .Status.ShouldBe(AbStatus.BadReply); } [Fact] From 22d4680e7be3eba1287ade6d163e3ffb0f9335b4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:17:03 -0400 Subject: [PATCH 14/67] fix(abcip): a refused probe tag proves the device reachable (no blackout) H10: the probe loop recorded EVERY exception - including the controller answering NotFound for a typo'd or download-deleted Probe.ProbeTagPath - as a Forward-Open failure. At a 5 s tick against a 30 s backoff cap ShouldAttempt was never true again, so every cold or evicted data tag on a reachable device read BadCommunicationError indefinitely and the host showed Stopped. The probe now decides the backoff verdict and the host state separately: - a device refusal (thrown or as a status word) is backoff success + host Running + one Warning per device naming the probe tag; an initialised probe runtime is kept so its lease is not re-opened every tick; - a malformed probe tag name (AbCipTagConfigurationException) never reached the device: no backoff signal, no host transition, one Warning; - a genuine transport failure is unchanged (backoff failure, host Stopped). Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipDriver.cs | 70 ++++++++++++--- .../AbCipProbeRefusalTests.cs | 86 +++++++++++++++++++ 2 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProbeRefusalTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 3ddd7e82..c9c2f669 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -712,12 +712,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, IAbCipTagRuntime? probeRuntime = null; while (!ct.IsCancellationRequested) { - var success = false; - // The probe's backoff verdict for this tick: true = record success, false = record - // failure, null = no signal (a read that returned a non-zero status without throwing - // never recorded either, and still doesn't). Recorded AFTER the try/catch so the - // per-device connect gate is taken outside a catch block — see below. + // The probe's verdicts for this tick, decided SEPARATELY (review 07 H10): + // backoffVerdict — true = record success, false = record failure, null = no signal; + // hostState — the host transition, null = no evidence either way (leave it). + // Recorded AFTER the try/catch so the per-device connect gate is taken outside a catch block. bool? backoffVerdict = null; + HostState? hostState = HostState.Stopped; try { probeRuntime ??= _tagFactory.Create(probeParams); @@ -729,14 +729,51 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, state.ProbeInitialized = true; } await probeRuntime.ReadAsync(ct).ConfigureAwait(false); - success = probeRuntime.GetStatus() == 0; - if (success) + var status = probeRuntime.GetStatus(); + if (status == 0) + { backoffVerdict = true; // Device reachable — reset the data-path window + hostState = HostState.Running; + } + else if (AbCipRefusals.IsDeviceRefusal(status)) + { + // The controller answered, refusing the probe tag: reachable. + backoffVerdict = true; + hostState = HostState.Running; + WarnProbeRefusedOnce(state, ((AbStatus)status).ToString()); + } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } + catch (Exception ex) when (AbCipRefusals.IsDeviceRefusal(ex)) + { + // H10: a missing / typo'd / download-deleted probe tag. The controller ANSWERED over a working session, + // so this is a config error on the probe tag — not a Forward-Open failure. Recording a failure here + // every tick saturated the backoff (5 s ticks vs a 30 s cap ⇒ ShouldAttempt never true) and blacked out + // every cold / evicted data tag on a reachable device, indefinitely. + backoffVerdict = true; + hostState = HostState.Running; + WarnProbeRefusedOnce(state, ex.Message); + // Keep an initialised runtime (its lease stays up — no Forward Open/Close per tick); one whose + // initialisation threw is re-created next tick, as before. + if (!state.ProbeInitialized) + { + try { probeRuntime?.Dispose(); } catch { } + probeRuntime = null; + } + } + catch (AbCipTagConfigurationException ace) + { + // The probe tag's own name cannot become a request — it never reached the device, so it is no evidence + // either way: no backoff signal, no host transition, one Warning. + try { probeRuntime?.Dispose(); } catch { } + probeRuntime = null; + state.ProbeInitialized = false; + hostState = null; + WarnProbeRefusedOnce(state, ace.Message); + } catch (Exception ex) { // Wire / init error — tear down the probe runtime so the next tick re-creates it. @@ -748,9 +785,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, try { probeRuntime?.Dispose(); } catch { } probeRuntime = null; state.ProbeInitialized = false; - // Keep the data-path window open — unless the probe tag's own name is malformed - // — it never reached the device, so it is no evidence either way. - backoffVerdict = ex is AbCipTagConfigurationException ? null : false; + backoffVerdict = false; // the device did not answer — keep the data-path window open } // ConnectionBackoff is caller-serialized, and the data path now records under the @@ -780,7 +815,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } } - TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); + if (hostState is { } host) TransitionDeviceState(state, host); try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } @@ -789,6 +824,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, try { probeRuntime?.Dispose(); } catch { } } + /// Logs, once per device state, that the controller answered but refused the configured probe tag. + private void WarnProbeRefusedOnce(DeviceState state, string reason) + { + if (Interlocked.Exchange(ref state.ProbeRefusalWarned, 1) != 0) return; + _logger.LogWarning( + "AbCip probe tag {ProbeTag} on device {Device} cannot be read: {Reason}. The controller answered, so the device " + + "is treated as reachable; fix Probe.ProbeTagPath for driver {DriverInstanceId} (logged once per device)", + _options.Probe.ProbeTagPath, state.Options.HostAddress, reason, _driverInstanceId); + } + private void TransitionDeviceState(DeviceState state, HostState newState) { HostState old; @@ -2096,6 +2141,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// Gets or sets whether the probe has been initialized. public bool ProbeInitialized { get; set; } + /// 1 once the probe-tag refusal Warning has been logged for this device state. + public int ProbeRefusalWarned; + /// /// The fire-and-forget probe loop's . Stored so /// can await the loop's clean exit after diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProbeRefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProbeRefusalTests.cs new file mode 100644 index 00000000..c0693b8b --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipProbeRefusalTests.cs @@ -0,0 +1,86 @@ +using Microsoft.Extensions.Logging; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using CapturingLogger = ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests.AbCipLoggingTests.CapturingLogger; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Review 07 H10: a typo'd (or download-deleted) Probe.ProbeTagPath used to record a Forward-Open +/// FAILURE every probe tick, so the device's backoff window never closed and every cold / evicted data tag read +/// BadCommunicationError forever — a permanent device blackout that a restart did not cure. The controller +/// ANSWERED, so the probe now counts it as reachable: backoff success, host Running, one config Warning. +/// +[Trait("Category", "Unit")] +public sealed class AbCipProbeRefusalTests +{ + private const string Device = "ab://10.0.0.5/1,0"; + + private static (AbCipDriver Driver, CapturingLogger Logger) Build(Exception probeFault) + { + var logger = new CapturingLogger(); + var factory = new FakeAbCipTagFactory + { + Customise = p => p.TagName == "ProbeTagTypo" + ? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = probeFault } + : new FakeAbCipTag(p) { Value = 7 }, + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Device, "Motor1.Speed", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions + { + Enabled = true, + ProbeTagPath = "ProbeTagTypo", + Interval = TimeSpan.FromMilliseconds(10), + Timeout = TimeSpan.FromMilliseconds(100), + }, + }, "abcip-probe-refusal", factory, logger: logger); + return (drv, logger); + } + + private static async Task WaitUntilAsync(Func condition) + { + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!condition()) + { + if (DateTime.UtcNow > deadline) throw new TimeoutException("condition never met"); + await Task.Delay(5); + } + } + + /// A NotFound probe tag leaves the device reachable: data tags still connect, host is Running, warned once. + [Fact] + public async Task A_missing_probe_tag_does_not_black_out_the_device() + { + var (drv, logger) = Build(new AbTransportException(AbStatus.NotFound, "NotFound")); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Running); + await Task.Delay(100); // many probe ticks + + var r = await drv.ReadAsync(["Speed"], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); // a cold create, NOT throttled + r[0].Value.ShouldBe(7); + drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running); + logger.Entries.Count(e => e.Level == LogLevel.Warning && e.Message.Contains("ProbeTagTypo")).ShouldBe(1); + await drv.DisposeAsync(); + } + + /// Positive control: a probe that genuinely cannot connect still stops the host and throttles cold creates. + [Fact] + public async Task An_unreachable_device_still_stops_the_host_and_throttles() + { + var (drv, _) = Build(new AbTransportException(AbStatus.Timeout, "Timeout")); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Stopped); + + var r = await drv.ReadAsync(["Speed"], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); // throttled by the open window + await drv.DisposeAsync(); + } +} From 48b0173ae95d5a0094cd1fedc79d3088ff244fa8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:17:16 -0400 Subject: [PATCH 15/67] fix(abcip): a reader racing a re-init neither leaks a runtime nor degrades Found by the H13 stress test (200 re-inits under a concurrent reader): a read that resolved the device just before the table swap could cold-create a runtime into the retired state after its teardown had disposed it (leaked native handle / lease), and could hit a per-tag SemaphoreSlim the teardown had disposed, which the generic catch reported as a comms fault (Degraded). - DeviceState.Retire marks the state retired before disposing handles; each handle is removed-then-disposed exactly once. A creator re-checks the flag after its TryAdd (fenced, Dekker-paired with Retire) and disposes the orphan. - A failure on a retired device is the teardown's doing: BadCommunicationError for that one call, no evict, no health signal. - Per-tag/RMW gates are no longer disposed (async-only waits hold no handle), matching the ConnectGate rationale. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipDriver.cs | 105 ++++++++++++++---- .../AbCipLifecycleConcurrencyTests.cs | 31 +++++- 2 files changed, 111 insertions(+), 25 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index c9c2f669..e4b6f9bb 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -561,7 +561,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, { // Never-published states hold no probe loop and no runtime yet; dispose for symmetry. if (builtDevices is not null) - foreach (var state in builtDevices.Values) state.DisposeHandles(); + foreach (var state in builtDevices.Values) state.Retire(); if (ex is OperationCanceledException) { _logger.LogInformation("AbCip driver {DriverInstanceId} initialization abandoned: {Reason}", @@ -631,7 +631,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, state.ProbeCts?.Dispose(); state.ProbeCts = null; state.ProbeTask = null; - state.DisposeHandles(); + state.Retire(); } return generation; } @@ -1155,6 +1155,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ate.Message); return ReadOutcome.Refused; } + catch (Exception) when (device.Retired) + { + // A re-init / shutdown retired this device under the read: its handles were disposed by the teardown, not + // lost on the wire — no evict (the teardown owns them), no health signal. + results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadCommunicationError, null, now); + return ReadOutcome.None; + } catch (Exception ex) { // Transport exception — evict so the next read creates a fresh handle. An AbTransportException @@ -1271,6 +1278,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, ate.Message); return ReadOutcome.Refused; } + catch (Exception) when (device.Retired) + { + StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError); + return ReadOutcome.None; + } catch (Exception ex) { EvictRuntime(device, parent.Name); @@ -1492,6 +1504,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, results[i] = new WriteResult(AbCipStatusMapper.MapStatus(ate.Status)); WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ate.Message); } + catch (Exception) when (device.Retired) + { + // The device was retired under this write by a re-init / shutdown — not a comms fault. + results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError); + } catch (Exception ex) { // Transport / wire error — evict so the next write creates a fresh handle. @@ -1554,6 +1571,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, DeviceState device, string parentTagName, CancellationToken ct) { if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing; + ThrowIfRetired(device); var runtime = _tagFactory.Create(device.BuildCreateParams(parentTagName, _options.Timeout)); try @@ -1567,10 +1585,25 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } // Two concurrent callers can both miss the cache + both initialize a runtime; only the // first TryAdd wins. Dispose the loser so it doesn't leak a native tag handle. - if (device.ParentRuntimes.TryAdd(parentTagName, runtime)) - return runtime; - runtime.Dispose(); - return device.ParentRuntimes[parentTagName]; + if (!device.ParentRuntimes.TryAdd(parentTagName, runtime)) + { + runtime.Dispose(); + return device.ParentRuntimes[parentTagName]; + } + Interlocked.MemoryBarrier(); // pairs with DeviceState.Retire + if (device.Retired) + { + if (device.ParentRuntimes.TryRemove(parentTagName, out var orphan)) orphan.Dispose(); + ThrowIfRetired(device); + } + return runtime; + } + + /// Throws when a teardown has retired (a reader raced a re-init / shutdown). + private static void ThrowIfRetired(DeviceState device) + { + if (device.Retired) + throw new ObjectDisposedException(nameof(DeviceState), $"AbCip device {device.Options.HostAddress} was retired by a re-initialization or shutdown."); } /// @@ -1607,6 +1640,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Re-check under the gate: a sibling cold tag may have connected (or a concurrent write // may have materialised this very handle) while this caller waited. if (device.Runtimes.TryGetValue(def.Name, out var raced)) return raced; + ThrowIfRetired(device); // Inside an open backoff window a data-path caller fails fast — no runtime // create, no Forward-Open. The malformed-path config error above still surfaces (a permanent @@ -1662,10 +1696,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // The gate makes a cold create single-flight, but a concurrent EvictRuntime (a failed read // on this handle) can still land between the re-check and the add; only the first TryAdd // wins. Dispose the loser so it doesn't leak a native tag handle. - if (device.Runtimes.TryAdd(def.Name, runtime)) - return runtime; - runtime.Dispose(); - return device.Runtimes[def.Name]; + if (!device.Runtimes.TryAdd(def.Name, runtime)) + { + runtime.Dispose(); + return device.Runtimes[def.Name]; + } + // A teardown that retired this state while we created would never see this handle: take it back out + // (TryRemove — exactly one of us disposes it) rather than leak it into a dead table. + Interlocked.MemoryBarrier(); // pairs with DeviceState.Retire + if (device.Retired) + { + if (device.Runtimes.TryRemove(def.Name, out var orphan)) orphan.Dispose(); + ThrowIfRetired(device); + } + return runtime; } finally { @@ -2230,19 +2274,38 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, AuthoredType: authoredType, UseConnectedMessaging: Profile.SupportsConnectedMessaging); - /// Disposes all runtime tag handles and clears the caches. + private volatile bool _retired; + + /// + /// True once a teardown retired this state. A reader that resolved the device just before the swap can + /// still be running against it: its failures are the teardown's doing, not the device's (no Degraded), and a + /// runtime it cold-creates afterwards is disposed by the creator instead of leaking into a dead table. + /// + public bool Retired => _retired; + + /// Marks the state retired, then disposes every runtime handle (see ). + public void Retire() + { + _retired = true; + // Dekker pairing with the creator's post-add check: the flag is globally visible before the tables are + // enumerated, and the creator fences between its add and its read of the flag — so at least one of the two + // sees the other's write and the handle is disposed. + Interlocked.MemoryBarrier(); + DisposeHandles(); + } + + /// + /// Disposes every runtime tag handle, each exactly once (removed before it is disposed, so a racing + /// creator's own cleanup cannot dispose it a second time). The per-runtime / RMW gates are deliberately NOT + /// disposed, for the same reason as : an in-flight reader of a retired state may + /// still release one, and a only awaited asynchronously holds no wait handle. + /// public void DisposeHandles() { - foreach (var r in Runtimes.Values) r.Dispose(); - Runtimes.Clear(); - foreach (var r in ParentRuntimes.Values) r.Dispose(); - ParentRuntimes.Clear(); - // Dispose + clear the per-runtime/RMW gates so ReinitializeAsync cycles don't orphan - // their SemaphoreSlim instances (each leaks a wait handle once contended). - foreach (var sem in _runtimeLocks.Values) sem.Dispose(); - _runtimeLocks.Clear(); - foreach (var sem in _rmwLocks.Values) sem.Dispose(); - _rmwLocks.Clear(); + foreach (var key in Runtimes.Keys) + if (Runtimes.TryRemove(key, out var r)) r.Dispose(); + foreach (var key in ParentRuntimes.Keys) + if (ParentRuntimes.TryRemove(key, out var r)) r.Dispose(); } } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs index 00cb921a..eea409d2 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs @@ -123,14 +123,32 @@ public sealed class AbCipLifecycleConcurrencyTests /// /// Readers of the device/routing tables (the retained poll engine, the actor's host-status pull) never - /// see a table mid-mutation while the driver re-initialises underneath them. + /// see a table mid-mutation while the driver re-initialises underneath them, never trip over a per-tag lock a + /// retire disposed, and never leak a runtime they cold-created into a device state a retire already tore down. /// [Fact] - public async Task Readers_survive_repeated_reinitialisation() + public async Task Readers_survive_repeated_reinitialisation_and_leak_no_runtime() { - var (drv, _) = Build(probeReadDelay: TimeSpan.Zero); + var created = new System.Collections.Concurrent.ConcurrentBag(); + var factory = new FakeAbCipTagFactory + { + Customise = p => + { + var t = new FakeAbCipTag(p) { Value = 1 }; + created.Add(t); + return t; + }, + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Host)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Host, "Motor1.Speed", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions { Enabled = false }, + }, "abcip-lifecycle", factory); await drv.InitializeAsync("{}", CancellationToken.None); using var stop = new CancellationTokenSource(); + var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var badHealth = 0; var reader = Task.Run(async () => { var reads = 0; @@ -139,17 +157,22 @@ public sealed class AbCipLifecycleConcurrencyTests _ = drv.GetHostStatuses(); _ = drv.ResolveHost("Speed"); await drv.ReadAsync(["Speed"], CancellationToken.None); + if (drv.GetHealth().State == DriverState.Degraded) Interlocked.Increment(ref badHealth); reads++; + started.TrySetResult(); } return reads; }); + await started.Task.WaitAsync(TimeSpan.FromSeconds(5)); - for (var i = 0; i < 50; i++) + for (var i = 0; i < 200; i++) await drv.InitializeAsync("{}", CancellationToken.None); stop.Cancel(); (await reader).ShouldBeGreaterThan(0); // positive control: the reader actually ran + Volatile.Read(ref badHealth).ShouldBe(0, "a re-init is not a comms fault"); await drv.DisposeAsync(); + created.Where(t => !t.Disposed).ShouldBeEmpty("every runtime a reader created must be disposed by some teardown"); } /// F10: the driver exposes only an async dispose; a sync Dispose() that blocked on the async From 7377daa9b0d93b1b3128c446dccb5b6b6fff02a0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:17:16 -0400 Subject: [PATCH 16/67] fix(ablegacy): prune per-key gates when an indirect index moves Review 07 L7: each index value an indirect tag (N7:[N7:0]) swept through left a per-runtime and a creation SemaphoreSlim in the device's gate tables until shutdown. A moved index now drops the stale resolution's gates along with its handle, and sweeps any other stale resolution of the same tag a concurrent reader cached, so the tables stay bounded by the authored set. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriver.cs | 30 +++++++++++++++++-- .../AbLegacyIndirectAddressingTests.cs | 28 +++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 66db900d..b5bc01e2 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -965,8 +965,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover var concreteText = concrete.ToLibplctagName(); var resolvedName = $"{def.Name}=>{concreteText}"; - // The index moved: drop the previous resolution's handle so a sweeping index does not accumulate one - // libplctag handle per value it ever took. + // The index moved: drop the previous resolution's handle AND its per-key gates (review 07 L7), so a sweeping + // index does not accumulate one libplctag handle plus two SemaphoreSlims per value it ever took. var previous = device.IndirectLastName.TryGetValue(def.Name, out var prior) ? prior : null; device.IndirectLastName[def.Name] = resolvedName; if (previous is not null && !string.Equals(previous, resolvedName, StringComparison.OrdinalIgnoreCase)) @@ -974,6 +974,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover var oldLock = device.GetRuntimeLock(previous); await oldLock.WaitAsync(ct).ConfigureAwait(false); try { EvictRuntime(device, previous); } finally { oldLock.Release(); } + device.ForgetGates(previous); + // A concurrent reader of the same indirect tag can have cached a resolution between our TryGetValue and + // the write above (a stale one nothing would ever evict). Sweep every OTHER resolution of this tag. + var prefix = def.Name + "=>"; + foreach (var key in device.Runtimes.Keys) + { + if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + || string.Equals(key, resolvedName, StringComparison.OrdinalIgnoreCase)) continue; + EvictRuntime(device, key); + device.ForgetGates(key); + } } return new IndirectResolution(def with { Name = resolvedName, Address = concreteText }, 0, false); } @@ -1379,6 +1390,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// Tags already warned about for a malformed address (once-per-tag log). public System.Collections.Concurrent.ConcurrentDictionary MalformedAddressLogged { get; } = new(); + /// + /// Drops the per-runtime and creation gates of a runtime key that is no longer in use (a moved indirect + /// resolution). The gates are removed, not disposed: a caller that already holds a reference finishes on + /// it, and the next user of the key simply gets a fresh gate. + /// + /// The runtime key whose gates to drop. + public void ForgetGates(string runtimeKey) + { + _runtimeLocks.TryRemove(runtimeKey, out _); + _creationLocks.TryRemove($"tag:{runtimeKey}", out _); + } + + /// The number of per-key gates currently held (runtime + creation + RMW). Tests only. + internal int GateCount => _runtimeLocks.Count + _creationLocks.Count + _rmwLocks.Count; + private volatile bool _retired; /// True once this state has been unpublished and released; nothing may be cached on it any more. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyIndirectAddressingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyIndirectAddressingTests.cs index 6baf2165..bd034822 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyIndirectAddressingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyIndirectAddressingTests.cs @@ -128,4 +128,32 @@ public sealed class AbLegacyIndirectAddressingTests w.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange); f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0); } + + /// + /// Review 07 L7: every index value an indirect tag sweeps through used to leave one per-runtime semaphore and + /// one creation semaphore behind until shutdown. They are pruned with the stale resolution's handle, so the + /// per-key gate tables stay bounded by the authored set however far the index sweeps. + /// + [Fact] + public async Task Sweeping_index_does_not_accumulate_per_key_gates() + { + var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int)); + Seed(f, ("N7:0", (short)10)); + (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var state = drv.GetDeviceState(Host)!; + var baseline = state.GateCount; + + for (short i = 11; i < 60; i++) + { + f.Tags["N7:0"].Value = i; + f.Customise = p => new FakeAbLegacyTag(p) { Value = (short)(p.TagName == "N7:0" ? i : 1000 + i) }; + var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single(); + r.StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: every resolution really read + r.Value.ShouldBe((short)(1000 + i)); + } + + state.GateCount.ShouldBe(baseline); + // Only the live resolution's handle (plus the index word's parent runtime) remains cached. + state.Runtimes.Keys.Count(k => k.StartsWith("Ind=>", StringComparison.Ordinal)).ShouldBe(1); + } } From 4e9bd9d9665f10866063c1df24cc1f14132dddad Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:19:38 -0400 Subject: [PATCH 17/67] perf(abcip): native read window >= 64; cold waiters hold no window slot P8: on the native transport the bounded-parallel read window is also the cap on services per Multiple Service Packet (the device's shared scheduler packs whatever is queued, within the connection's request/reply byte budget), so the default 8 cost a 1000-tag controller ~125 round trips per poll instead of ~10. The effective window (AbCipDriver.ReadWindow) is now max(64, option) when the native tag factory serves reads; libplctag and injected factories keep the option exactly. Option doc + AdminUI help text updated (Razor: Wave 4 live check). P7 addendum: a work item now takes its window slot only after its runtime exists. A cold create queues on its device's single-flight connect gate, and holding a slot there let one dead device's backoff-expiry attempt occupy the whole window for a full Timeout, stalling every other device's reads. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipDriverOptions.cs | 6 + .../AbCipDriver.cs | 271 ++++++++++-------- .../Drivers/Forms/AbCipDriverForm.razor | 2 +- .../AbCipReadWindowTests.cs | 96 +++++++ 4 files changed, 254 insertions(+), 121 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReadWindowTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs index 9fc101c3..9656a70e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Contracts/AbCipDriverOptions.cs @@ -117,6 +117,12 @@ public sealed class AbCipDriverOptions /// device round trips (the whole point: a strictly sequential batch pays one full round /// trip per tag); lowering it to 1 restores strictly sequential reads. Values below /// 1 are clamped to 1 at the use site. + /// + /// On the transport the effective window is at least 64: there the + /// window caps how many services one Multiple Service Packet coalesces (the device's shared scheduler packs + /// what is queued, bounded by the connection's request/reply byte budget), so a small value only multiplies + /// round trips. A value above 64 is honoured; a value below it is raised. + /// /// public int MaxConcurrentReads { get; init; } = 8; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index e4b6f9bb..b48a619c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -895,7 +895,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // round trips overlap, which a strictly sequential await chain made impossible. Results are // index-addressed, so completion order is irrelevant. Per-item health verdicts are COLLECTED // and written once after the batch: an inline write to the shared _health field would race. - var maxConcurrent = Math.Max(1, _options.MaxConcurrentReads); + var maxConcurrent = ReadWindow; var outcomes = new ReadOutcome[itemCount]; using var window = new SemaphoreSlim(maxConcurrent, maxConcurrent); var tasks = new Task[itemCount]; @@ -910,32 +910,44 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, ApplyBatchHealth(outcomes, now); return results; + // The window slot is taken INSIDE each item, after its runtime exists (P7): a cold create queues on its + // device's single-flight connect gate, and holding a slot while queued there let one dead device's + // backoff-expiry attempt occupy the whole window for a full Timeout, stalling every other device's reads. async Task RunItemAsync(int slot) { - // Acquire OUTSIDE the try: a wait that throws never acquired, so it must not release. - await window.WaitAsync(cancellationToken).ConfigureAwait(false); - try + if (slot < groups.Count) { - if (slot < groups.Count) - { - outcomes[slot] = await ReadGroupAsync(groups[slot], results, now, cancellationToken) - .ConfigureAwait(false); - } - else - { - var fb = fallbacks[slot - groups.Count]; - outcomes[slot] = await ReadSingleAsync( - fb, fullReferences[fb.OriginalIndex], results, now, cancellationToken) - .ConfigureAwait(false); - } + outcomes[slot] = await ReadGroupAsync(groups[slot], results, now, window, cancellationToken) + .ConfigureAwait(false); } - finally + else { - window.Release(); + var fb = fallbacks[slot - groups.Count]; + outcomes[slot] = await ReadSingleAsync( + fb, fullReferences[fb.OriginalIndex], results, now, window, cancellationToken) + .ConfigureAwait(false); } } } + /// + /// Floor on the read window when the native transport serves this driver (P8). Its runtimes share one + /// request scheduler per device that packs whatever is queued when the pump wakes into one Multiple Service + /// Packet — bounded by the connection's request AND reply byte budget, so a wide window cannot over-pack. The + /// window is therefore the cap on services per round trip: at 8, a 1000-tag controller paid ~125 round trips + /// per poll instead of ~10. + /// + internal const int NativeMinReadWindow = 64; + + /// + /// The effective bounded-parallel read window: (clamped + /// to ≥ 1), raised to at least when the native tag factory serves the reads. + /// libplctag overlaps per-handle round trips only, so it (and any injected factory) keeps the option as is. + /// + internal int ReadWindow => _tagFactory is NativeAbCipTagFactory + ? Math.Max(NativeMinReadWindow, _options.MaxConcurrentReads) + : Math.Max(1, _options.MaxConcurrentReads); + /// /// One read work item's health verdict. Returned by / /// instead of written to the shared health field inline, because @@ -1015,7 +1027,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } private async Task ReadSingleAsync( - AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct) + AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, + SemaphoreSlim window, CancellationToken ct) { if (!_resolver.TryResolve(reference, out var def)) { @@ -1041,82 +1054,91 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, { var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false); + // Window slot only now that the runtime exists (see ReadAsync); a wait that throws never acquired. + await window.WaitAsync(ct).ConfigureAwait(false); // Serialise Read → GetStatus → Decode against the shared runtime — the cached libplctag // Tag handle is not concurrency-safe and GetStatus returns the LAST op's status. var opLock = device.GetRuntimeLock(def.Name); - await opLock.WaitAsync(ct).ConfigureAwait(false); try { - await runtime.ReadAsync(ct).ConfigureAwait(false); + await opLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await runtime.ReadAsync(ct).ConfigureAwait(false); - var status = runtime.GetStatus(); - if (status == (int)AbStatus.TypeMismatch) - { - // Final review I4: the controller ANSWERED with a type that disagrees with the authored - // one — a deterministic config error, not a comms fault. Keep the handle (evicting would - // re-create it — and, for a sole lease holder, re-Forward-Open — on every poll), leave - // health alone, warn once per tag. - results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now); - WarnTypeMismatchOnce(def.Name, - "AbCip read of {Tag} on device {Device}: the controller's type does not match the authored " + - "{DataType}; reporting BadTypeMismatch until the tag's dataType is corrected (logged once per tag)", - reference, def.DeviceHostAddress, def.DataType); - return ReadOutcome.Refused; - } - if (AbCipRefusals.IsDeviceRefusal(status)) - { - // The controller answered with a deterministic refusal: per-tag Bad, keep the handle. - results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.MapReadStatus((AbStatus)status), null, now); - WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ((AbStatus)status).ToString()); - return ReadOutcome.Refused; - } - if (status != 0) - { - // Evict the stale handle so the next call re-creates it. - // A non-zero status can mean the controller dropped the connection or the tag - // handle became permanently invalid (e.g. after a PLC download). Evicting - // mirrors the probe loop's recreate-on-failure behaviour. - EvictRuntime(device, def.Name); - results[fb.OriginalIndex] = new DataValueSnapshot(null, - AbCipStatusMapper.MapStatus(status), null, now); - _logger.LogWarning( - "AbCip read returned non-zero transport status {Status} for tag {Tag} on device {Device}; " + - "evicting cached runtime so next call re-creates it", - status, reference, def.DeviceHostAddress); - return ReadOutcome.Degraded($"transport status {status} reading {reference}"); - } + var status = runtime.GetStatus(); + if (status == (int)AbStatus.TypeMismatch) + { + // Final review I4: the controller ANSWERED with a type that disagrees with the authored + // one — a deterministic config error, not a comms fault. Keep the handle (evicting would + // re-create it — and, for a sole lease holder, re-Forward-Open — on every poll), leave + // health alone, warn once per tag. + results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now); + WarnTypeMismatchOnce(def.Name, + "AbCip read of {Tag} on device {Device}: the controller's type does not match the authored " + + "{DataType}; reporting BadTypeMismatch until the tag's dataType is corrected (logged once per tag)", + reference, def.DeviceHostAddress, def.DataType); + return ReadOutcome.Refused; + } + if (AbCipRefusals.IsDeviceRefusal(status)) + { + // The controller answered with a deterministic refusal: per-tag Bad, keep the handle. + results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.MapReadStatus((AbStatus)status), null, now); + WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ((AbStatus)status).ToString()); + return ReadOutcome.Refused; + } + if (status != 0) + { + // Evict the stale handle so the next call re-creates it. + // A non-zero status can mean the controller dropped the connection or the tag + // handle became permanently invalid (e.g. after a PLC download). Evicting + // mirrors the probe loop's recreate-on-failure behaviour. + EvictRuntime(device, def.Name); + results[fb.OriginalIndex] = new DataValueSnapshot(null, + AbCipStatusMapper.MapStatus(status), null, now); + _logger.LogWarning( + "AbCip read returned non-zero transport status {Status} for tag {Tag} on device {Device}; " + + "evicting cached runtime so next call re-creates it", + status, reference, def.DeviceHostAddress); + return ReadOutcome.Degraded($"transport status {status} reading {reference}"); + } - var tagPath = AbCipTagPath.TryParse(def.TagPath); - var bitIndex = tagPath?.BitIndex; + var tagPath = AbCipTagPath.TryParse(def.TagPath); + var bitIndex = tagPath?.BitIndex; - // libplctag never checks the CIP type the controller returned; it decodes the bytes as - // the authored type. A scalar whose buffer is not that type's width is mis-typed config - // (seen live: a REAL authored as INT read Good -17346), so refuse it rather than publish - // garbage as Good. Same-width mismatches (DINT vs REAL) cannot be seen this way. - if (!IsArrayTag(def) && bitIndex is null - && ExpectedScalarWidth(def.DataType) is { } width - && runtime.GetSize() is var size && size >= 0 && size != width) - { - // Same class as the native TypeMismatch above (final review I4): deterministic, device - // answered — no Degraded, warn once per tag. - results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now); - WarnTypeMismatchOnce(def.Name, - "AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType (logged once per tag)", - reference, def.DeviceHostAddress, size, def.DataType, width); - return ReadOutcome.Refused; + // libplctag never checks the CIP type the controller returned; it decodes the bytes as + // the authored type. A scalar whose buffer is not that type's width is mis-typed config + // (seen live: a REAL authored as INT read Good -17346), so refuse it rather than publish + // garbage as Good. Same-width mismatches (DINT vs REAL) cannot be seen this way. + if (!IsArrayTag(def) && bitIndex is null + && ExpectedScalarWidth(def.DataType) is { } width + && runtime.GetSize() is var size && size >= 0 && size != width) + { + // Same class as the native TypeMismatch above (final review I4): deterministic, device + // answered — no Degraded, warn once per tag. + results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now); + WarnTypeMismatchOnce(def.Name, + "AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType (logged once per tag)", + reference, def.DeviceHostAddress, size, def.DataType, width); + return ReadOutcome.Refused; + } + // Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an + // element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array + // (ElementCount 1). Scalar tags keep the single-value path. + var value = IsArrayTag(def) + ? runtime.DecodeArray(def.DataType, Math.Max(1, def.ElementCount)) + : runtime.DecodeValue(def.DataType, bitIndex); + results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now); + return ReadOutcome.Success; + } + finally + { + opLock.Release(); } - // Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an - // element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array - // (ElementCount 1). Scalar tags keep the single-value path. - var value = IsArrayTag(def) - ? runtime.DecodeArray(def.DataType, Math.Max(1, def.ElementCount)) - : runtime.DecodeValue(def.DataType, bitIndex); - results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now); - return ReadOutcome.Success; } finally { - opLock.Release(); + window.Release(); } } catch (OperationCanceledException) @@ -1185,7 +1207,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// per-tag fallback list are unaffected. /// private async Task ReadGroupAsync( - AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, CancellationToken ct) + AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, SemaphoreSlim window, CancellationToken ct) { var parent = group.ParentDefinition; @@ -1199,53 +1221,62 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, { var runtime = await EnsureTagRuntimeAsync(device, parent, ct).ConfigureAwait(false); + // Window slot only now that the runtime exists (see ReadAsync); a wait that throws never acquired. + await window.WaitAsync(ct).ConfigureAwait(false); // Serialise the whole-UDT Read → GetStatus → per-member DecodeValueAt against the shared // parent runtime — the members decode from that one handle's buffer. var opLock = device.GetRuntimeLock(parent.Name); - await opLock.WaitAsync(ct).ConfigureAwait(false); try { - await runtime.ReadAsync(ct).ConfigureAwait(false); + await opLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await runtime.ReadAsync(ct).ConfigureAwait(false); - var status = runtime.GetStatus(); - if (status == (int)AbStatus.TypeMismatch) - { - // Final review I4: deterministic — keep the handle, no health signal, warn once. - StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch); - WarnTypeMismatchOnce(parent.Name, - "AbCip whole-UDT read of {Parent} on device {Device}: the controller's type does not match " + - "the authored type; {MemberCount} members report BadTypeMismatch (logged once per tag)", - group.ParentName, parent.DeviceHostAddress, group.Members.Count); - return ReadOutcome.Refused; - } - if (AbCipRefusals.IsDeviceRefusal(status)) - { - StampGroupStatus(group, results, now, AbCipStatusMapper.MapReadStatus((AbStatus)status)); - WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, ((AbStatus)status).ToString()); - return ReadOutcome.Refused; - } - if (status != 0) - { - EvictRuntime(device, parent.Name); - var mapped = AbCipStatusMapper.MapStatus(status); - StampGroupStatus(group, results, now, mapped); - _logger.LogWarning( - "AbCip whole-UDT read returned non-zero transport status {Status} for parent {Parent} " + - "on device {Device}; {MemberCount} member values stamped with mapped status", - status, group.ParentName, parent.DeviceHostAddress, group.Members.Count); - return ReadOutcome.Degraded($"transport status {status} reading UDT {group.ParentName}"); - } + var status = runtime.GetStatus(); + if (status == (int)AbStatus.TypeMismatch) + { + // Final review I4: deterministic — keep the handle, no health signal, warn once. + StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch); + WarnTypeMismatchOnce(parent.Name, + "AbCip whole-UDT read of {Parent} on device {Device}: the controller's type does not match " + + "the authored type; {MemberCount} members report BadTypeMismatch (logged once per tag)", + group.ParentName, parent.DeviceHostAddress, group.Members.Count); + return ReadOutcome.Refused; + } + if (AbCipRefusals.IsDeviceRefusal(status)) + { + StampGroupStatus(group, results, now, AbCipStatusMapper.MapReadStatus((AbStatus)status)); + WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, ((AbStatus)status).ToString()); + return ReadOutcome.Refused; + } + if (status != 0) + { + EvictRuntime(device, parent.Name); + var mapped = AbCipStatusMapper.MapStatus(status); + StampGroupStatus(group, results, now, mapped); + _logger.LogWarning( + "AbCip whole-UDT read returned non-zero transport status {Status} for parent {Parent} " + + "on device {Device}; {MemberCount} member values stamped with mapped status", + status, group.ParentName, parent.DeviceHostAddress, group.Members.Count); + return ReadOutcome.Degraded($"transport status {status} reading UDT {group.ParentName}"); + } - foreach (var member in group.Members) - { - var value = runtime.DecodeValueAt(member.Definition.DataType, member.Offset, bitIndex: null); - results[member.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now); + foreach (var member in group.Members) + { + var value = runtime.DecodeValueAt(member.Definition.DataType, member.Offset, bitIndex: null); + results[member.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now); + } + return ReadOutcome.Success; + } + finally + { + opLock.Release(); } - return ReadOutcome.Success; } finally { - opLock.Release(); + window.Release(); } } catch (OperationCanceledException) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor index 49ecfd06..496a2bed 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbCipDriverForm.razor @@ -21,7 +21,7 @@
-
Read work items in flight at once. Default 8; 1 = sequential.
+
Read work items in flight at once. Default 8; 1 = sequential (libplctag). The native transport always uses at least 64 — it packs concurrent reads into one request.
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReadWindowTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReadWindowTests.cs new file mode 100644 index 00000000..45cd3489 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipReadWindowTests.cs @@ -0,0 +1,96 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Review 07 P8 / P7: the bounded-parallel read window. On the native transport every concurrent tag read of a +/// device rides ONE shared request scheduler that packs whatever is queued into a Multiple Service Packet, so a +/// window of 8 capped MSP coalescing at 8 services per round trip; it is widened to at least 64 there (the +/// scheduler's reply budget already prevents over-packing). And a cold create waiting on a device's connect gate +/// must not occupy a window slot, or one dead device's backoff-expiry attempt stalls every other device's reads. +/// +[Trait("Category", "Unit")] +public sealed class AbCipReadWindowTests +{ + private static AbCipDriverOptions Options(int maxConcurrentReads, AbCipTransport? transport = null) => new() + { + Devices = [new AbCipDeviceOptions("ab://10.0.0.5/1,0")], + Probe = new AbCipProbeOptions { Enabled = false }, + MaxConcurrentReads = maxConcurrentReads, + Transport = transport, + }; + + /// The window per transport: native ≥ 64; libplctag and injected factories honour the option exactly. + [Fact] + public void The_native_window_is_at_least_64_and_other_transports_keep_the_option() + { + new AbCipDriver(Options(8, AbCipTransport.Native), "a").ReadWindow.ShouldBe(AbCipDriver.NativeMinReadWindow); + AbCipDriver.NativeMinReadWindow.ShouldBeGreaterThanOrEqualTo(64); + new AbCipDriver(Options(1, AbCipTransport.Native), "a").ReadWindow.ShouldBe(AbCipDriver.NativeMinReadWindow); + new AbCipDriver(Options(200, AbCipTransport.Native), "a").ReadWindow.ShouldBe(200); + new AbCipDriver(Options(8), "a", defaultTransport: AbCipTransport.Native).ReadWindow.ShouldBe(AbCipDriver.NativeMinReadWindow); + + // Positive controls: libplctag (per-handle concurrency) and an injected test factory keep the option. + new AbCipDriver(Options(8, AbCipTransport.Libplctag), "a").ReadWindow.ShouldBe(8); + new AbCipDriver(Options(0, AbCipTransport.Libplctag), "a").ReadWindow.ShouldBe(1); + new AbCipDriver(Options(3, AbCipTransport.Native), "a", new FakeAbCipTagFactory()).ReadWindow.ShouldBe(3); + } + + /// A tag whose initialisation blocks until released (a cold connect to a slow / dead device). + private sealed class BlockingInitTag(AbCipTagCreateParams p, Task release) : FakeAbCipTag(p) + { + public override async Task InitializeAsync(CancellationToken cancellationToken) + { + await base.InitializeAsync(cancellationToken); + await release.WaitAsync(cancellationToken); + } + } + + /// + /// With a window of ONE, device B's blocked cold create (and its queued sibling) must not stop device A's tag + /// from being read: cold waiters sit on B's connect gate, not in the window. + /// + [Fact] + public async Task A_blocked_cold_create_on_one_device_does_not_hold_the_window_against_another() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var factory = new FakeAbCipTagFactory + { + Customise = p => p.Gateway == "10.0.0.6" + ? new BlockingInitTag(p, release.Task) { Value = 2 } + : new FakeAbCipTag(p) { Value = 1 }, + }; + const string a = "ab://10.0.0.5/1,0", b = "ab://10.0.0.6/1,0"; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(a), new AbCipDeviceOptions(b)], + RawTags = AbCipRawTags.From( + new AbCipTagDefinition("B1", b, "B1", AbCipDataType.DInt), + new AbCipTagDefinition("B2", b, "B2", AbCipDataType.DInt), + new AbCipTagDefinition("A1", a, "A1", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions { Enabled = false }, + MaxConcurrentReads = 1, + }, "abcip-window", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var read = drv.ReadAsync(["B1", "B2", "A1"], CancellationToken.None); + var deadline = DateTime.UtcNow.AddSeconds(5); + while (!(factory.Tags.TryGetValue("A1", out var tag) && tag.ReadCount > 0)) + { + if (DateTime.UtcNow > deadline) + { + release.TrySetResult(); + await read; + throw new ShouldAssertException("device A's read waited behind device B's blocked cold create"); + } + await Task.Delay(5); + } + read.IsCompleted.ShouldBeFalse(); // B is still blocked — A was read regardless + + release.SetResult(); + var r = await read; + r.Select(x => x.Value).ShouldBe([2, 2, 1]); // positive control: B's reads complete once it connects + await drv.DisposeAsync(); + } +} From 8b50b43048f2b6efebd42fe397642cb57be5ab4f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:20:17 -0400 Subject: [PATCH 18/67] fix(ablegacy): deterministic NotSupported refusals are BadNotSupported with no health write Review 07 H16: an array of String/Timer/Counter/Control passed the mapper, so every poll created a handle (one init read), the decode threw NotSupportedException, the generic catch evicted the handle and latched Degraded. FromTagConfig now rejects that combination (the skip carries the reason, Inspect/deploy gate and the AdminUI model state it), and a runtime NotSupportedException on read or write is BadNotSupported: handle kept, no health write, one Warning per tag and operation. Write refusals (native PLC-5 and status-bit writes, libplctag translated addresses) now warn once per tag too. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyTagDefinitionFactory.cs | 26 ++++ .../AbLegacyDriver.cs | 39 ++++- .../Uns/TagEditors/AbLegacyTagConfigModel.cs | 5 + .../AbLegacyDeterministicRefusalTests.cs | 136 ++++++++++++++++++ .../Uns/AbLegacyTagConfigModelTests.cs | 14 ++ 5 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeterministicRefusalTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs index 8c0742f5..c6ab0a97 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs @@ -77,6 +77,10 @@ public static class AbLegacyTagDefinitionFactory if (rawLength >= 1) arrayLength = Math.Min(rawLength, AbLegacyArray.MaxElements); } + // A deterministic refusal (review 07 H16): String / Timer / Counter / Control elements are structured + // or variable-width and do not lay out as a flat array, so both runtimes refuse to decode them. Accepting + // the tag only bought a handle create + a NotSupportedException on every poll. + if (arrayLength is not null && !IsArrayElementTypeSupported(dataType)) return false; // "writable" defaults to true when absent (today's value) — makes read-only equipment tags // authorable. var writable = TagConfigJson.ReadWritable(root); @@ -224,8 +228,30 @@ public static class AbLegacyTagDefinitionFactory } if (TagConfigJson.DescribeInvalidEnum(root, "dataType") is { } w) warnings.Add(w); + + if (IsArrayFlag(root) && ReadInt(root, "arrayLength") >= 1 + && TagConfigJson.TryReadEnumStrict(root, "dataType", AbLegacyDataType.Int, out var dt) + && !IsArrayElementTypeSupported(dt)) + warnings.Add(ArrayElementTypeError(dt)); } + /// + /// Whether can be the element type of an array tag. Only the flat word/long/float/bit + /// files lay out as a contiguous array; String, Timer, Counter and Control elements are structured. + /// + /// The authored element data type. + /// for Bit, Int, AnalogInt, Long and Float. + public static bool IsArrayElementTypeSupported(AbLegacyDataType type) => type is + AbLegacyDataType.Bit or AbLegacyDataType.Int or AbLegacyDataType.AnalogInt + or AbLegacyDataType.Long or AbLegacyDataType.Float; + + /// The authoring / deploy-gate message for an array of an unsupported element type. + /// The authored element data type. + /// The message. + public static string ArrayElementTypeError(AbLegacyDataType type) => + $"AbLegacy {type} is not supported as an array element (String, Timer, Counter and Control elements are " + + "structured); author a scalar tag per element instead. The driver skips this tag and it reads BadNodeIdUnknown."; + private static int ReadInt(JsonElement o, string name) => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) ? v : 0; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index b5bc01e2..9adfd47c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -221,10 +221,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { if (!AbLegacyTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def)) { - _skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition"); + // The deploy gate's own explanation (same authority) — e.g. an array of a structured type. + var reason = AbLegacyTagDefinitionFactory.Inspect(entry.TagConfig).FirstOrDefault() + ?? "TagConfig did not map to a definition"; + _skippedTags.Skip(entry.RawPath, reason); _logger.LogWarning( - "AbLegacy tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}", - _driverInstanceId, entry.RawPath); + "AbLegacy tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath} Reason={Reason}", + _driverInstanceId, entry.RawPath, reason); continue; } tags[entry.RawPath] = def with @@ -523,6 +526,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { // The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read). // Not a comms fault: BadNotSupported, keep the handle, leave health alone. + WarnRefusalOnce(device, def, "read", ex.Message); + results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); + } + catch (NotSupportedException nse) + { + // A deterministic decode refusal (review 07 H16) — the same answer every poll. BadNotSupported, keep + // the handle (re-creating it only re-paid the init read), warn once, and NO health write. + WarnRefusalOnce(device, def, "read", nse.Message); results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); } catch (Exception ex) @@ -601,8 +612,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover // READ, and against a device lacking that file the read failure surfaced as a comms fault — // BadCommunicationError, evicted handle, Degraded health — instead of the refusal. // Ahead of the bit-RMW branch too, which would otherwise read the parent word first. - if (parsed is not null && _tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is not null) + if (parsed is not null && _tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is { } refusal) { + WarnRefusalOnce(device, def, "write", refusal); results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); continue; } @@ -654,8 +666,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover catch (OperationCanceledException) { throw; } catch (NotSupportedException nse) { + // Deterministic (review 07 H16): nothing reached the wire, so it says nothing about the device. + WarnRefusalOnce(device, def, "write", nse.Message); results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); - SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message)); } catch (Exception ex) when (ex is FormatException or InvalidCastException) { @@ -669,6 +682,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { // The runtime refused the write before the wire — a native status-bit sub-element write or a // PLC-5 write. Not a comms fault: BadNotSupported, keep the handle, leave health alone. + WarnRefusalOnce(device, def, "write", ex.Message); results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); } catch (Exception ex) @@ -1157,6 +1171,18 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover _driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, device.Options.PlcFamily); } + /// + /// A deterministic refusal (BadNotSupported) is answered the same way every time, so it is logged ONCE per + /// tag and operation — a polled tag must not flood the log, and the refusal never touches driver health. + /// + private void WarnRefusalOnce(DeviceState device, AbLegacyTagDefinition def, string operation, string reason) + { + if (!device.RefusalLogged.TryAdd($"{operation}:{def.Name}", 0)) return; + _logger.LogWarning( + "AbLegacy {Operation} refused (BadNotSupported). Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Address={Address} Reason={Reason}", + operation, _driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, reason); + } + /// The create parameters for a tag's own runtime — shared by creation and the write-refusal check, so /// the refusal is asked about exactly the runtime a write would otherwise create. private AbLegacyTagCreateParams CreateParamsFor(DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed) => @@ -1387,6 +1413,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover ///
public bool FirstNonZeroStatusLogged { get; set; } + /// Tag operations already warned about for a deterministic refusal (once-per-tag-and-operation log). + public System.Collections.Concurrent.ConcurrentDictionary RefusalLogged { get; } = new(StringComparer.Ordinal); + /// Tags already warned about for a malformed address (once-per-tag log). public System.Collections.Concurrent.ConcurrentDictionary MalformedAddressLogged { get; } = new(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs index ca742cff..dcb67114 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs @@ -87,6 +87,11 @@ public sealed class AbLegacyTagConfigModel if (isArray && arrayLength > AbLegacyArray.MaxElements) return $"AbLegacy array length cannot exceed {AbLegacyArray.MaxElements} (PCCC data-file element limit)."; + // Same rule the reader enforces (review 07 H16): structured elements never form an array. Stated here so + // the CSV-import / API paths get the specific reason instead of the parse gate's generic one. + if (isArray && arrayLength >= 1 && !AbLegacyTagDefinitionFactory.IsArrayElementTypeSupported(DataType)) + return AbLegacyTagDefinitionFactory.ArrayElementTypeError(DataType); + // Parse-gate backstop: the checks above mirror the reader's whole rejection surface today, so this is // expected to be silent for AbLegacy. It is here so a future reader rule cannot become a gap. return TagConfigParseGate.Describe( diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeterministicRefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeterministicRefusalTests.cs new file mode 100644 index 00000000..6542df4c --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeterministicRefusalTests.cs @@ -0,0 +1,136 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// Review 07 H16: an array of String/Timer/Counter/Control was accepted by the mapper, so every poll created a +/// handle (one init read), threw from the decode, disposed the handle and +/// latched Degraded. A deterministic refusal is now rejected at the mapper; a runtime +/// that still occurs is per-tag BadNotSupported, keeps its handle, +/// warns once and leaves driver health alone. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyDeterministicRefusalTests +{ + private const string Host = "ab://10.0.0.5/1,0"; + + [Theory] + [InlineData("String", "ST9:0")] + [InlineData("TimerElement", "T4:0")] + [InlineData("CounterElement", "C5:0")] + [InlineData("ControlElement", "R6:0")] + public void Mapper_rejects_arrays_of_structured_element_types(string dataType, string address) + { + var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}"""; + + AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _).ShouldBeFalse(); + AbLegacyTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("not supported as an array")); + // Positive control: the same type as a SCALAR is still accepted. + AbLegacyTagDefinitionFactory.FromTagConfig( + $$"""{"address":"{{address}}","dataType":"{{dataType}}"}""", "p", out _).ShouldBeTrue(); + } + + [Theory] + [InlineData("Int", "N7:0")] + [InlineData("Float", "F8:0")] + [InlineData("Long", "L9:0")] + [InlineData("Bit", "B3:0")] + public void Mapper_keeps_arrays_of_flat_element_types(string dataType, string address) + { + var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}"""; + AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out var def).ShouldBeTrue(); + def.ArrayLength.ShouldBe(4); + AbLegacyTagDefinitionFactory.Inspect(json).ShouldBeEmpty(); + } + + [Fact] + public async Task Rejected_array_tag_is_skipped_with_its_reason_and_creates_no_runtime() + { + var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host)], + RawTags = + [ + new RawTagEntry("Strs", """{"address":"ST9:0","dataType":"String","isArray":true,"arrayLength":3}""", false, Host), + new RawTagEntry("Good", """{"address":"N7:0","dataType":"Int"}""", false, Host), + ], + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-h16", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync(["Strs", "Good"], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); + r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control + factory.Tags.Keys.ShouldNotContain("ST9:0"); + var skipped = drv.GetSkippedTags().ShouldNotBeNull(); + skipped.Sample.ShouldContain(s => s.RawPath == "Strs" && s.Reason.Contains("not supported as an array")); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + await drv.ShutdownAsync(CancellationToken.None); + } + + private sealed class RefusingDecodeTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p) + { + public override object? DecodeArray(AbLegacyDataType type, int count) => + throw new NotSupportedException("decode refused"); + + public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => + throw new NotSupportedException("encode refused"); + } + + [Fact] + public async Task Runtime_NotSupported_on_read_is_BadNotSupported_keeps_handle_no_health_warns_once() + { + var logger = new AbLegacyTestLogger(); + var factory = new FakeAbLegacyTagFactory + { + Customise = p => p.TagName == "N7:0" ? new RefusingDecodeTag(p) : new FakeAbLegacyTag(p) { Value = 7 }, + }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host)], + RawTags = AbLegacyRawTags.Entries( + new AbLegacyTagDefinition("Arr", Host, "N7:0", AbLegacyDataType.Int, ArrayLength: 4), + new AbLegacyTagDefinition("Good", Host, "N9:0", AbLegacyDataType.Int)), + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-h16", factory, logger); + await drv.InitializeAsync("{}", CancellationToken.None); + + var first = await drv.ReadAsync(["Arr", "Good"], CancellationToken.None); + var second = await drv.ReadAsync(["Arr", "Good"], CancellationToken.None); + + first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); + second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); + first[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: the refusal is per tag + var tag = factory.Tags["N7:0"]; + tag.InitializeCount.ShouldBe(1); // handle kept, not churned + tag.Disposed.ShouldBeFalse(); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + logger.Warnings.Count(w => w.Contains("Arr") && w.Contains("decode refused")).ShouldBe(1); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Runtime_NotSupported_on_write_is_BadNotSupported_with_no_health_write() + { + var factory = new FakeAbLegacyTagFactory { Customise = p => new RefusingDecodeTag(p) { Value = 1 } }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host)], + RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)), + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-h16", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + + var w = await drv.WriteAsync([new WriteRequest("X", 5)], CancellationToken.None); + + w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + factory.Tags["N7:0"].Disposed.ShouldBeFalse(); + await drv.ShutdownAsync(CancellationToken.None); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs index 975bd68e..2bf866bb 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs @@ -122,6 +122,20 @@ public sealed class AbLegacyTagConfigModelTests m.Validate().ShouldBeNull(); } + /// Review 07 H16: an array of a structured element type is refused with the reader's own reason. + [Theory] + [InlineData("String", "ST9:0")] + [InlineData("TimerElement", "T4:0")] + public void Validate_refuses_an_array_of_a_structured_element_type(string dataType, string address) + { + var m = AbLegacyTagConfigModel.FromJson( + $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}"""); + + m.Validate().ShouldNotBeNull().ShouldContain("not supported as an array"); + // Positive control: the same type as a scalar validates. + AbLegacyTagConfigModel.FromJson($$"""{"address":"{{address}}","dataType":"{{dataType}}"}""").Validate().ShouldBeNull(); + } + [Fact] public void Validate_returns_null_for_scalar_tag_regardless_of_no_array_length() { From cb16ebc75e757cc1129f56b41900117c5b55dd93 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:21:43 -0400 Subject: [PATCH 19/67] fix(abcip): bit-in-word writes take the parent width from its read H17: the .n read-modify-write hard-coded a DINT parent. On an INT or SINT host (both legal in Logix, and reads of Flags.3 there already worked) the DINT decode of a 2-byte buffer threw OutOfBounds, the generic catch evicted the BIT tag's (non-existent) runtime and reported BadCommunicationError + Degraded. The parent width now comes from the parent read's size (1 SINT / 2 INT / 4 DINT / 8 LINT; unknown keeps the DINT assumption). Narrow hosts use unsigned arithmetic so a set top bit encodes without overflow; a bit past the host's width is BadOutOfRange and a non-integer width BadTypeMismatch, both refused before anything is written, warned once, no health write. A transport failure in the RMW now evicts the parent runtime it actually used. Native end-to-end: an INT host is written as ONE INT (type 0xC3, 2 bytes). Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipDriver.cs | 73 ++++++++-- .../AbCipBitInWordWidthTests.cs | 127 ++++++++++++++++++ 2 files changed, 189 insertions(+), 11 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBitInWordWidthTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index b48a619c..89f06793 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -1374,6 +1374,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, continue; } + string? rmwParent = null; try { var parsedPath = AbCipTagPath.TryParse(def.TagPath); @@ -1384,6 +1385,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // losing one another's update. if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit) { + rmwParent = (parsedPath with { BitIndex = null }).ToLibplctagName(); results[i] = new WriteResult( await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken) .ConfigureAwait(false)); @@ -1542,8 +1544,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } catch (Exception ex) { - // Transport / wire error — evict so the next write creates a fresh handle. - EvictRuntime(device, def.Name); + // Transport / wire error — evict so the next write creates a fresh handle (for a bit write, the + // parent runtime the read-modify-write used; the bit tag itself holds none). + if (rmwParent is not null) EvictParentRuntime(device, rmwParent); + else EvictRuntime(device, def.Name); results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError); _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); _logger.LogWarning(ex, @@ -1556,10 +1560,11 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } /// - /// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel - /// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent - /// writers against the same parent via a per-parent . - /// Matches the Modbus BitInRegister + FOCAS PMC Bit pattern. + /// Read-modify-write one bit within an integer parent (SINT / INT / DINT / LINT — Logix allows .n on + /// each). Creates / reuses a parallel parent runtime (distinct from the bit-selector handle) + serialises + /// concurrent writers against the same parent via a per-parent . The parent's + /// width comes from the size its read returned (H17 — a hard-coded DINT decode threw OutOfBounds on an INT + /// host and was reported as a comms fault). Matches the Modbus BitInRegister + FOCAS PMC Bit pattern. /// private async Task WriteBitInDIntAsync( DeviceState device, AbCipTagPath bitPath, int bit, object? value, CancellationToken ct) @@ -1576,12 +1581,49 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, var readStatus = parentRuntime.GetStatus(); if (readStatus != 0) return AbCipStatusMapper.MapStatus(readStatus); - var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0); - var updated = Convert.ToBoolean(value) - ? current | (1 << bit) - : current & ~(1 << bit); + // Unsigned at the narrow widths so a set top bit (SINT bit 7, INT bit 15) encodes without overflowing the + // signed type; DINT keeps its signed int arithmetic (1 << 31 is int.MinValue, which a DINT encodes as is). + var size = parentRuntime.GetSize(); + AbCipDataType? hostType = size switch + { + 1 => AbCipDataType.USInt, + 2 => AbCipDataType.UInt, + 8 => AbCipDataType.LInt, + 4 or <= 0 => AbCipDataType.DInt, // <= 0: the runtime cannot say — the historical DINT assumption + _ => null, + }; + if (hostType is not { } type) + { + WarnTypeMismatchOnce(parentName, + "AbCip bit write to {Parent}.{Bit} on device {Device} refused: the parent is {Size} byte(s), not an integer host (logged once per tag)", + parentName, bit, device.Options.HostAddress, size); + return AbCipStatusMapper.BadTypeMismatch; + } + var widthBits = type switch { AbCipDataType.USInt => 8, AbCipDataType.UInt => 16, AbCipDataType.LInt => 64, _ => 32 }; + if (bit >= widthBits) + { + WarnTypeMismatchOnce(parentName, + "AbCip bit write to {Parent}.{Bit} on device {Device} refused: the parent holds only {Bits} bits (logged once per tag)", + parentName, bit, device.Options.HostAddress, widthBits); + return AbCipStatusMapper.BadOutOfRange; + } - parentRuntime.EncodeValue(AbCipDataType.DInt, bitIndex: null, updated); + var set = Convert.ToBoolean(value); + var decoded = parentRuntime.DecodeValue(type, bitIndex: null) ?? 0; + object updated; + if (type == AbCipDataType.LInt) + { + var current = Convert.ToInt64(decoded); + updated = set ? current | (1L << bit) : current & ~(1L << bit); + } + else + { + var mask = type switch { AbCipDataType.USInt => 0xFF, AbCipDataType.UInt => 0xFFFF, _ => -1 }; + var current = Convert.ToInt32(decoded) & mask; + updated = (set ? current | (1 << bit) : current & ~(1 << bit)) & mask; + } + + parentRuntime.EncodeValue(type, bitIndex: null, updated); await parentRuntime.WriteAsync(ct).ConfigureAwait(false); var writeStatus = parentRuntime.GetStatus(); return writeStatus == 0 @@ -1763,6 +1805,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } } + /// Evicts + disposes a bit-write parent runtime so the next read-modify-write re-creates it. + private static void EvictParentRuntime(DeviceState device, string parentName) + { + if (device.ParentRuntimes.TryRemove(parentName, out var stale)) + { + try { stale.Dispose(); } catch { } + } + } + /// public DriverHealth GetHealth() => _health; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBitInWordWidthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBitInWordWidthTests.cs new file mode 100644 index 00000000..11f7b736 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBitInWordWidthTests.cs @@ -0,0 +1,127 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Review 07 H17: Logix allows a .n bit selector on SINT / INT / DINT (and LINT) hosts, and reads of +/// Flags.3 on an INT worked — but the bit write's read-modify-write hard-coded a DINT parent, so its +/// DecodeValue(DInt) on a 2-byte buffer threw OutOfBounds and the write was reported as a comms fault +/// (BadCommunicationError + Degraded). The parent width now comes from the parent read's size. +/// +[Trait("Category", "Unit")] +public sealed class AbCipBitInWordWidthTests +{ + private const string Device = "ab://10.0.0.5/1,0"; + + private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync(int parentSize, object parentValue, string bitPath) + { + var factory = new FakeAbCipTagFactory + { + Customise = p => new FakeAbCipTag(p) { Value = parentValue, Size = parentSize }, + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Bit", Device, bitPath, AbCipDataType.Bool)), + Probe = new AbCipProbeOptions { Enabled = false }, + }, "abcip-bit", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + return (drv, factory); + } + + /// SINT / INT / DINT hosts: the bit is set, every other bit is preserved, nothing degrades. + /// The parent's wire width. + /// The parent's value before the write. + /// The bit written true. + /// The value the parent must be written back with. + [Theory] + [InlineData(1, 0x81, 3, 0x89)] // SINT 0x81 (-127): bit 7 set stays set, written as its unsigned byte + [InlineData(1, 0x00, 7, 0x80)] // SINT top bit: 128 would overflow an sbyte encode + [InlineData(2, 0x8001, 3, 0x8009)] // INT top bit preserved + [InlineData(2, 0x0000, 15, 0x8000)] // INT top bit: 32768 would overflow a short encode + [InlineData(4, 0x00000001, 3, 0x00000009)] + public async Task A_bit_write_uses_the_parent_width(int size, int before, int bit, int after) + { + var (drv, factory) = await BuildAsync(size, before, $"Motor.Flags.{bit}"); + + var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + Convert.ToInt64(factory.Tags["Motor.Flags"].Value).ShouldBe(after); + factory.Tags["Motor.Flags"].WriteCount.ShouldBe(1); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// A bit past the host's width is refused before anything is written, with no health write. + [Fact] + public async Task A_bit_past_the_host_width_is_BadOutOfRange_and_nothing_is_written() + { + var (drv, factory) = await BuildAsync(2, 0, "Motor.Flags.20"); + + var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); + factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// A host whose width is no integer's is a type mismatch, refused before writing, no health write. + [Fact] + public async Task A_host_of_no_integer_width_is_BadTypeMismatch() + { + var (drv, factory) = await BuildAsync(3, 0, "Motor.Flags.1"); + + var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); + factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// + /// End to end over the REAL native runtime against a fake CIP target whose Flags is an INT: the write + /// goes out as ONE INT (type 0xC3, two data bytes) with the bit set and the other bits preserved. + /// + [Fact] + public async Task A_native_bit_write_on_an_INT_host_writes_an_INT() + { + await using var target = new FakeCipTarget(); + // Read Tag Fragmented (0x52) → INT (0x00C3) 0x8001; Write Tag (0x4D) → success. + target.ConnectedHandler = req => Task.FromResult(req[0] switch + { + 0x52 => [0xD2, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x01, 0x80], + 0x4D => [0xCD, 0x00, 0x00, 0x00], + _ => target.ServiceReply(req), + }); + var host = $"ab://127.0.0.1:{target.Port}/1,0"; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Bit", host, "Flags.3", AbCipDataType.Bool)), + Probe = new AbCipProbeOptions { Enabled = false }, + Timeout = TimeSpan.FromSeconds(5), + }, "abcip-bit-native", new NativeAbCipTagFactory(new DeviceSessionPool())); + await drv.InitializeAsync("{}", CancellationToken.None); + try + { + var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + var write = target.ConnectedCips.Single(c => c[0] == 0x4D); + var typeAt = 2 + write[1] * 2; + write[typeAt].ShouldBe((byte)0xC3); // INT, not DINT + write.Length.ShouldBe(typeAt + 2 + 2 + 2); // type, element count, ONE 2-byte element + write[^2].ShouldBe((byte)0x09); // 0x8001 | bit 3 + write[^1].ShouldBe((byte)0x80); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + finally + { + await drv.DisposeAsync(); + } + } +} From 895cc28886993feb421268f792d7c85948dc3fea Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:24:06 -0400 Subject: [PATCH 20/67] fix(abcip): write-side health neither latches nor overwrites H19: Format/InvalidCast/Overflow on a client value never touched the wire but latched Degraded; a good write set Healthy and so undid a Degraded the poll loop had just recorded for other tags (the batch read path is worst-wins for exactly this reason). - The three conversion failures map to BadTypeMismatch/BadOutOfRange with a once-per-tag Warning and no health write (AbLegacy's rule). - A good write (scalar or bit RMW) advances LastSuccessfulRead only - the device answered - and leaves the state alone. - A write comms fault (thrown, or a non-refusal status word, which used to write no health at all) is Degraded with LastSuccessfulRead preserved and never downgrades Faulted. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipDriver.cs | 45 ++++--- .../AbCipWriteHealthTests.cs | 113 ++++++++++++++++++ 2 files changed, 142 insertions(+), 16 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 89f06793..f675234e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -1390,7 +1390,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken) .ConfigureAwait(false)); if (results[i].StatusCode == AbCipStatusMapper.Good) - _health = new DriverHealth(DriverState.Healthy, now, null); + RecordWriteAnswered(now); continue; } @@ -1455,6 +1455,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, { EvictRuntime(device, def.Name); results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status)); + RecordWriteCommsFault($"transport status {status} writing {w.FullReference}"); _logger.LogWarning( "AbCip write returned non-zero transport status {Status} for tag {Tag} on device {Device}; " + "evicting cached runtime so next call re-creates it", @@ -1463,7 +1464,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, else { results[i] = new WriteResult(AbCipStatusMapper.Good); - _health = new DriverHealth(DriverState.Healthy, now, null); + RecordWriteAnswered(now); } } catch (OperationCanceledException) @@ -1481,30 +1482,29 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, "AbCip write not supported for tag {Tag} on device {Device}: {Reason} (logged once per tag)", w.FullReference, def.DeviceHostAddress, nse.Message); } + // H19: the three client-value conversion failures below never touched the wire — they say nothing about + // the device, so they write no health (a Degraded here latched until the next good read, and AbLegacy + // already maps the same three with no health write). Not a transport fault either: no evict. catch (FormatException fe) { - // Value conversion error — not a transport fault; don't evict. results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message); - _logger.LogWarning(fe, - "AbCip write value-conversion error for tag {Tag} on device {Device}", - w.FullReference, def.DeviceHostAddress); + WarnWriteRefusedOnce(def.Name, + "AbCip write to {Tag} on device {Device}: the value cannot be converted to {DataType}: {Reason} (logged once per tag)", + w.FullReference, def.DeviceHostAddress, def.DataType, fe.Message); } catch (InvalidCastException ice) { results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message); - _logger.LogWarning(ice, - "AbCip write type-cast error for tag {Tag} on device {Device}", - w.FullReference, def.DeviceHostAddress); + WarnWriteRefusedOnce(def.Name, + "AbCip write to {Tag} on device {Device}: the value cannot be converted to {DataType}: {Reason} (logged once per tag)", + w.FullReference, def.DeviceHostAddress, def.DataType, ice.Message); } catch (OverflowException oe) { results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message); - _logger.LogWarning(oe, - "AbCip write value out of range for tag {Tag} on device {Device}", - w.FullReference, def.DeviceHostAddress); + WarnWriteRefusedOnce(def.Name, + "AbCip write to {Tag} on device {Device}: the value is out of range for {DataType}: {Reason} (logged once per tag)", + w.FullReference, def.DeviceHostAddress, def.DataType, oe.Message); } catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported) { @@ -1549,7 +1549,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, if (rmwParent is not null) EvictParentRuntime(device, rmwParent); else EvictRuntime(device, def.Name); results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + RecordWriteCommsFault(ex.Message); _logger.LogWarning(ex, "AbCip write transport exception for tag {Tag} on device {Device}", w.FullReference, def.DeviceHostAddress); @@ -1559,6 +1559,19 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, return results; } + /// + /// A good write proves the device answered: advance LastSuccessfulRead but leave the STATE alone. Writing + /// Healthy here (H19) overwrote a Degraded the poll loop had just recorded for other tags. + /// + private void RecordWriteAnswered(DateTime now) => + UpdateHealth(h => h with { LastSuccessfulRead = now }); + + /// A write-side comms fault: Degraded (never downgrading Faulted), LastSuccessfulRead preserved. + private void RecordWriteCommsFault(string error) => + UpdateHealth(h => h.State == DriverState.Faulted + ? h + : new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, error)); + /// /// Read-modify-write one bit within an integer parent (SINT / INT / DINT / LINT — Logix allows .n on /// each). Creates / reuses a parallel parent runtime (distinct from the bit-selector handle) + serialises diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs new file mode 100644 index 00000000..4821a43a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs @@ -0,0 +1,113 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Review 07 H19: write-side health. A client value that cannot be converted never touched the wire, so it must +/// not latch Degraded; and a good write must not overwrite a Degraded the read path just recorded +/// with Healthy (the batch read path is worst-wins for exactly this reason). A good write proves the device +/// answered, so it advances LastSuccessfulRead without touching the state. +/// +[Trait("Category", "Unit")] +public sealed class AbCipWriteHealthTests +{ + private const string Device = "ab://10.0.0.5/1,0"; + + private sealed class ThrowingWriteTag(AbCipTagCreateParams p) : FakeAbCipTag(p) + { + public override Task WriteAsync(CancellationToken cancellationToken) => + throw new InvalidOperationException("socket reset"); + } + + private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync( + Func? make = null) + { + var factory = new FakeAbCipTagFactory + { + Customise = make ?? (p => p.TagName == "Broken" + ? new FakeAbCipTag(p) { ThrowOnRead = true } + : new RealEncodingTag(p) { Value = 0 }), + }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device)], + RawTags = AbCipRawTags.From( + new AbCipTagDefinition("D", Device, "D", AbCipDataType.DInt), + new AbCipTagDefinition("I", Device, "I", AbCipDataType.Int), + new AbCipTagDefinition("Broken", Device, "Broken", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions { Enabled = false }, + }, "abcip-write-health", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + return (drv, factory); + } + + /// A fake whose encode converts like the real runtimes do (Convert.ToInt32 / ToInt16). + private sealed class RealEncodingTag(AbCipTagCreateParams p) : FakeAbCipTag(p) + { + public override void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => + Value = type switch + { + AbCipDataType.Int => Convert.ToInt16(value), + _ => Convert.ToInt32(value), + }; + } + + /// Unconvertible / out-of-range client values: refused per tag, health untouched, nothing sent. + [Fact] + public async Task A_client_conversion_error_writes_no_health() + { + var (drv, factory) = await BuildAsync(); + var before = drv.GetHealth(); + + var r = await drv.WriteAsync( + [ + new WriteRequest("D", "not a number"), // FormatException + new WriteRequest("D", new object()), // InvalidCastException + new WriteRequest("I", 1_000_000), // OverflowException + ], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); + r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); + r[2].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); + drv.GetHealth().ShouldBe(before); + factory.Tags["D"].WriteCount.ShouldBe(0); + factory.Tags["I"].WriteCount.ShouldBe(0); + } + + /// A good write keeps a Degraded the read path recorded, and advances LastSuccessfulRead. + [Fact] + public async Task A_good_write_does_not_overwrite_Degraded_with_Healthy() + { + var (drv, _) = await BuildAsync(); + await drv.ReadAsync(["Broken"], CancellationToken.None); + var degraded = drv.GetHealth(); + degraded.State.ShouldBe(DriverState.Degraded); + await Task.Delay(20); + + var r = await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + var after = drv.GetHealth(); + after.State.ShouldBe(DriverState.Degraded); + after.LastError.ShouldBe(degraded.LastError); + after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(degraded.LastSuccessfulRead!.Value); // the device answered + } + + /// Positive control: a genuine write transport fault still degrades and preserves LastSuccessfulRead. + [Fact] + public async Task A_write_transport_fault_still_degrades() + { + var (drv, factory) = await BuildAsync(p => new ThrowingWriteTag(p)); + var before = drv.GetHealth(); + + var r = await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); + var after = drv.GetHealth(); + after.State.ShouldBe(DriverState.Degraded); + after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead); + factory.Tags["D"].Disposed.ShouldBeTrue(); + } +} From 4615495f01399cc66cab3d1cdd8675a68c4fa0c7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:24:53 -0400 Subject: [PATCH 21/67] feat(scripting): bind the Scripting section node-wide and clamp its knobs Program.cs binds ScriptingOptions from Scripting:* once, clamps every numeric knob through ScriptingOptions.Sanitized (logging a Warning per adjustment) and registers the one instance. It now reaches all three script seams: RoslynVirtualTagEvaluator, the scripted-alarm engine built in DriverHostActor (new trailing Props parameter, wired in WithOtOpcUaRuntimeActors) and every Calculation driver (node options as the base, per-driver config keys override one knob on a copy). ScriptRunGuard sanitizes again in its ctor and computes the stuck threshold with a saturating multiply, so an absurd StuckOrphanQuarantineFactor can no longer throw OverflowException on a refused run. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../ScriptRunGuard.cs | 12 +- .../ScriptingOptions.cs | 81 +++++++++++++ .../CalculationDriverFactoryExtensions.cs | 55 +++++++-- .../Drivers/DriverFactoryBootstrap.cs | 9 +- src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 14 ++- .../Drivers/DriverHostActor.cs | 20 +++- .../ServiceCollectionExtensions.cs | 5 +- .../ScriptingOptionsTests.cs | 109 ++++++++++++++++++ ...culationScriptingOptionsResolutionTests.cs | 56 +++++++++ 9 files changed, 338 insertions(+), 23 deletions(-) create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptingOptionsTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationScriptingOptionsResolutionTests.cs diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunGuard.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunGuard.cs index de1afee2..7efa4026 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunGuard.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptRunGuard.cs @@ -96,9 +96,11 @@ public sealed class ScriptRunGuard /// Where the quarantine Error line goes; null ⇒ Serilog's global logger. public ScriptRunGuard(ScriptingOptions? options = null, ILogger? logger = null) { - options ??= new ScriptingOptions(); + // Clamped here as well as at the bind site, because options also arrive from a Calculation driver's own + // config and from tests. An unclamped factor made the refusal path's timeout * factor overflow. + options = (options ?? new ScriptingOptions()).Sanitized(out _); _maxAllocBytesPerRun = options.MaxAllocBytesPerRun; - _quarantineThreshold = Math.Max(1, options.QuarantineAfterConsecutiveTimeouts); + _quarantineThreshold = options.QuarantineAfterConsecutiveTimeouts; _stuckFactor = options.StuckOrphanQuarantineFactor; _logger = logger ?? Log.Logger; } @@ -197,7 +199,11 @@ public sealed class ScriptRunGuard if (_stuckFactor > 0 && Volatile.Read(ref state.RunStartedTimestamp) is var startedAt and not 0) { var running = Stopwatch.GetElapsedTime(startedAt); - var threshold = timeout * _stuckFactor; + // Saturating: even the clamped maximum factor times a huge timeout must not overflow TimeSpan. + var thresholdTicks = timeout.Ticks * _stuckFactor; + var threshold = thresholdTicks >= TimeSpan.MaxValue.Ticks + ? TimeSpan.MaxValue + : TimeSpan.FromTicks((long)thresholdTicks); if (running > threshold) { Quarantine(key, state, diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptingOptions.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptingOptions.cs index 296d862c..167e124f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptingOptions.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptingOptions.cs @@ -53,4 +53,85 @@ public sealed class ScriptingOptions /// refusal. Zero or negative disables the rule. Default 10. /// public double StuckOrphanQuarantineFactor { get; set; } = DefaultStuckOrphanQuarantineFactor; + + /// Upper bound applies to . + public const double MaxStuckOrphanQuarantineFactor = 1000; + + /// Upper bound applies to . + public const int MaxQuarantineAfterConsecutiveTimeouts = 1000; + + /// + /// Lower bound applies to a positive : 1 MB. A + /// smaller budget would quarantine practically every script on its first run. + /// + public const long MinMaxAllocBytesPerRun = 1024L * 1024; + + /// + /// Returns a copy with every numeric knob pulled into a sane range. The bind site calls it and logs + /// . calls it again, so options that reach the + /// guard by another route (a Calculation driver's own config, a test) are clamped too. + /// + /// : NaN becomes the default, a negative value becomes 0 + /// (disabled), and a value above becomes that maximum. + /// Unclamped, an absurd factor made timeout * factor throw on + /// the first refused run. + /// : into [1, ]. + /// : a negative value becomes 0 (disabled), and a positive value + /// below becomes that minimum. + /// + /// + /// One line per value that was out of range, naming its key. A negative value that + /// already meant "disabled" is normalised without a line. + /// A new, clamped instance. This instance is not modified. + public ScriptingOptions Sanitized(out IReadOnlyList adjustments) + { + var notes = new List(); + + var factor = StuckOrphanQuarantineFactor; + if (double.IsNaN(factor)) + { + notes.Add($"{SectionName}:{nameof(StuckOrphanQuarantineFactor)} is NaN; using the default {DefaultStuckOrphanQuarantineFactor}."); + factor = DefaultStuckOrphanQuarantineFactor; + } + else if (factor < 0) + { + factor = 0; + } + else if (factor > MaxStuckOrphanQuarantineFactor) + { + notes.Add($"{SectionName}:{nameof(StuckOrphanQuarantineFactor)} {factor} is above the maximum; clamped to {MaxStuckOrphanQuarantineFactor}."); + factor = MaxStuckOrphanQuarantineFactor; + } + + var threshold = QuarantineAfterConsecutiveTimeouts; + if (threshold < 1) + { + notes.Add($"{SectionName}:{nameof(QuarantineAfterConsecutiveTimeouts)} {threshold} is below 1; clamped to 1."); + threshold = 1; + } + else if (threshold > MaxQuarantineAfterConsecutiveTimeouts) + { + notes.Add($"{SectionName}:{nameof(QuarantineAfterConsecutiveTimeouts)} {threshold} is above the maximum; clamped to {MaxQuarantineAfterConsecutiveTimeouts}."); + threshold = MaxQuarantineAfterConsecutiveTimeouts; + } + + var alloc = MaxAllocBytesPerRun; + if (alloc < 0) + { + alloc = 0; + } + else if (alloc > 0 && alloc < MinMaxAllocBytesPerRun) + { + notes.Add($"{SectionName}:{nameof(MaxAllocBytesPerRun)} {alloc} is below the minimum; clamped to {MinMaxAllocBytesPerRun}."); + alloc = MinMaxAllocBytesPerRun; + } + + adjustments = notes; + return new ScriptingOptions + { + MaxAllocBytesPerRun = alloc, + QuarantineAfterConsecutiveTimeouts = threshold, + StuckOrphanQuarantineFactor = factor, + }; + } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs index 1a589d65..ce7817cf 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs @@ -38,13 +38,16 @@ public static class CalculationDriverFactoryExtensions /// The driver factory registry to register with. /// Optional logger factory for per-instance diagnostics loggers. /// Optional root script logger (fans failure entries onto the script-logs topic). + /// The node-wide Scripting options every Calculation driver inherits; a key + /// authored in a driver's own config overrides that one knob. Null ⇒ the compiled defaults. public static void Register( DriverFactoryRegistry registry, ILoggerFactory? loggerFactory = null, - ScriptRootLogger? scriptRoot = null) + ScriptRootLogger? scriptRoot = null, + ScriptingOptions? scripting = null) { ArgumentNullException.ThrowIfNull(registry); - registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot)); + registry.Register(DriverTypeName, (id, json) => CreateInstance(id, json, loggerFactory, scriptRoot, scripting)); } /// Public overload for the Server-side bootstrapper + test consumers. @@ -63,6 +66,18 @@ public static class CalculationDriverFactoryExtensions public static CalculationDriver CreateInstance( string driverInstanceId, string driverConfigJson, ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot) + => CreateInstance(driverInstanceId, driverConfigJson, loggerFactory, scriptRoot, nodeScripting: null); + + /// The full overload: also takes the node-wide Scripting options the driver inherits. + /// The unique identifier for the driver instance. + /// The merged driver config JSON (carries the RawTags array). + /// Optional logger factory for per-instance diagnostics loggers. + /// Optional root script logger for failure fan-out. + /// The node-wide run-guard options; null ⇒ the compiled defaults. + /// The constructed . + public static CalculationDriver CreateInstance( + string driverInstanceId, string driverConfigJson, + ILoggerFactory? loggerFactory, ScriptRootLogger? scriptRoot, ScriptingOptions? nodeScripting) { ArgumentException.ThrowIfNullOrWhiteSpace(driverInstanceId); ArgumentException.ThrowIfNullOrWhiteSpace(driverConfigJson); @@ -71,15 +86,7 @@ public static class CalculationDriverFactoryExtensions ?? throw new InvalidOperationException( $"Calculation driver config for '{driverInstanceId}' deserialised to null"); - // Run-guard knobs are optional and default to the node-wide ScriptingOptions defaults; only an - // explicitly authored key overrides one (so a config carrying neither binds Scripting = null). - ScriptingOptions? scripting = null; - if (dto.MaxAllocBytesPerRun is not null || dto.QuarantineAfterConsecutiveTimeouts is not null) - { - scripting = new ScriptingOptions(); - if (dto.MaxAllocBytesPerRun is { } alloc) scripting.MaxAllocBytesPerRun = alloc; - if (dto.QuarantineAfterConsecutiveTimeouts is { } n) scripting.QuarantineAfterConsecutiveTimeouts = n; - } + var scripting = ResolveScripting(dto.MaxAllocBytesPerRun, dto.QuarantineAfterConsecutiveTimeouts, nodeScripting); var options = new CalculationDriverOptions { @@ -94,6 +101,32 @@ public static class CalculationDriverFactoryExtensions logger: loggerFactory?.CreateLogger()); } + /// + /// The run-guard options for one Calculation driver. The node-wide options are the base. A key authored + /// in the driver's config overrides only its own knob, on a copy, so the shared node object is never + /// written. With neither, the result is null and the guard uses its compiled defaults. + /// + /// The driver config's maxAllocBytesPerRun, or null. + /// The driver config's quarantineAfterConsecutiveTimeouts, or null. + /// The node-wide options, or null. + /// The options to hand the driver, or null. + internal static ScriptingOptions? ResolveScripting( + long? maxAllocBytesPerRun, int? quarantineAfterConsecutiveTimeouts, ScriptingOptions? nodeScripting) + { + if (maxAllocBytesPerRun is null && quarantineAfterConsecutiveTimeouts is null) return nodeScripting; + + var resolved = new ScriptingOptions(); + if (nodeScripting is not null) + { + resolved.MaxAllocBytesPerRun = nodeScripting.MaxAllocBytesPerRun; + resolved.QuarantineAfterConsecutiveTimeouts = nodeScripting.QuarantineAfterConsecutiveTimeouts; + resolved.StuckOrphanQuarantineFactor = nodeScripting.StuckOrphanQuarantineFactor; + } + if (maxAllocBytesPerRun is { } alloc) resolved.MaxAllocBytesPerRun = alloc; + if (quarantineAfterConsecutiveTimeouts is { } n) resolved.QuarantineAfterConsecutiveTimeouts = n; + return resolved; + } + /// The merged-config DTO the calc factory binds from. The Calculation driver has no /// backend/endpoint config — only the injected raw tags (+ an optional evaluation-timeout override). internal sealed class CalculationDriverConfigDto diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs index 45ee0286..7cb8d869 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Drivers/DriverFactoryBootstrap.cs @@ -68,6 +68,8 @@ public static class DriverFactoryBootstrap // The calc driver needs the root script logger so a script failure fans out onto the // script-logs topic; resolve it here (null on nodes without the script pipeline wired). var scriptRoot = sp.GetService(); + // The node-wide Scripting section (bound + clamped in Program.cs); null in test graphs ⇒ defaults. + var scripting = sp.GetService(); // The Galaxy driver resolves its Gateway.ApiKeySecretRef secret: arm through the // shared ISecretResolver (registered unconditionally by AddZbSecrets on every node). var secretResolver = sp.GetRequiredService(); @@ -84,7 +86,7 @@ public static class DriverFactoryBootstrap // failures; without this it writes to NullLogger in production. ConfigureAbTransportLogging(sp); Register(registry, loggerFactory, secretResolver, primaryRoleSource, ResolveAbCipDefaultTransport(sp), - scriptRoot); + scriptRoot, scripting); return registry; }); services.AddSingleton(sp => @@ -209,11 +211,12 @@ public static class DriverFactoryBootstrap ISecretResolver secretResolver, IDriverPrimaryRoleSource primaryRoleSource, Driver.AbCip.AbCipTransport abCipDefaultTransport, - ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null) + ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptRootLogger? scriptRoot = null, + ZB.MOM.WW.OtOpcUa.Core.Scripting.ScriptingOptions? scripting = null) { Driver.AbCip.AbCipDriverFactoryExtensions.Register(registry, loggerFactory, abCipDefaultTransport); Driver.AbLegacy.AbLegacyDriverFactoryExtensions.Register(registry, loggerFactory); - Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot); + Driver.Calculation.CalculationDriverFactoryExtensions.Register(registry, loggerFactory, scriptRoot, scripting); Driver.FOCAS.FocasDriverFactoryExtensions.Register(registry, loggerFactory); Driver.Galaxy.GalaxyDriverFactoryExtensions.Register(registry, secretResolver, loggerFactory); Driver.Modbus.ModbusDriverFactoryExtensions.Register(registry, loggerFactory); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index b2300ab6..70ecc606 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -365,10 +365,22 @@ if (hasDriver) // F8b — production virtual-tag evaluator (Roslyn-compiled scripts cached per expression). // Replaces the F8-default NullVirtualTagEvaluator so VirtualTagActor evaluates real user // scripts at runtime. + // Node-wide script run-guard knobs (Scripting:MaxAllocBytesPerRun / QuarantineAfterConsecutiveTimeouts / + // StuckOrphanQuarantineFactor). Bound once and clamped into sane ranges here, so an absurd value is a startup + // Warning rather than an OverflowException inside a script run. The one instance is shared by every script + // seam on the node: the virtual-tag evaluator below, the scripted-alarm engine (DriverHostActor, via + // WithOtOpcUaRuntimeActors) and every Calculation driver (DriverFactoryBootstrap). + var scriptingOptions = (builder.Configuration.GetSection(ScriptingOptions.SectionName).Get() + ?? new ScriptingOptions()).Sanitized(out var scriptingAdjustments); + foreach (var adjustment in scriptingAdjustments) + Log.Warning("Scripting misconfiguration detected at startup: {Adjustment}", adjustment); + builder.Services.AddSingleton(scriptingOptions); + builder.Services.AddSingleton(sp => new RoslynVirtualTagEvaluator( sp.GetRequiredService().CreateLogger(), - sp.GetRequiredService())); + sp.GetRequiredService(), + scripting: sp.GetRequiredService())); builder.Services.AddSingleton(sp => sp.GetRequiredService()); // Script-log fan-out (Layer 0). The DPS publisher resolves the ActorSystem lazily so it never diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 76c82ebf..eee3a095 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -202,6 +202,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS private readonly IHistoryWriter _historyWriter; private readonly IActorRef? _virtualTagHostOverride; private readonly ScriptRootLogger? _scriptRootLogger; + + /// Node-wide script run-guard options for the scripted-alarm engine; null ⇒ compiled defaults. + private readonly ScriptingOptions? _scripting; private readonly IActorRef? _scriptedAlarmHostOverride; private readonly ILoggingAdapter _log = Context.GetLogger(); @@ -694,6 +697,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS /// capture to a mesh-partner Primary the cluster failure detector reports unreachable. /// Where the OPC UA HistoryUpdate verdict is published for the node manager's /// HistoryUpdate Primary gate. Null on nodes that wire no redundancy manager. + /// The node-wide Scripting run-guard options handed to the scripted-alarm + /// engine. Null ⇒ the compiled defaults. /// The Akka.NET used to spawn this actor. public static Props Props( IDbContextFactory? dbFactory, @@ -728,7 +733,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS Func? sharesAlarmQueueWithLocal = null, TimeSpan? applyOutcomeTimeout = null, bool captureWhilePrimaryUnreachable = true, - IHistoryUpdateRoleView? historyUpdateRoleView = null) => + IHistoryUpdateRoleView? historyUpdateRoleView = null, + ScriptingOptions? scripting = null) => // WARNING: this forwarding list is POSITIONAL, and Props.Create compiles it into an // expression tree. Six IActorRef? parameters and several interface-typed ones mean a // mis-ordered argument is usually type-compatible and therefore compiles clean, then binds @@ -741,7 +747,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher, alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView, sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay, - sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView)); + sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView, + scripting)); /// Initializes a new DriverHostActor with the specified dependencies. /// Database context factory for configuration database access. @@ -830,6 +837,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS /// ( — the data-plane verdict, but closed until the first /// redundancy snapshot) is published for the node manager's HistoryUpdate Primary gate. Null on nodes that wire no /// redundancy manager. + /// The node-wide Scripting run-guard options handed to the scripted-alarm + /// engine. Null ⇒ the compiled defaults. public DriverHostActor( IDbContextFactory? dbFactory, CommonsNodeId localNode, @@ -863,8 +872,10 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS Func? sharesAlarmQueueWithLocal = null, TimeSpan? applyOutcomeTimeout = null, bool captureWhilePrimaryUnreachable = true, - IHistoryUpdateRoleView? historyUpdateRoleView = null) + IHistoryUpdateRoleView? historyUpdateRoleView = null, + ScriptingOptions? scripting = null) { + _scripting = scripting; _captureWhilePrimaryUnreachable = captureWhilePrimaryUnreachable; _historyUpdateRoleView = historyUpdateRoleView; _applyOutcomeTimeout = applyOutcomeTimeout ?? DefaultApplyOutcomeTimeout; @@ -1181,7 +1192,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS var store = _alarmStateStore; var upstream = new DependencyMuxTagUpstreamSource(); var engine = new ScriptedAlarmEngine( - upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger); + upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger, + scripting: _scripting); _scriptedAlarmHost = Context.ActorOf( ScriptedAlarmHostActor.Props( _opcUaPublishActor, _dependencyMux, upstream, engine, _localNode, telemetryHub: _telemetryHub), diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index e6e58419..17237391 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -638,7 +638,10 @@ public static class ServiceCollectionExtensions sharesAlarmQueueWithLocal: BuildAlarmQueuePeerPredicate( resolver.GetService(), roleInfo, replicationPeerHost), // The HistoryUpdate verdict (closed until the first redundancy snapshot). - historyUpdateRoleView: resolver.GetService()), + historyUpdateRoleView: resolver.GetService(), + // The node-wide Scripting section (bound + clamped in Program.cs) for the scripted-alarm + // engine. Unbound in test graphs ⇒ null ⇒ the compiled defaults. + scripting: resolver.GetService()), DriverHostActorName); registry.Register(driverHost); diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptingOptionsTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptingOptionsTests.cs new file mode 100644 index 00000000..c702854a --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptingOptionsTests.cs @@ -0,0 +1,109 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; + +/// +/// — the bind-time clamp of the Scripting section's numeric +/// knobs. An absurd value must be pulled into a sane range at bind time rather than surfacing later as an +/// inside a script run (timeout * StuckOrphanQuarantineFactor). +/// +[Trait("Category", "Unit")] +public sealed class ScriptingOptionsTests +{ + [Fact] + public void Defaults_pass_through_unchanged_with_no_adjustments() + { + var sanitized = new ScriptingOptions().Sanitized(out var adjustments); + + adjustments.ShouldBeEmpty(); + sanitized.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.DefaultMaxAllocBytesPerRun); + sanitized.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts); + sanitized.StuckOrphanQuarantineFactor.ShouldBe(ScriptingOptions.DefaultStuckOrphanQuarantineFactor); + } + + [Theory] + [InlineData(double.MaxValue, ScriptingOptions.MaxStuckOrphanQuarantineFactor)] + [InlineData(double.PositiveInfinity, ScriptingOptions.MaxStuckOrphanQuarantineFactor)] + [InlineData(1e300, ScriptingOptions.MaxStuckOrphanQuarantineFactor)] + [InlineData(double.NaN, ScriptingOptions.DefaultStuckOrphanQuarantineFactor)] + [InlineData(-5, 0)] + [InlineData(double.NegativeInfinity, 0)] + [InlineData(0, 0)] + [InlineData(2.5, 2.5)] + public void StuckOrphanQuarantineFactor_is_clamped(double configured, double expected) + { + var sanitized = new ScriptingOptions { StuckOrphanQuarantineFactor = configured }.Sanitized(out var adjustments); + + sanitized.StuckOrphanQuarantineFactor.ShouldBe(expected); + // Negative already means "disabled" and is documented as such; normalising it to 0 is not worth a warning. + if (!double.IsNaN(configured) && configured >= 0 && configured <= ScriptingOptions.MaxStuckOrphanQuarantineFactor) + adjustments.ShouldBeEmpty(); + } + + [Theory] + [InlineData(0, 1)] + [InlineData(-3, 1)] + [InlineData(int.MaxValue, ScriptingOptions.MaxQuarantineAfterConsecutiveTimeouts)] + [InlineData(5, 5)] + public void QuarantineAfterConsecutiveTimeouts_is_clamped(int configured, int expected) => + new ScriptingOptions { QuarantineAfterConsecutiveTimeouts = configured }.Sanitized(out _) + .QuarantineAfterConsecutiveTimeouts.ShouldBe(expected); + + [Theory] + [InlineData(-1L, 0L)] + [InlineData(0L, 0L)] + [InlineData(1024L, ScriptingOptions.MinMaxAllocBytesPerRun)] + [InlineData(long.MaxValue, long.MaxValue)] + public void MaxAllocBytesPerRun_is_clamped(long configured, long expected) => + new ScriptingOptions { MaxAllocBytesPerRun = configured }.Sanitized(out _) + .MaxAllocBytesPerRun.ShouldBe(expected); + + [Fact] + public void An_out_of_range_value_is_reported_as_an_adjustment_naming_the_key() + { + _ = new ScriptingOptions { StuckOrphanQuarantineFactor = 1e300 }.Sanitized(out var adjustments); + + adjustments.ShouldHaveSingleItem().ShouldContain("Scripting:StuckOrphanQuarantineFactor"); + } + + [Fact] + public void Sanitized_returns_a_copy_and_leaves_the_source_untouched() + { + var source = new ScriptingOptions { StuckOrphanQuarantineFactor = 1e300 }; + + var sanitized = source.Sanitized(out _); + + sanitized.ShouldNotBeSameAs(source); + source.StuckOrphanQuarantineFactor.ShouldBe(1e300); + } + + /// + /// The guard takes options from more places than the bind (the Calculation driver's per-driver DTO, tests), + /// so it must not overflow either: an absurd factor reaching the guard directly is clamped, and a refusal + /// behind a young orphan is still a plain refusal rather than an . + /// + [Fact] + public async Task An_absurd_factor_reaching_the_guard_directly_does_not_overflow_on_a_refusal() + { + var guard = new ScriptRunGuard(new ScriptingOptions { StuckOrphanQuarantineFactor = double.MaxValue }); + const string spin = + """ + var end = System.DateTime.UtcNow.AddSeconds(3); + while (System.DateTime.UtcNow < end) { } + return 1; + """; + using var eval = ScriptEvaluator.Compile(spin); + var ct = TestContext.Current.CancellationToken; + var timeout = TimeSpan.FromMilliseconds(60); + + await Should.ThrowAsync( + () => guard.RunAsync("k", spin, eval, new FakeScriptContext(), timeout, ct)); + // The refusal path evaluates timeout * factor. Unclamped, TimeSpan * double.MaxValue throws OverflowException. + await Should.ThrowAsync( + () => guard.RunAsync("k", spin, eval, new FakeScriptContext(), timeout, ct)); + // A huge timeout times the clamped maximum factor must not overflow either. + await Should.ThrowAsync( + () => guard.RunAsync("k", spin, eval, new FakeScriptContext(), TimeSpan.FromDays(365 * 1000), ct)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationScriptingOptionsResolutionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationScriptingOptionsResolutionTests.cs new file mode 100644 index 00000000..3b187df6 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationScriptingOptionsResolutionTests.cs @@ -0,0 +1,56 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; + +namespace ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests; + +/// +/// The Calculation driver's run-guard options: the node-wide Scripting section is the base, and a key +/// authored in the driver's own config overrides only that knob. Before the section was bound, a Calculation +/// driver always ran on the compiled defaults whatever the node configured. +/// +[Trait("Category", "Unit")] +public sealed class CalculationScriptingOptionsResolutionTests +{ + private static readonly ScriptingOptions Node = new() + { + MaxAllocBytesPerRun = 8L * 1024 * 1024, + QuarantineAfterConsecutiveTimeouts = 7, + StuckOrphanQuarantineFactor = 4, + }; + + [Fact] + public void No_driver_keys_and_no_node_options_resolves_null_so_the_guard_uses_its_defaults() => + CalculationDriverFactoryExtensions.ResolveScripting(null, null, null).ShouldBeNull(); + + [Fact] + public void No_driver_keys_inherits_every_node_knob() + { + var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, null, Node).ShouldNotBeNull(); + + resolved.MaxAllocBytesPerRun.ShouldBe(Node.MaxAllocBytesPerRun); + resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(7); + resolved.StuckOrphanQuarantineFactor.ShouldBe(4); + } + + [Fact] + public void A_driver_key_overrides_only_its_own_knob_and_the_node_object_is_not_mutated() + { + var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, 2, Node).ShouldNotBeNull(); + + resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(2); + resolved.MaxAllocBytesPerRun.ShouldBe(Node.MaxAllocBytesPerRun, "the unauthored key keeps the node value"); + resolved.StuckOrphanQuarantineFactor.ShouldBe(4, "the stuck factor has no driver key; it is node-wide"); + resolved.ShouldNotBeSameAs(Node); + Node.QuarantineAfterConsecutiveTimeouts.ShouldBe(7); + } + + [Fact] + public void A_driver_key_with_no_node_options_layers_on_the_compiled_defaults() + { + var resolved = CalculationDriverFactoryExtensions.ResolveScripting(0L, null, null).ShouldNotBeNull(); + + resolved.MaxAllocBytesPerRun.ShouldBe(0); + resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts); + } +} From 517837e79894fdb6cd052aa20cfe3f3a7bf33603 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:25:12 -0400 Subject: [PATCH 22/67] feat(ablegacy): encode array writes element by element Review 07 U10 (user decision: implement array encode now). Both runtimes encoded a scalar for an array tag: an int[] threw InvalidCastException into BadTypeMismatch + Degraded, a scalar wrote element 0 natively, and libplctag's SetInt32(0, ..) re-sent the stale last-read buffer for elements 1..n-1. IAbLegacyTagRuntime gains EncodeArray; both runtimes write every element at the element width (Int/AnalogInt 2 bytes, Long/Float 4). The driver converts the written value before any runtime exists: each element is converted, not exact-matched (ScadaBridge writes int[] to Int16 nodes), a length other than the tag's element count is BadOutOfRange, an overflowing element is BadOutOfRange, a non-array or non-numeric payload is BadTypeMismatch, and a Bit array (bit-packed words; an encode would clobber neighbour bits) is BadNotSupported. None of these touch health or the wire. Adds an env-gated ab_server round trip for an N-file array write. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyArrayWriteEncoder.cs | 79 ++++++++ .../AbLegacyDriver.cs | 23 ++- .../IAbLegacyTagRuntime.cs | 14 ++ .../LibplctagLegacyTagRuntime.cs | 30 +++ .../NativeAbLegacyTagRuntime.cs | 26 +++ .../AbLegacyArrayWriteIntegrationTests.cs | 70 +++++++ .../AbLegacyArrayWriteTests.cs | 183 ++++++++++++++++++ .../FakeAbLegacyTag.cs | 5 + 8 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs new file mode 100644 index 00000000..a28e7169 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs @@ -0,0 +1,79 @@ +using System.Collections; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Converts an OPC UA array-write value into the element-typed CLR array a runtime's +/// takes. Pure: it runs BEFORE any runtime is created, so a refused +/// write never costs a wire round trip and never touches driver health. +/// +/// +/// Elements are converted, not exact-matched. ScadaBridge writes CLR lists such as int[] to +/// Int16 nodes, and an OPC UA client may send any numeric array, so each element goes through the same checked +/// the scalar path uses. A value that does not fit is BadOutOfRange; one that is not +/// a number at all is BadTypeMismatch. +/// The length must equal the tag's element count (BadOutOfRange otherwise): a shorter write +/// would have to fill the tail from the last read buffer, which is the stale-elements defect this replaces. +/// Bit arrays are refused (BadNotSupported): a PCCC bit array is bit-packed into words, so +/// encoding it rewrites whole words and would clobber neighbouring bits the PLC owns. String / Timer / Counter / +/// Control arrays never reach here — the mapper rejects them. +/// +internal static class AbLegacyArrayWriteEncoder +{ + /// Converts to a typed array of exactly elements. + /// The tag's element data type. + /// The value the client wrote. + /// The tag's element count. + /// The converted array when the result is Good. + /// , or the Bad status the write is answered with. + public static uint TryConvert(AbLegacyDataType type, object? value, int count, out Array? typed) + { + typed = null; + if (type is AbLegacyDataType.Bit) return AbLegacyStatusMapper.BadNotSupported; + if (type is not (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float)) + return AbLegacyStatusMapper.BadNotSupported; + if (value is null or string || value is not IEnumerable items) return AbLegacyStatusMapper.BadTypeMismatch; + + var elements = items.Cast().ToList(); + if (elements.Count != count) return AbLegacyStatusMapper.BadOutOfRange; + try + { + switch (type) + { + case AbLegacyDataType.Long: + { + var a = new int[count]; + for (var i = 0; i < count; i++) a[i] = Convert.ToInt32(Element(elements[i])); + typed = a; + break; + } + case AbLegacyDataType.Float: + { + var a = new float[count]; + for (var i = 0; i < count; i++) a[i] = Convert.ToSingle(Element(elements[i])); + typed = a; + break; + } + default: + { + var a = new short[count]; + for (var i = 0; i < count; i++) a[i] = Convert.ToInt16(Element(elements[i])); + typed = a; + break; + } + } + return AbLegacyStatusMapper.Good; + } + catch (OverflowException) { return AbLegacyStatusMapper.BadOutOfRange; } + catch (Exception ex) when (ex is FormatException or InvalidCastException) { return AbLegacyStatusMapper.BadTypeMismatch; } + } + + // A string element is not a number: Convert.ToInt16("12") would PARSE it, which no OPC UA numeric array carries. + private static object Element(object? e) => e switch + { + null => throw new InvalidCastException("null array element"), + string => throw new InvalidCastException("string array element"), + bool => throw new InvalidCastException("bool array element"), + _ => e, + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 9adfd47c..97754d94 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -612,7 +612,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover // READ, and against a device lacking that file the read failure surfaced as a comms fault — // BadCommunicationError, evicted handle, Degraded health — instead of the refusal. // Ahead of the bit-RMW branch too, which would otherwise read the parent word first. - if (parsed is not null && _tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is { } refusal) + if (_tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is { } refusal) { WarnRefusalOnce(device, def, "write", refusal); results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); @@ -627,7 +627,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover // sub-element combined with a /N bit index, so their BitIndex is always null. NOTE: an Input-image (I) write is sent // to the PLC like any other write — the device drives the input image from physical inputs and // may reject it; we surface that real PCCC status rather than pre-rejecting at the driver. - if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit) + // Array writes (review 07 U10): convert and length-check BEFORE any runtime exists, then encode every + // element. A bit / sub-element array has no flat encoding. + Array? arrayValue = null; + if (IsArrayTag(def)) + { + var conversion = parsed.BitIndex is not null || parsed.SubElement is not null + ? AbLegacyStatusMapper.BadNotSupported + : AbLegacyArrayWriteEncoder.TryConvert(def.DataType, w.Value, EffectiveArrayLength(def), out arrayValue); + if (conversion != AbLegacyStatusMapper.Good) + { + if (conversion == AbLegacyStatusMapper.BadNotSupported) + WarnRefusalOnce(device, def, "write", $"{def.DataType} array writes are not supported."); + results[i] = new WriteResult(conversion); + continue; + } + } + else if (def.DataType == AbLegacyDataType.Bit && parsed.BitIndex is int bit) { results[i] = new WriteResult( await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false)); @@ -643,7 +659,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover await opLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value); + if (arrayValue is not null) runtime.EncodeArray(def.DataType, arrayValue); + else runtime.EncodeValue(def.DataType, parsed.BitIndex, w.Value); await runtime.WriteAsync(cancellationToken).ConfigureAwait(false); status = runtime.GetStatus(); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs index 502f6038..e41152a1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs @@ -50,6 +50,20 @@ public interface IAbLegacyTagRuntime : IDisposable /// Optional bit index for bit-level access. /// The value to encode. void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value); + + /// + /// Encodes EVERY element of a multi-element tag for the next , at the element width + /// of . is already element-typed by the driver + /// (short[] for Int/AnalogInt, int[] for Long, float[] for Float) and its length must + /// equal the runtime's element count (), so no element of + /// the write comes from a stale read buffer. Bit and structured element arrays are not encodable. + /// + /// The PCCC element data type. + /// The typed elements, exactly element-count long. + /// The length differs from the element count, or the array's element type + /// does not match . + /// has no flat array encoding. + void EncodeArray(AbLegacyDataType type, Array values); } public interface IAbLegacyTagFactory diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs index 751e9701..3fd98a76 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs @@ -15,6 +15,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime { private readonly Tag _tag; + /// The element count the tag was created with (1 for a scalar). + private readonly int _count; + /// Why a write to this tag is refused before the wire (), else null. private readonly string? _writeRefusal; @@ -32,6 +35,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p) { _tag = BuildTag(p); + _count = Math.Max(1, p.ElementCount); _writeRefusal = WriteRefusal(p); } @@ -244,6 +248,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime } } + /// + public void EncodeArray(AbLegacyDataType type, Array values) + { + ArgumentNullException.ThrowIfNull(values); + if (values.Length != _count) + throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values)); + // Same offsets DecodeArray reads. EVERY element is set, so the buffer libplctag sends holds nothing from the + // last read (a scalar Set*(0, ...) used to re-send elements 1..n-1 as they were last read). + switch (type, values) + { + case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s): + for (var i = 0; i < s.Length; i++) _tag.SetInt16(i * 2, s[i]); + break; + case (AbLegacyDataType.Long, int[] l): + for (var i = 0; i < l.Length; i++) _tag.SetInt32(i * 4, l[i]); + break; + case (AbLegacyDataType.Float, float[] f): + for (var i = 0; i < f.Length; i++) _tag.SetFloat32(i * 4, f[i]); + break; + case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _): + throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values)); + default: + throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding."); + } + } + /// Disposes the tag and releases its resources. public void Dispose() => _tag.Dispose(); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs index 9a20acc0..8c372e5d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs @@ -448,6 +448,32 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime } } + /// + public void EncodeArray(AbLegacyDataType type, Array values) + { + ArgumentNullException.ThrowIfNull(values); + if (values.Length != _count) + throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values)); + // Same layout as DecodeArray: element i at i * elementSize. Every element is overwritten, so nothing the + // write sends comes from the last read. + switch (type, values) + { + case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s): + for (var i = 0; i < s.Length; i++) BinaryPrimitives.WriteInt16LittleEndian(SliceW(i * 2, 2), s[i]); + break; + case (AbLegacyDataType.Long, int[] l): + for (var i = 0; i < l.Length; i++) BinaryPrimitives.WriteInt32LittleEndian(SliceW(i * 4, 4), l[i]); + break; + case (AbLegacyDataType.Float, float[] f): + for (var i = 0; i < f.Length; i++) BinaryPrimitives.WriteSingleLittleEndian(SliceW(i * 4, 4), f[i]); + break; + case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _): + throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values)); + default: + throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding."); + } + } + /// /// Releases the lease on the shared device connection (the last release closes it) without waiting: /// closing does I/O (Forward Close / UnRegisterSession) and the driver disposes under a per-tag lock. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs new file mode 100644 index 00000000..0b479e73 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs @@ -0,0 +1,70 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; + +/// +/// Review 07 U10: an N-file array write (N7:2, 4 elements) round-trips every element against +/// ab_server on both transports, and leaves the words either side untouched. Simulator-only (gated by +/// ); the real-controller proof is a hardware userGate. +/// +[Collection(AbLegacyServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Simulator", "ab_server-PCCC")] +public sealed class AbLegacyArrayWriteIntegrationTests(AbLegacyServerFixture sim) +{ + /// Seeds the neighbours, writes an int[] to a 4-element Int array, reads it all back. + /// The simulator profile. + /// The transport stack the driver runs on. + [AbLegacyTheory] + [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))] + public async Task N_file_array_write_round_trips_every_element(AbLegacyServerProfile profile, AbLegacyTransport transport) + { + if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip); + IntegrationWriteGate.SkipUnlessWritesAllowed(); + + var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}"; + await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)], + RawTags = AbLegacyRawTags.Entries( + new AbLegacyTagDefinition("Before", deviceUri, "N7:1", AbLegacyDataType.Int), + new AbLegacyTagDefinition("Arr", deviceUri, "N7:2", AbLegacyDataType.Int, ArrayLength: 4), + new AbLegacyTagDefinition("After", deviceUri, "N7:6", AbLegacyDataType.Int)), + Timeout = TimeSpan.FromSeconds(5), + Probe = new AbLegacyProbeOptions { Enabled = false }, + Transport = transport, + }, driverInstanceId: $"ablegacy-arraywrite-{profile.Family}-{transport}"); + var ct = TestContext.Current.CancellationToken; + await drv.InitializeAsync("{}", ct); + + if (profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native) + { + // Native PLC-5 writes are refused before the wire (no capture of the PLC-5 write frames exists). + (await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], ct)).Single() + .StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); + return; + } + + (await drv.WriteAsync( + [new WriteRequest("Before", (short)111), new WriteRequest("After", (short)666)], ct)) + .ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good); + + // ScadaBridge shape: a CLR int[] to an Int16 array node. + (await drv.WriteAsync([new WriteRequest("Arr", new[] { 10, -20, 30, 32767 })], ct)).Single() + .StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + + var back = await drv.ReadAsync(["Before", "Arr", "After"], ct); + back.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); + back[1].Value.ShouldBe(new short[] { 10, -20, 30, 32767 }); + Convert.ToInt32(back[0].Value).ShouldBe(111); // neighbours untouched + Convert.ToInt32(back[2].Value).ShouldBe(666); + + // A length mismatch is refused before the wire and changes nothing. + (await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], ct)).Single() + .StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange); + (await drv.ReadAsync(["Arr"], ct)).Single().Value.ShouldBe(new short[] { 10, -20, 30, 32767 }); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs new file mode 100644 index 00000000..0ef846a0 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs @@ -0,0 +1,183 @@ +using System.Buffers.Binary; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// Review 07 U10 (user decision "implement array encode now"): an array write used to encode a SCALAR — an +/// int[] threw into BadTypeMismatch + Degraded, and a scalar to an +/// array tag wrote element 0 (native) or re-sent the stale last-read buffer (libplctag). The driver now encodes +/// exactly count elements at the element width, converting each element (ScadaBridge writes CLR lists +/// such as int[] to Int16 nodes), and refuses a length mismatch with BadOutOfRange. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyArrayWriteTests +{ + private const string Host = "ab://10.0.0.5/1,0"; + + private sealed class RecordingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p) + { + public Array? Encoded { get; private set; } + public AbLegacyDataType? EncodedType { get; private set; } + + public override void EncodeArray(AbLegacyDataType type, Array values) + { + EncodedType = type; + Encoded = values; + } + } + + private static async Task<(AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory)> NewAsync( + AbLegacyDataType type, string address, int length) + { + var factory = new FakeAbLegacyTagFactory { Customise = p => new RecordingTag(p) }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host)], + RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("Arr", Host, address, type, ArrayLength: length)), + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-u10", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + return (drv, factory); + } + + public static TheoryData IntPayloads => ["int[]", "short[]", "List", "object[]"]; + + private static object Payload(string kind) => kind switch + { + "int[]" => new[] { 1, -2, 3 }, // ScadaBridge: CLR int[] to an Int16 node + "short[]" => new short[] { 1, -2, 3 }, + "List" => new List { 1, -2, 3 }, + _ => new object[] { 1L, (short)-2, 3.0 }, + }; + + [Theory] + [MemberData(nameof(IntPayloads))] + public async Task Int_array_write_converts_each_element_and_encodes_count_elements(string kind) + { + var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3); + + var r = await drv.WriteAsync([new WriteRequest("Arr", Payload(kind))], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var tag = (RecordingTag)f.Tags["N7:0"]; + tag.CreationParams.ElementCount.ShouldBe(3); + tag.EncodedType.ShouldBe(AbLegacyDataType.Int); + tag.Encoded.ShouldBe(new short[] { 1, -2, 3 }); + tag.WriteCount.ShouldBe(1); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + [Fact] + public async Task Float_and_Long_arrays_encode_at_their_element_width() + { + var (fd, ff) = await NewAsync(AbLegacyDataType.Float, "F8:0", 2); + (await fd.WriteAsync([new WriteRequest("Arr", new[] { 1.5, 2 })], CancellationToken.None))[0] + .StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + ((RecordingTag)ff.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1.5f, 2f }); + + var (ld, lf) = await NewAsync(AbLegacyDataType.Long, "L9:0", 2); + (await ld.WriteAsync([new WriteRequest("Arr", new short[] { 7, -8 })], CancellationToken.None))[0] + .StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + ((RecordingTag)lf.Tags["L9:0"]).Encoded.ShouldBe(new[] { 7, -8 }); + } + + [Theory] + [InlineData(2)] + [InlineData(4)] + public async Task Length_mismatch_is_BadOutOfRange_and_nothing_is_sent(int supplied) + { + var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3); + + var r = await drv.WriteAsync([new WriteRequest("Arr", new int[supplied])], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange); + f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + [Fact] + public async Task Element_overflow_is_BadOutOfRange_and_nothing_is_sent() + { + var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2); + + var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange); + f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0); + } + + [Theory] + [InlineData(5)] + [InlineData("1,2")] + public async Task Non_array_payload_or_unconvertible_element_is_BadTypeMismatch(object payload) + { + var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2); + + var r = await drv.WriteAsync([new WriteRequest("Arr", payload)], CancellationToken.None); + var e = await drv.WriteAsync([new WriteRequest("Arr", new object[] { 1, "x" })], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch); + e[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch); + f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + [Fact] + public async Task Bit_array_write_is_refused_BadNotSupported_before_any_runtime() + { + // Bit arrays are bit-packed in words: an encode of N bits would rewrite whole words from a stale read. + var (drv, f) = await NewAsync(AbLegacyDataType.Bit, "B3:0", 16); + + var r = await drv.WriteAsync([new WriteRequest("Arr", new bool[16])], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); + f.Tags.ShouldBeEmpty(); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + [Fact] + public async Task Native_array_encode_writes_every_element_at_its_offset() + { + var channel = new FakeCipChannel(); + await using var scheduler = new RequestScheduler(channel, allowPacking: false); + var rt = new NativeAbLegacyTagRuntime( + new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2), 3), + scheduler, 0xF33D, 0x21504345); + channel.Respond = req => Task.FromResult(NativeEcho(req)); + + rt.EncodeArray(AbLegacyDataType.Int, new short[] { 10, -1, 300 }); + await rt.WriteAsync(CancellationToken.None); + + var data = channel.Sent.Single()[23..]; + data.Length.ShouldBe(6); + BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(0)).ShouldBe((short)10); + BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(2)).ShouldBe((short)-1); + BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(4)).ShouldBe((short)300); + rt.Dispose(); + } + + [Fact] + public void Native_array_encode_refuses_a_length_other_than_the_runtime_count() + { + var channel = new FakeCipChannel(); + var scheduler = new RequestScheduler(channel, allowPacking: false); + var rt = new NativeAbLegacyTagRuntime( + new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "F8:0", TimeSpan.FromSeconds(2), 3), + scheduler, 0xF33D, 0x21504345); + + Should.Throw(() => rt.EncodeArray(AbLegacyDataType.Float, new float[2])); + rt.Dispose(); + } + + // Execute PCCC reply echoing the request's requestor id and TNS with STS 0 and no data. + private static byte[] NativeEcho(byte[] req) + { + var body = new List(req[6..13]) { 0x4F, 0x00, req[15], req[16] }; + return FakeCipChannel.Reply(0x4B, 0x00, [.. body]); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs index ba0a4618..dc3a868c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs @@ -109,6 +109,11 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime /// The value to encode. public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value; + /// Encodes a whole typed array (array writes); stored as . + /// The AbLegacy element type. + /// The typed elements. + public virtual void EncodeArray(AbLegacyDataType type, Array values) => ArrayValue = values; + /// Disposes the tag. public virtual void Dispose() => Disposed = true; } From 82a485e42ee9b3c99872080c16452f239ceb8ef1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:27:34 -0400 Subject: [PATCH 23/67] fix(abcip): a BOOL-array element reads its own bit, not "low byte != 0" U11: a scalar BOOL authored over Bits[n] requested element n and decoded "any of bits 0-7 of the reply set", so Bits[n] read Good with the subscript ignored. A Logix BOOL array is stored as DWORDs (0xD3) and its element segment addresses them, so the native runtime now requests DWORD n / 32 and decodes bit n % 32 when the controller answers 0xD3 (a plain BOOL reply still decodes its byte). libplctag exposes no type code and so cannot say which DWORD it read: a 4-byte reply for such a tag is refused BadNotSupported (warned once) instead of publishing a wrong value as Good. Real-controller proof is a hardware gate. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipDriver.cs | 1 + .../LibplctagTagRuntime.cs | 16 ++++++- .../NativeAbCipTagRuntime.cs | 42 ++++++++++++++++++- .../NativeAbCipTagRuntimeTests.cs | 31 ++++++++++++++ 4 files changed, 87 insertions(+), 3 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index f675234e..0a7eb3b0 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -1150,6 +1150,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // A deterministic local refusal — not a comms fault: BadNotSupported, keep the handle, no // health signal. results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now); + WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ate.Message); return ReadOutcome.None; } catch (AbCipTagConfigurationException ace) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs index e2ff94c9..2e6cd7fc 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs @@ -29,8 +29,19 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime public LibplctagTagRuntime(AbCipTagCreateParams p) { _tag = BuildTag(p); + _boolArrayElement = !p.IsArray && p.AuthoredType == AbCipDataType.Bool + && NativeAbCipTagRuntime.TrySplitBoolArrayElement(p.TagName, out _, out _); } + /// + /// A scalar BOOL authored over a BOOL-array element (Bits[n]). libplctag reads it as whatever the + /// controller returns for that element segment — a packed DWORD — and exposes no type code, so which DWORD + /// came back (and so which bit is element n) cannot be checked here. Review 07 U11: such a read used to decode + /// "low byte != 0" as Good; it is refused (BadNotSupported) when a DWORD came back. The native transport + /// (the default) addresses the right DWORD and decodes the bit. + /// + private readonly bool _boolArrayElement; + /// Builds the (uninitialized) libplctag tag for . /// The tag creation parameters. /// The new, uninitialised libplctag tag. @@ -120,7 +131,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime { AbCipDataType.Bool => bitIndex is int bit ? _tag.GetBit(bit) - : _tag.GetInt8(offset) != 0, + : _boolArrayElement && offset == 0 && _tag.GetSize() == 4 + ? throw new AbTransportException(AbStatus.Unsupported, + $"'{_tag.Name}' is a BOOL-array element read as a packed DWORD; libplctag cannot say which DWORD it is — use the native transport.") + : _tag.GetInt8(offset) != 0, AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(offset), AbCipDataType.USInt => (int)_tag.GetUInt8(offset), AbCipDataType.Int => (int)_tag.GetInt16(offset), diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs index 8bf65cca..917f205b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs @@ -41,6 +41,10 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime private int? _implicitBit; private byte[]? _path; + // U11: a scalar BOOL authored over a BOOL-array element "Bits[n]" — the bit (n % 32) of the DWORD the request + // addresses (n / 32). Null for every other tag. + private int? _boolElementBit; + private ConnectionLease? _lease; private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's private LogixTagClient? _client; @@ -115,7 +119,14 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime try { split = CipPath.SplitBitSelector(_p.TagName); - path = CipPath.EncodeSymbolic(split.Name); + var name = split.Name; + if (split.Bit is null && !_p.IsArray && _p.AuthoredType == AbCipDataType.Bool + && TrySplitBoolArrayElement(name, out var dwordName, out var bit)) + { + name = dwordName; + _boolElementBit = bit; + } + path = CipPath.EncodeSymbolic(name); } catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException) { @@ -275,7 +286,9 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime return type switch { - AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : Span(offset, 1)[0] != 0, + AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) + : offset == 0 && _boolElementBit is int element && _typeCode == LogixTypeCode.BoolArray ? GetBit(element) + : Span(offset, 1)[0] != 0, AbCipDataType.SInt => (int)(sbyte)Span(offset, 1)[0], AbCipDataType.USInt => (int)Span(offset, 1)[0], AbCipDataType.Int => (int)BinaryPrimitives.ReadInt16LittleEndian(Span(offset, 2)), @@ -409,6 +422,31 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime catch (Exception) { /* ForTest only; Dispose must not throw */ } } + /// + /// Splits a BOOL-array element reference Name[n] (one non-negative subscript, last segment) into the + /// DWORD it lives in (Name[n / 32]) and its bit within that DWORD (n % 32). A Logix BOOL array + /// is stored as DWORDs (type 0xD3) and its element segment addresses those DWORDs. + /// + /// The tag name, bit selector already split off. + /// The name addressing the containing DWORD. + /// The element's bit within that DWORD. + /// when ends in a single subscript. + internal static bool TrySplitBoolArrayElement(string name, out string dwordName, out int bit) + { + dwordName = name; + bit = 0; + if (!name.EndsWith(']')) return false; + var open = name.LastIndexOf('['); + if (open <= 0) return false; + var inner = name[(open + 1)..^1]; + if (inner.Length == 0 || !inner.All(char.IsAsciiDigit) + || !int.TryParse(inner, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var n)) + return false; + dwordName = $"{name[..open]}[{n / 32}]"; + bit = n % 32; + return true; + } + private byte[] Path() => _path ?? throw new InvalidOperationException("InitializeAsync has not been called."); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs index 35f42bd3..9d013613 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs @@ -112,6 +112,37 @@ public sealed class NativeAbCipTagRuntimeTests rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true); } + /// + /// Review 07 U11: a scalar BOOL authored over a BOOL-array element Bits[n] reads DWORD n / 32 + /// (a Logix BOOL array is stored as DWORDs and its CIP element index addresses them) and decodes bit + /// n % 32 of it — it used to request element n and report "any of bits 0-7 of the reply set". + /// + /// The authored BOOL-array element. + /// The DWORD the request must address. + /// The DWORD the controller returns (little-endian hex). + /// The element's value. + [Theory] + [InlineData(37, 1, "20000000", true)] // bit 5 of DWORD 1 + [InlineData(36, 1, "20000000", false)] // bit 4 of the same DWORD — the old "low byte != 0" read true + [InlineData(5, 0, "20000000", true)] + [InlineData(1, 0, "01000000", false)] // bit 1, not bit 0 + [InlineData(31, 0, "00000080", true)] // top bit of DWORD 0 — outside the low byte entirely + public async Task A_BOOL_array_element_decodes_its_bit_of_its_DWORD(int n, int dword, string reply, bool expected) + { + var ch = Replying("D300", reply); + using var rt = await ReadAsync(ch, Params($"Bits[{n}]", AbCipDataType.Bool)); + rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(expected); + ch.Sent[0].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "28" + dword.ToString("X2") + "0100" + "00000000")); + } + + /// Positive control: a BOOL answered as a plain BOOL (0xC1) still decodes its byte. + [Fact] + public async Task A_BOOL_element_answered_as_BOOL_decodes_the_byte() + { + using var rt = await ReadAsync(Replying("C100", "01"), Params("Flag", AbCipDataType.Bool)); + rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true); + } + [Fact] public async Task Real_returned_for_a_DInt_authored_tag_is_TypeMismatch() { From 7cc841531041a557156b5083d80752b0279e7ecd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:30:11 -0400 Subject: [PATCH 24/67] feat(abcip): array writes encode count elements at the element width U10 (user decision: implement array encode now): config, the AdminUI and discovery all advertised array tags Operate-writable, but both runtimes encoded a scalar - an int[] threw InvalidCastException (BadTypeMismatch AND Degraded), a scalar wrote element 0 natively, and libplctag re-sent its whole last-read buffer with element 0 patched (stale elements 1..n-1). - AbCipArrayCodec encodes count elements little-endian at the element width, CONVERTING each element (ScadaBridge writes int[] to INT arrays; overflow is BadOutOfRange, unconvertible BadTypeMismatch, both before anything is sent). - The driver checks the shape before any runtime exists: not a list -> BadTypeMismatch, length != elementCount -> BadOutOfRange, BOOL/STRING/ structure element types -> BadNotSupported; and, before sending, a controller buffer that is not width x count -> BadTypeMismatch. Refusals warn once and write no health. - Native: ONE Write Tag of count elements with the controller's type code (the client fragments a large one); libplctag: the codec's bytes copied into the elem_count-sized buffer, refused TooSmall if it does not fit. - IAbCipTagRuntime.EncodeArray (default: NotSupportedException). Real-controller proof is a hardware userGate (see the lane report recipe). Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipArrayCodec.cs | 80 ++++++++ .../AbCipDriver.cs | 63 ++++++- .../IAbCipTagRuntime.cs | 11 ++ .../LibplctagTagRuntime.cs | 17 ++ .../NativeAbCipTagRuntime.cs | 22 ++- .../AbCipArrayWriteTests.cs | 178 ++++++++++++++++++ .../FakeAbCipTag.cs | 19 ++ 7 files changed, 383 insertions(+), 7 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayCodec.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayCodec.cs new file mode 100644 index 00000000..bb6455ab --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipArrayCodec.cs @@ -0,0 +1,80 @@ +using System.Buffers.Binary; +using System.Collections; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Encodes a whole-array write (review 07 U10): count elements at the element type's wire width, +/// little-endian, exactly the layout a Logix atomic array holds and the read path decodes. Shared by both +/// runtimes so the bytes on the wire are transport-independent. +/// +/// +/// Element values CONVERT, they are not exact-matched. ScadaBridge (and any OPC UA client that +/// round-trips what it read) writes CLR lists such as int[] to an INT array — the read path itself +/// publishes INT/SINT elements as int — so each element goes through the same the +/// scalar path uses. A value that does not fit is an (BadOutOfRange) and one that +/// cannot convert a / (BadTypeMismatch), all +/// thrown before any byte is sent. +/// BOOL arrays (bit-packed DWORDs — a byte-per-element encode would clobber neighbour bits), STRING arrays +/// (structures) and structure arrays are not encodable here; the driver refuses them BadNotSupported +/// before a runtime exists. +/// +internal static class AbCipArrayCodec +{ + /// Wire width of one element of an encodable array type, or null when the type is not encodable. + /// The element type. + /// The element width in bytes, or null. + public static int? ElementWidth(AbCipDataType type) => type switch + { + AbCipDataType.SInt or AbCipDataType.USInt => 1, + AbCipDataType.Int or AbCipDataType.UInt => 2, + AbCipDataType.DInt or AbCipDataType.UDInt or AbCipDataType.Real or AbCipDataType.Dt => 4, + AbCipDataType.LInt or AbCipDataType.ULInt or AbCipDataType.LReal => 8, + _ => null, + }; + + /// + /// The elements of an array write value, or null when the value is not a list (a scalar, a string, null). + /// Any non-string is accepted — int[], short[], List<object> … + /// + /// The client's write value. + /// The elements, or null. + public static object?[]? Elements(object? value) => value switch + { + null or string => null, + IEnumerable list => [.. list.Cast()], + _ => null, + }; + + /// Encodes as elements. + /// The element type (must have an ). + /// The element values; their count is the element count. + /// The little-endian wire bytes, values.Count × ElementWidth(type) long. + /// The type is not encodable. + public static byte[] Encode(AbCipDataType type, IReadOnlyList values) + { + var width = ElementWidth(type) + ?? throw new NotSupportedException($"Array writes of {type} elements are not supported."); + var buffer = new byte[values.Count * width]; + for (var i = 0; i < values.Count; i++) + { + var v = values[i]; + var span = buffer.AsSpan(i * width, width); + switch (type) + { + case AbCipDataType.SInt: span[0] = unchecked((byte)Convert.ToSByte(v)); break; + case AbCipDataType.USInt: span[0] = Convert.ToByte(v); break; + case AbCipDataType.Int: BinaryPrimitives.WriteInt16LittleEndian(span, Convert.ToInt16(v)); break; + case AbCipDataType.UInt: BinaryPrimitives.WriteUInt16LittleEndian(span, Convert.ToUInt16(v)); break; + case AbCipDataType.DInt: + case AbCipDataType.Dt: BinaryPrimitives.WriteInt32LittleEndian(span, Convert.ToInt32(v)); break; + case AbCipDataType.UDInt: BinaryPrimitives.WriteUInt32LittleEndian(span, Convert.ToUInt32(v)); break; + case AbCipDataType.LInt: BinaryPrimitives.WriteInt64LittleEndian(span, Convert.ToInt64(v)); break; + case AbCipDataType.ULInt: BinaryPrimitives.WriteUInt64LittleEndian(span, Convert.ToUInt64(v)); break; + case AbCipDataType.Real: BinaryPrimitives.WriteSingleLittleEndian(span, Convert.ToSingle(v)); break; + case AbCipDataType.LReal: BinaryPrimitives.WriteDoubleLittleEndian(span, Convert.ToDouble(v)); break; + } + } + return buffer; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 0a7eb3b0..43afe57f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -1376,6 +1376,43 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } string? rmwParent = null; + // U10: an array tag writes the WHOLE array. Shape is checked before any runtime exists, so a refused + // write creates, reads and sends nothing; a refusal is not a fault — no health change. + object?[]? arrayElements = null; + if (IsArrayTag(def)) + { + var count = Math.Max(1, def.ElementCount); + uint? shapeRefusal = null; + string reason = ""; + if (AbCipArrayCodec.ElementWidth(def.DataType) is null) + { + shapeRefusal = AbCipStatusMapper.BadNotSupported; + reason = $"{def.DataType} arrays cannot be written element-wise (BOOL arrays are bit-packed DWORDs, STRING/structure elements are structures)"; + } + else if (AbCipArrayCodec.Elements(w.Value) is not { } elements) + { + shapeRefusal = AbCipStatusMapper.BadTypeMismatch; + reason = $"the value is not an array ({w.Value?.GetType().Name ?? "null"})"; + } + else if (elements.Length != count) + { + shapeRefusal = AbCipStatusMapper.BadOutOfRange; + reason = $"{elements.Length} element(s) written to a {count}-element array"; + } + else + { + arrayElements = elements; + } + if (shapeRefusal is { } refusedShape) + { + results[i] = new WriteResult(refusedShape); + WarnWriteRefusedOnce(def.Name, + "AbCip array write to {Tag} on device {Device} refused: {Reason} (logged once per tag)", + w.FullReference, def.DeviceHostAddress, reason); + continue; + } + } + try { var parsedPath = AbCipTagPath.TryParse(def.TagPath); @@ -1413,10 +1450,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // BOOL-array DWORD wrote all 32 bits. Discovered UDT members are where a mis-authored type // is most likely (browse-commit fills dataType from the template, an operator may retype). size = runtime.GetSize(); - refusal = WriteWidthRefusal(def, parsedPath?.BitIndex, size, runtimeTypesBool: runtime is NativeAbCipTagRuntime); + refusal = arrayElements is not null + ? ArrayWriteWidthRefusal(def, size) + : WriteWidthRefusal(def, parsedPath?.BitIndex, size, runtimeTypesBool: runtime is NativeAbCipTagRuntime); if (refusal is null) { - runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value); + if (arrayElements is not null) runtime.EncodeArray(def.DataType, arrayElements); + else runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value); await runtime.WriteAsync(cancellationToken).ConfigureAwait(false); status = runtime.GetStatus(); } @@ -1436,7 +1476,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, results[i] = new WriteResult(refused); WarnTypeMismatchOnce(def.Name, "AbCip write to {Tag} on device {Device} refused before sending: the controller holds {Size} byte(s) there but the authored {DataType} encodes {Width} (logged once per tag)", - w.FullReference, def.DeviceHostAddress, size, def.DataType, ExpectedScalarWriteWidth(def.DataType)); + w.FullReference, def.DeviceHostAddress, size, def.DataType, + arrayElements is not null + ? AbCipArrayCodec.ElementWidth(def.DataType) * arrayElements.Length + : ExpectedScalarWriteWidth(def.DataType)); } else if (status == (int)AbStatus.TypeMismatch) { @@ -2055,6 +2098,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, : AbCipStatusMapper.BadTypeMismatch; } + /// + /// The refusal for a whole-array write whose authored shape does not fit what the controller holds: the last + /// read returned bytes, which must be the element width × the element count. Unknown + /// size (<= 0) is not judged here (the native runtime re-checks against the controller's type code). + /// + /// The array tag being written. + /// The runtime's read-buffer size in bytes. + /// BadTypeMismatch, or null to write. + internal static uint? ArrayWriteWidthRefusal(AbCipTagDefinition def, int size) + { + if (size <= 0 || AbCipArrayCodec.ElementWidth(def.DataType) is not { } width) return null; + return size == width * Math.Max(1, def.ElementCount) ? null : AbCipStatusMapper.BadTypeMismatch; + } + /// /// Recursively fan a controller-discovered UDT shape out into Variable nodes — one per /// ATOMIC leaf — under the folder produced by . Mirrors the diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs index 1cf3c2e5..ad8b00a2 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs @@ -85,6 +85,17 @@ public interface IAbCipTagRuntime : IDisposable /// Bit index for BOOL-within-DINT insertion, or null. /// Value to encode. void EncodeValue(AbCipDataType type, int? bitIndex, object? value); + + /// + /// Encode a WHOLE-array write of (values.Count elements of + /// , at the element width — see ) into the local buffer. + /// Callers pair this with . A runtime that cannot write arrays throws + /// . + /// + /// The array element type. + /// The element values; already length-checked against the tag's element count. + void EncodeArray(AbCipDataType type, IReadOnlyList values) => + throw new NotSupportedException($"{GetType().Name} does not support array writes."); } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs index 2e6cd7fc..07ac008e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs @@ -235,6 +235,23 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime _ => 4, }; + /// + /// + /// The codec's bytes are copied into the buffer the last read sized (elem_count elements), so the write + /// sends exactly the new array — never a stale tail. A buffer smaller than the encoded array is refused + /// () before anything is changed. + /// + public void EncodeArray(AbCipDataType type, IReadOnlyList values) => + LibplctagStatusTranslation.WrapSync(() => + { + var bytes = AbCipArrayCodec.Encode(type, values); + var size = _tag.GetSize(); + if (size < bytes.Length) + throw new AbTransportException(AbStatus.TooSmall, + $"Array write to '{_tag.Name}': {bytes.Length} byte(s) encoded but the tag buffer holds {size}."); + for (var i = 0; i < bytes.Length; i++) _tag.SetUInt8(i, bytes[i]); + }); + /// public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => LibplctagStatusTranslation.WrapSync(() => EncodeValueCore(type, bitIndex, value)); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs index 917f205b..a77179f9 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs @@ -55,6 +55,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime private AbStatus _status = AbStatus.Pending; private byte[]? _writeBuffer; + private ushort _writeElements = 1; private string? _pendingString; private bool _disposed; @@ -233,17 +234,18 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime // Width is checked only for codes with a known width: DATE / DATE_AND_TIME (0xCD/0xCF) and // Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are outside LogixTypeCode's table and write as today. var width = LogixTypeCode.ByteWidth(type); - if (width > 0 && width != buffer.Length) + if (width > 0 && width * _writeElements != buffer.Length) { // An authored BOOL over a packed BOOL-array DWORD: writing the DWORD would clobber the // 31 sibling bools (libplctag does exactly that), so refuse it as a config error. - if (type == LogixTypeCode.BoolArray) + if (type == LogixTypeCode.BoolArray && _writeElements == 1) throw new NotSupportedException( $"Write to '{_p.TagName}': a BOOL-array element (type 0xD3 DWORD) cannot be written as a single BOOL."); throw new AbTransportException(AbStatus.TypeMismatch, - $"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) but the controller's type 0x{type:X4} is {width} byte(s) wide."); + $"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) for {_writeElements} element(s) but the controller's type 0x{type:X4} is {width} byte(s) wide."); } - await client.WriteAsync(path, type, null, 1, buffer, cancellationToken).ConfigureAwait(false); + // U10: an array write is ONE Write Tag of _writeElements elements (the client fragments it if large). + await client.WriteAsync(path, type, null, _writeElements, buffer, cancellationToken).ConfigureAwait(false); } else { @@ -327,10 +329,22 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime }; } + /// + public void EncodeArray(AbCipDataType type, IReadOnlyList values) + { + ArgumentNullException.ThrowIfNull(values); + _writeBuffer = null; + _pendingString = null; + var buffer = AbCipArrayCodec.Encode(type, values); // throws (nothing staged) before any state changes + _writeElements = (ushort)Math.Clamp(values.Count, 1, ushort.MaxValue); + _writeBuffer = buffer; + } + /// public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) { _writeBuffer = null; + _writeElements = 1; _pendingString = null; switch (type) { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs new file mode 100644 index 00000000..ea908d12 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs @@ -0,0 +1,178 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; + +/// +/// Review 07 U10: array writes. Config and the AdminUI allowed them and discovery advertised array nodes +/// Operate-writable, but both runtimes encoded a SCALAR: an int[] threw InvalidCastException (BadTypeMismatch +/// AND Degraded), a scalar wrote element 0 natively, and libplctag re-sent the whole last-read buffer with only +/// element 0 patched. The driver now encodes count elements at the element width, refuses a length mismatch +/// with BadOutOfRange, and converts element values (ScadaBridge writes int[] to INT arrays). +/// +[Trait("Category", "Unit")] +public sealed class AbCipArrayWriteTests +{ + private const string Device = "ab://10.0.0.5/1,0"; + + private static async Task<(AbCipDriver Driver, FakeAbCipTagFactory Factory)> BuildAsync( + AbCipDataType type, int count, int size = -1) + { + var factory = new FakeAbCipTagFactory { Customise = p => new FakeAbCipTag(p) { Size = size } }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", Device, "Arr", type, ElementCount: count, IsArray: true)), + Probe = new AbCipProbeOptions { Enabled = false }, + }, "abcip-array-write", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + return (drv, factory); + } + + // ---- the codec ---- + + /// Each element is converted and laid out little-endian at the element width. + [Fact] + public void The_codec_converts_elements_and_lays_them_out_at_the_element_width() + { + AbCipArrayCodec.Encode(AbCipDataType.Int, [1, -2, 300]).ShouldBe(Convert.FromHexString("0100FEFF2C01")); + AbCipArrayCodec.Encode(AbCipDataType.SInt, [(short)-1, 5L]).ShouldBe(Convert.FromHexString("FF05")); + AbCipArrayCodec.Encode(AbCipDataType.DInt, [1.0, 2]).ShouldBe(Convert.FromHexString("0100000002000000")); + AbCipArrayCodec.Encode(AbCipDataType.Real, [1, 2.5]).ShouldBe(Convert.FromHexString("0000803F00002040")); + AbCipArrayCodec.Encode(AbCipDataType.ULInt, [ulong.MaxValue]).ShouldBe(Convert.FromHexString("FFFFFFFFFFFFFFFF")); + Should.Throw(() => AbCipArrayCodec.Encode(AbCipDataType.Int, [40000])); + Should.Throw(() => AbCipArrayCodec.Encode(AbCipDataType.Bool, [true])); + } + + // ---- the driver ---- + + /// ScadaBridge's wire shape: an int[] to an INT[3] encodes 3 two-byte elements and writes once, Good. + [Fact] + public async Task An_int_array_to_an_INT_array_encodes_every_element() + { + var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 6); + + var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + var tag = factory.Tags["Arr"]; + tag.EncodedArrayType.ShouldBe(AbCipDataType.Int); + tag.EncodedArrayCount.ShouldBe(3); + tag.EncodedArrayBytes.ShouldBe(Convert.FromHexString("010002000300")); + tag.WriteCount.ShouldBe(1); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// A list of a different length is BadOutOfRange; nothing is created, encoded or written. + /// The client's element count. + [Theory] + [InlineData(2)] + [InlineData(4)] + public async Task A_length_mismatch_is_BadOutOfRange_and_nothing_is_written(int length) + { + var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3); + + var r = await drv.WriteAsync([new WriteRequest("Arr", Enumerable.Range(0, length).ToArray())], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); + factory.Tags.ShouldNotContainKey("Arr"); // refused before any runtime exists + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// A scalar (or a string) to an array tag is BadTypeMismatch — it used to write element 0 only. + [Fact] + public async Task A_scalar_to_an_array_tag_is_BadTypeMismatch() + { + var (drv, factory) = await BuildAsync(AbCipDataType.DInt, 3); + + var r = await drv.WriteAsync( + [new WriteRequest("Arr", 5), new WriteRequest("Arr", "1,2,3")], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); + r[1].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); + factory.Tags.ShouldNotContainKey("Arr"); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// An element that does not fit is BadOutOfRange with no health write and nothing sent. + [Fact] + public async Task An_element_overflow_is_BadOutOfRange_and_nothing_is_sent() + { + var (drv, factory) = await BuildAsync(AbCipDataType.Int, 2, size: 4); + + var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); + factory.Tags["Arr"].WriteCount.ShouldBe(0); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// BOOL / STRING arrays are refused BadNotSupported before any runtime exists. + /// The array element type. + [Theory] + [InlineData(AbCipDataType.Bool)] + [InlineData(AbCipDataType.String)] + public async Task Unencodable_element_types_are_BadNotSupported(AbCipDataType type) + { + var (drv, factory) = await BuildAsync(type, 2); + + var r = await drv.WriteAsync([new WriteRequest("Arr", new object[] { true, false })], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported); + factory.Tags.ShouldNotContainKey("Arr"); + } + + /// The controller holding a different shape than authored (size ≠ width × count) is BadTypeMismatch, nothing sent. + [Fact] + public async Task A_controller_shape_mismatch_is_BadTypeMismatch_and_nothing_is_sent() + { + var (drv, factory) = await BuildAsync(AbCipDataType.Int, 3, size: 12); // DINT[3] on the controller + + var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadTypeMismatch); + factory.Tags["Arr"].WriteCount.ShouldBe(0); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// + /// End to end over the REAL native runtime: an int[] to an INT[3] goes out as ONE Write Tag of type + /// 0xC3 with element count 3 and the three 2-byte elements. + /// + [Fact] + public async Task A_native_array_write_is_one_write_of_count_elements() + { + await using var target = new FakeCipTarget(); + target.ConnectedHandler = req => Task.FromResult(req[0] switch + { + 0x52 => [0xD2, 0x00, 0x00, 0x00, 0xC3, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], // INT[3] = 0,0,0 + 0x4D => [0xCD, 0x00, 0x00, 0x00], + _ => target.ServiceReply(req), + }); + var host = $"ab://127.0.0.1:{target.Port}/1,0"; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Arr", host, "Arr", AbCipDataType.Int, ElementCount: 3, IsArray: true)), + Probe = new AbCipProbeOptions { Enabled = false }, + Timeout = TimeSpan.FromSeconds(5), + }, "abcip-array-native", new NativeAbCipTagFactory(new DeviceSessionPool())); + await drv.InitializeAsync("{}", CancellationToken.None); + try + { + var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, -2, 300 })], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + var write = target.ConnectedCips.Single(c => c[0] == 0x4D); + var typeAt = 2 + write[1] * 2; + write.AsSpan(typeAt).ToArray().ShouldBe(Convert.FromHexString("C300" + "0300" + "0100FEFF2C01")); + } + finally + { + await drv.DisposeAsync(); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs index 740e0985..a95b8822 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/FakeAbCipTag.cs @@ -115,6 +115,25 @@ internal class FakeAbCipTag : IAbCipTagRuntime /// The value to encode. public virtual void EncodeValue(AbCipDataType type, int? bitIndex, object? value) => Value = value; + /// The element type of the last , if any. + public AbCipDataType? EncodedArrayType { get; private set; } + + /// The bytes the last produced (the shared codec, so conversions throw as in production). + public byte[]? EncodedArrayBytes { get; private set; } + + /// The element count of the last . + public int EncodedArrayCount { get; private set; } + + /// Encodes an array write through the production codec and records it. + /// The element type. + /// The element values. + public virtual void EncodeArray(AbCipDataType type, IReadOnlyList values) + { + EncodedArrayBytes = AbCipArrayCodec.Encode(type, values); + EncodedArrayType = type; + EncodedArrayCount = values.Count; + } + /// Marks the tag as disposed. public virtual void Dispose() => Disposed = true; } From f966efcfad1832b9816fce17f5387aeac574097f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:30:11 -0400 Subject: [PATCH 25/67] fix(opcua): replay writes deferred during an off-thread plan; cache-ref and guard fixes (Lane B fix round 1) C1 (critical): B6 computes the plan off the pinned thread while B1 made writes non-creating, so a value/status/alarm write for a node the in-flight plan adds was skipped, and change-driven drivers never republish it (BadWaitingForInitialData indefinitely; on a full rebuild every re-subscribe value landed on discarded nodes). OpcUaPublishActor now applies writes immediately AND remembers the latest per (realm, id) while a plan is in flight or queued (value+status compose; alarm state/quality coalesce to the latest, which the node's delta gate makes inert on an existing condition), replays it after each apply before answering, and drops it once the pipeline is idle. Bounded (250k keys by default; beyond it new keys are not held, logged once). I2: the data-plane-authority parse runs in its own try inside the plan task; a throw is logged and never degrades the address-space apply. I3: the SDK's DeleteMonitoredItems releases an item's component-cache reference on a Good delete; a tombstoned item's reference was already released, so the delete took one from the re-created node's entry. Released items are remembered and the orphan delete takes one back first. Event items never held a reference (EventManager + SubscribeToEvents), so the three event-item releases are gone. I4: direct tests for the HistoryUpdate override's per-request updater check and the HistoryReadEvents override's source guard (both reached by restoring the bit after a reset - the B7 race window); fixed the false remark. Minors: the MaterialiseAlarmCondition kind-swap tombstones the dropped instance; a bytes-less rebuild arriving mid-plan queues behind it (replies in order); a PlanReady/PlanFailed from a previous actor incarnation is ignored and its requester answered degraded; the tie-cluster end bound of an uncapped read fits against the backend ceiling (defence in depth - guarded by a sweep test); the lock-time benchmark stays in the default run, headroom noted. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../OtOpcUaNodeManager.cs | 49 +- .../OpcUa/OpcUaPublishActor.cs | 324 ++++++++++++- .../StaleMonitoredNodeWireTests.cs | 35 ++ .../History/HistoryUpdateTests.cs | 32 ++ .../NodeManagerHistoryReadBoundsTests.cs | 48 ++ .../NodeManagerHistoryReadEventsTests.cs | 43 +- .../NodeManagerLockTimeBenchmarkTests.cs | 5 + .../NodeManagerStaleMonitoredNodeTests.cs | 33 ++ .../OpcUaPublishActorDeferredWriteTests.cs | 438 ++++++++++++++++++ 9 files changed, 977 insertions(+), 30 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs index 6f536cb0..6b6f5970 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OtOpcUaNodeManager.cs @@ -1494,6 +1494,9 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 // same-key kind-swap ever carries wired notifiers), but this is the one asymmetric teardown // path; keep it symmetric with RemoveAlarmConditionNode / RebuildAddressSpace. UnwireAlarmNotifiers(conditionKey); + // A removal path like any other (review 01 L2): the dropped instance's items are told and its + // fan-out forgotten, or a new item on the swapped-in condition would bind to the dead one. + TombstoneMonitoredSubtree(existing, UtcNow); existing.Parent?.RemoveChild(existing); ForgetPredefinedSubtree(existing); // The materialise tail raises only the new instance's unit (no full reconcile), so give the @@ -4413,6 +4416,18 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 || current.DataChangeMonitoredItems?.Contains(dataItem) != true)) { OnMonitoredItemDeleted(context, handle, dataItem); + + // Review fix I3 — the SDK's DeleteMonitoredItems releases the item's component-cache reference + // whenever this returns Good. A tombstoned item's reference was ALREADY released by the removal, so + // that second release would take one from the RE-CREATED node's entry (a new item on the same NodeId) + // and evict it while still referenced. Take one back first so the SDK's release nets to zero — + // returning a non-Good status instead would fail the client's delete. + if (_cacheReferenceReleased.TryGetValue(dataItem, out _)) + { + _cacheReferenceReleased.Remove(dataItem); + AddNodeToComponentCache(context, handle, handle.Node); + } + return ServiceResult.Good; } } @@ -4472,11 +4487,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (monitored.EventMonitoredItems is { Count: > 0 } eventItems) { - foreach (var item in eventItems.ToArray()) - { - _eventItems.TryRemove(item, out _); - ReleaseComponentCacheReference(item); - } + // No component-cache release: an event item never took a reference (the master node manager + // creates it through the EventManager + SubscribeToEvents, not AddNodeToComponentCache), so a + // release here would take one from a DATA item on the same NodeId. + foreach (var item in eventItems.ToArray()) _eventItems.TryRemove(item, out _); } } @@ -4534,7 +4548,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 if (!tracked.Suppressed || (nodeId is not null && tracked.Handle.NodeId != nodeId)) continue; if (!_eventItems.TryRemove(item, out _)) continue; Interlocked.Decrement(ref _suppressedItems); - ReleaseComponentCacheReference(item); + // Event items hold no component-cache reference (see TombstoneMonitoredNode). } } @@ -4555,13 +4569,21 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 } } - /// Gives back the component-cache reference the SDK took for at create, so - /// the cache stops handing out the removed node for that NodeId. + /// Gives back the component-cache reference the SDK took for the DATA item at + /// create, so the cache stops handing out the removed node for that NodeId — and remembers that it did, so the + /// item's later client delete does not give it back a second time (see ). private void ReleaseComponentCacheReference(IMonitoredItem item) { - if (item.ManagerHandle is NodeHandle handle) RemoveNodeFromComponentCache(SystemContext, handle); + if (item.ManagerHandle is not NodeHandle handle) return; + RemoveNodeFromComponentCache(SystemContext, handle); + _cacheReferenceReleased.AddOrUpdate(item, ReleasedMarker); } + /// Data items whose component-cache reference a removal already gave back (review fix I3). Weak, so an + /// item the SDK drops without a delete (it always deletes, but a leak here would be silent) is not kept alive. + private readonly System.Runtime.CompilerServices.ConditionalWeakTable _cacheReferenceReleased = new(); + private static readonly object ReleasedMarker = new(); + /// Test observability: how many NodeIds currently carry a fan-out. internal int MonitoredNodeCount { @@ -5352,8 +5374,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 { _eventItems.TryRemove(item, out _); Interlocked.Decrement(ref _suppressedItems); - ReleaseComponentCacheReference(item); - continue; + continue; // an event item holds no component-cache reference (see TombstoneMonitoredNode) } SubscribeToEvents(SystemContext, tracked.Handle.Node, item, unsubscribe: !permitted); @@ -7065,7 +7086,11 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2 else if (endBound is not null) { // The data is drained — the end bound ends this page, or is the next one when this one is full. - clusterCp = await AppendEndBoundAsync(slice, startUtc, readCap).ConfigureAwait(false); + // An uncapped read (readCap 0) on a backend with a row ceiling is still a page of at most that + // ceiling — the MaxReturnDataValues it advertises — so the bound fits against it, not "unbounded". + // (Today the over-fetch bound keeps a cluster slice at most L − 1, so this is defence in depth.) + var clusterAllowance = readCap > 0 ? readCap : pageLimit > 0 ? (uint)pageLimit : 0u; + clusterCp = await AppendEndBoundAsync(slice, startUtc, clusterAllowance).ConfigureAwait(false); } results[handle.Index] = new SdkHistoryReadResult diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 702396a8..833525a0 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -132,16 +132,56 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// which are the only part that must run on this actor's thread. Carries the and /// the original so the reply reaches whoever asked. /// + /// stamps the actor instance that started the plan: a PipeTo(Self) + /// outlives a restart (the ActorRef survives it), and a plan computed against a previous instance's + /// must not be applied by the next one. carries a + /// data-plane-authority parse failure, which never fails the address-space apply (review fix I2). + /// private sealed record PlanReady( - RebuildAddressSpace Request, IActorRef Requester, byte[] Artifact, DataPlaneAuthorityProjection? Authority, + object Incarnation, RebuildAddressSpace Request, IActorRef Requester, byte[] Artifact, + DataPlaneAuthorityProjection? Authority, Exception? AuthorityError, AddressSpaceComposition Composition, AddressSpacePlan Plan, IReadOnlyList Inconsistencies); /// The off-thread parse / plan step threw (unreadable bytes, a composer fault). - private sealed record PlanFailed(RebuildAddressSpace Request, IActorRef Requester, Exception Error); + private sealed record PlanFailed(object Incarnation, RebuildAddressSpace Request, IActorRef Requester, Exception Error); + + /// Test seams for the off-thread plan step. Production passes none. + public sealed class TestHooks + { + /// Runs on the thread pool at the start of every plan step (a test blocks it to hold a plan in flight). + public Action? BeforePlan { get; init; } + + /// Replaces (a test makes it throw). + internal Func? ParseAuthority { get; init; } + } + + /// The default bound on : distinct (realm, id) keys held while a plan is in flight. + public const int DefaultDeferredWriteCapacity = 250_000; + + /// Identity of THIS actor instance (see ). + private readonly object _incarnation = new(); + private readonly TestHooks? _hooks; + + /// + /// Review fix C1 — the latest value / status / alarm write per (realm, id) received while a rebuild's plan is + /// in flight (or queued), replayed through the sink after each apply. Writes are non-creating (review 01 L1), + /// so a driver's first publish for a node the in-flight plan ADDS used to be skipped — and a change-driven + /// driver (or a poll group whose last-value cache already holds it) never sends it again, leaving the node + /// BadWaitingForInitialData indefinitely. On a full rebuild the same holds for every node: the + /// re-subscribe's first values land on old nodes the apply then discards. + /// Writes are still applied IMMEDIATELY (existing nodes keep updating during the plan); the buffer + /// only remembers each key's latest so the replay can land it on a node the apply created. Replaying onto a + /// node that already holds it is inert: the same value/status/timestamp is filtered by every monitored + /// item's data-change filter, and an alarm write is delta-gated against the node's current state. That is + /// also why alarms coalesce to the latest state per id rather than replaying every transition: replaying an + /// older transition onto a live condition would move it backwards and fire spurious events. + /// + private readonly DeferredWrites _deferred; /// True while a rebuild's parse / plan step is running off-thread. Rebuilds are applied strictly in /// arrival order — the plan is a diff against , which only the apply advances — so a - /// rebuild arriving meanwhile waits in ; value and status batches do not. + /// rebuild arriving meanwhile waits in ; value, status and alarm writes do not — they + /// are applied at once AND remembered in for replay after the apply. private bool _planInFlight; private readonly Queue<(RebuildAddressSpace Request, IActorRef Requester)> _pendingRebuilds = new(); @@ -309,6 +349,18 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers dataPlaneAuthority, utcNow)); + /// Test-only Props exposing the deferred-write bound and the plan-step seams. + internal static Props PropsForTests( + IOpcUaAddressSpaceSink sink, + AddressSpaceApplier? applier, + TestHooks? hooks, + int deferredWriteCapacity = DefaultDeferredWriteCapacity, + NodeId? localNode = null, + IDataPlaneAuthoritySink? dataPlaneAuthority = null) => + Akka.Actor.Props.Create(() => new OpcUaPublishActor( + sink, NullServiceLevelPublisher.Instance, false, localNode, null, applier, null, null, null, null, + false, dataPlaneAuthority, null, deferredWriteCapacity, hooks)); + /// Initializes a new instance of the class. /// The OPC UA address space sink. /// The service level publisher. @@ -331,6 +383,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// ServiceLevel DB-reachability input is retired (DbReachable treated constant-true, only /// staleness demotes). No is wired on such a node. /// The optional sink that receives the API keys and ACL grants carried by each applied artifact. + /// The bound on distinct nodes whose writes are held for replay while a rebuild plan is in flight. + /// Test seams for the off-thread plan step; null in production. public OpcUaPublishActor( IOpcUaAddressSpaceSink sink, IServiceLevelPublisher serviceLevel, @@ -344,8 +398,12 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers TimeSpan? healthTickInterval = null, bool dbLess = false, IDataPlaneAuthoritySink? dataPlaneAuthority = null, - Func? utcNow = null) + Func? utcNow = null, + int deferredWriteCapacity = DefaultDeferredWriteCapacity, + TestHooks? hooks = null) { + _deferred = new DeferredWrites(Math.Max(1, deferredWriteCapacity)); + _hooks = hooks; _sink = sink; _dataPlaneAuthority = dataPlaneAuthority; _serviceLevel = serviceLevel; @@ -400,6 +458,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers { try { + if (PlanPipelineBusy) DeferValue(new ValueWrite(msg.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, msg.Realm)); _sink.WriteValue(msg.NodeId, msg.Value, msg.StatusCode, msg.TimestampUtc, msg.Realm); Interlocked.Increment(ref _writes); OtOpcUaTelemetry.OpcUaSinkWrite.Add(1, new KeyValuePair("kind", "value")); @@ -421,6 +480,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// a whole batch because one item was bad.
private void HandleAttributeBatchUpdate(AttributeValueBatchUpdate msg) { + if (PlanPipelineBusy) + foreach (var item in msg.Items) DeferValue(item); try { _sink.WriteValues(msg.Items); @@ -451,6 +512,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// value write on the same counter.
private void HandleAttributeStatusBatchUpdate(AttributeStatusBatchUpdate msg) { + if (PlanPipelineBusy) + foreach (var item in msg.Items) DeferStatus(item); try { _sink.WriteStatuses(msg.Items); @@ -474,6 +537,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private void HandleAlarmUpdate(AlarmStateUpdate msg) { + if (PlanPipelineBusy) DeferAlarm(msg); try { _sink.WriteAlarmCondition(msg.AlarmNodeId, msg.State, msg.TimestampUtc, msg.Realm); @@ -488,6 +552,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private void HandleAlarmQualityUpdate(AlarmQualityUpdate msg) { + if (PlanPipelineBusy) DeferAlarmQuality(msg); try { _sink.WriteAlarmQuality(msg.AlarmNodeId, msg.StatusCode, msg.TimestampUtc, msg.Realm); @@ -511,6 +576,16 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// private void StartRebuild(RebuildAddressSpace msg, IActorRef requester) { + // Strict arrival order for EVERY rebuild, including the ones that complete synchronously (no applier, no + // bytes): a bytes-less request arriving mid-plan used to answer before the plan ahead of it, so replies went + // out of order. HandleRebuild is the only entry that can find the pipeline busy; the drain calls this only + // when it is idle. + if (_planInFlight) + { + _pendingRebuilds.Enqueue((msg, requester)); + return; + } + // Only a node with NO applier at all (the F10b/dev seam) falls back to the raw-sink rebuild — and that // fallback WIPES the served address space without re-materialising, so it must NEVER be reached by a // real driver node that has an applier (that node diff-and-applies from its in-hand bytes below). @@ -559,36 +634,58 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers return; } - if (_planInFlight) - { - _pendingRebuilds.Enqueue((msg, requester)); - return; - } - _planInFlight = true; var artifact = msg.Artifact; var baseComposition = _lastApplied; var nodeId = _localNode?.Value; var wantAuthority = _dataPlaneAuthority is not null; + var incarnation = _incarnation; + var hooks = _hooks; Task.Run(() => { + hooks?.BeforePlan?.Invoke(); // Open() throws on bytes that are not valid JSON, where the static wrappers swallowed to an EMPTY // result — and an empty composition is not a benign no-op here (see the no-bytes guard above). The // throw lands in PlanFailed: logged at Error, _lastApplied untouched, last-known-good still served. using var parsed = DeploymentArtifact.Open(artifact); - var authority = wantAuthority ? parsed.ParseDataPlaneAuthority(nodeId ?? string.Empty) : null; + // The authority parse is isolated from the address-space plan (review fix I2): before the plan moved + // off-thread a throw here was contained by FeedDataPlaneAuthority's own catch; it must not now + // become PlanFailed and degrade an apply whose topology is perfectly readable. + DataPlaneAuthorityProjection? authority = null; + Exception? authorityError = null; + if (wantAuthority) + { + try + { + authority = hooks?.ParseAuthority is { } parse + ? parse(parsed, nodeId ?? string.Empty) + : parsed.ParseDataPlaneAuthority(nodeId ?? string.Empty); + } + catch (Exception ex) + { + authorityError = ex; + } + } + var inconsistencies = new List(); var composition = nodeId is not null ? parsed.ParseComposition(nodeId, inconsistencies.Add) : parsed.ParseComposition(); var plan = AddressSpacePlanner.Compute(baseComposition, composition); - return new PlanReady(msg, requester, artifact, authority, composition, plan, inconsistencies); + return new PlanReady(incarnation, msg, requester, artifact, authority, authorityError, composition, plan, inconsistencies); }) - .PipeTo(Self, failure: ex => new PlanFailed(msg, requester, ex)); + .PipeTo(Self, failure: ex => new PlanFailed(incarnation, msg, requester, ex)); } private void HandlePlanReady(PlanReady ready) { + if (IsFromAnotherIncarnation(ready.Incarnation, ready.Request, ready.Requester)) return; + + if (ready.AuthorityError is { } authorityError) + _log.Error(authorityError, + "OpcUaPublish: data-plane authority parse threw (correlation={Correlation}) — the address space is applied; the previously installed keys and grants stay in force", + ready.Request.Correlation); + (bool Degraded, string? Reason) outcome; try { @@ -600,20 +697,211 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } FinishRebuild(ready.Request, ready.Requester, outcome); - StartNextPendingRebuild(); + AfterPlanSettled(); } private void HandlePlanFailed(PlanFailed failed) { + if (IsFromAnotherIncarnation(failed.Incarnation, failed.Request, failed.Requester)) return; + _planInFlight = false; _log.Error(failed.Error, "OpcUaPublish: rebuild pipeline threw (correlation={Correlation})", failed.Request.Correlation); FinishRebuild(failed.Request, failed.Requester, (true, $"address-space apply threw: {failed.Error.Message}")); - StartNextPendingRebuild(); + AfterPlanSettled(); } + /// A plan piped back to a RESTARTED instance (the ActorRef survives a restart) was computed against the + /// previous instance's : never applied. Its requester is still owed an answer. + private bool IsFromAnotherIncarnation(object incarnation, RebuildAddressSpace request, IActorRef requester) + { + if (ReferenceEquals(incarnation, _incarnation)) return false; + _log.Warning( + "OpcUaPublish: ignoring a rebuild plan started by a previous incarnation of this actor (correlation={Correlation})", + request.Correlation); + if (request.ReportOutcome) + requester.Tell(new RebuildCompleted(request.Correlation, request.DeploymentId, true, + "the address-space publisher restarted while this rebuild was being planned; it was not applied")); + return true; + } + + /// After an apply (or a failed plan) has been answered — its deferred writes already replayed by + /// — start what queued behind it, and drop the buffer once nothing is in flight or + /// queued (a queued plan may add nodes the buffer names, so it is replayed again after that apply). + private void AfterPlanSettled() + { + StartNextPendingRebuild(); + if (!PlanPipelineBusy) _deferred.Clear(); + } + + /// Starts queued rebuilds in arrival order until one goes off-thread (or the queue empties) — the + /// synchronous completions (no bytes) answer in their turn. private void StartNextPendingRebuild() { - if (_pendingRebuilds.TryDequeue(out var next)) StartRebuild(next.Request, next.Requester); + while (!_planInFlight && _pendingRebuilds.TryDequeue(out var next)) + StartRebuild(next.Request, next.Requester); + } + + /// True while a plan is computing or a rebuild is queued behind one. + private bool PlanPipelineBusy => _planInFlight || _pendingRebuilds.Count > 0; + + private void DeferValue(in ValueWrite write) + { + if (!_deferred.Value(write)) WarnDeferredOverflow(); + } + + private void DeferStatus(in StatusWrite write) + { + if (!_deferred.Status(write)) WarnDeferredOverflow(); + } + + private void DeferAlarm(AlarmStateUpdate msg) + { + if (!_deferred.Alarm(msg)) WarnDeferredOverflow(); + } + + private void DeferAlarmQuality(AlarmQualityUpdate msg) + { + if (!_deferred.AlarmQuality(msg)) WarnDeferredOverflow(); + } + + private void WarnDeferredOverflow() + { + if (_deferred.Dropped == 1) + _log.Warning( + "OpcUaPublish: more than {Capacity} distinct nodes written while a rebuild plan is in flight — later new nodes are not held for replay (their first value lands only if the driver republishes it)", + _deferred.Capacity); + } + + /// Re-applies the deferred writes through the sink: values, then status-only annotations, then alarm + /// states and alarm qualities. Each pass isolated like the live handlers; not re-metered (counted on arrival). + private void ReplayDeferredWrites() + { + if (_deferred.IsEmpty) return; + var (values, statuses) = _deferred.VariableWrites(); + if (values.Count > 0) + { + try { _sink.WriteValues(values); } + catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred value writes threw", values.Count); } + } + + if (statuses.Count > 0) + { + try { _sink.WriteStatuses(statuses); } + catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred status writes threw", statuses.Count); } + } + + foreach (var alarm in _deferred.Alarms) + { + try { _sink.WriteAlarmCondition(alarm.AlarmNodeId, alarm.State, alarm.TimestampUtc, alarm.Realm); } + catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm write threw for {Node}", alarm.AlarmNodeId); } + } + + foreach (var quality in _deferred.AlarmQualities) + { + try { _sink.WriteAlarmQuality(quality.AlarmNodeId, quality.StatusCode, quality.TimestampUtc, quality.Realm); } + catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm-quality write threw for {Node}", quality.AlarmNodeId); } + } + + _log.Debug("OpcUaPublish: replayed {Values} value, {Statuses} status and {Alarms} alarm writes deferred during a rebuild plan", + values.Count, statuses.Count, _deferred.AlarmCount); + } + + /// + /// The latest write per (realm, id) received while a plan is in flight — see . A value + /// followed by a status-only write for the same node composes to the value under the new status (exactly what + /// the two live writes leave on the node). Bounded by distinct keys across all kinds; + /// beyond it a NEW key is not held (an existing key still updates), counted in . + /// + private sealed class DeferredWrites(int capacity) + { + private struct VariableEntry + { + public bool HasValue; + public object? Value; + public uint Status; + public DateTime TimestampUtc; + } + + private readonly Dictionary<(AddressSpaceRealm, string), VariableEntry> _variables = new(); + private readonly Dictionary<(AddressSpaceRealm, string), AlarmStateUpdate> _alarms = new(); + private readonly Dictionary<(AddressSpaceRealm, string), AlarmQualityUpdate> _qualities = new(); + + public int Capacity { get; } = capacity; + public long Dropped { get; private set; } + public int Count => _variables.Count + _alarms.Count + _qualities.Count; + public bool IsEmpty => Count == 0; + public int AlarmCount => _alarms.Count + _qualities.Count; + public IEnumerable Alarms => _alarms.Values; + public IEnumerable AlarmQualities => _qualities.Values; + + public bool Value(in ValueWrite write) + { + if (string.IsNullOrEmpty(write.NodeId)) return true; + var key = (write.Realm, write.NodeId); + if (!_variables.ContainsKey(key) && !HasRoom()) return false; + _variables[key] = new VariableEntry + { + HasValue = true, Value = write.Value, Status = write.StatusCode, TimestampUtc = write.SourceTimestampUtc, + }; + return true; + } + + public bool Status(in StatusWrite write) + { + if (string.IsNullOrEmpty(write.NodeId)) return true; + var key = (write.Realm, write.NodeId); + if (_variables.TryGetValue(key, out var entry)) + { + entry.Status = write.StatusCode; + entry.TimestampUtc = write.SourceTimestampUtc; + _variables[key] = entry; + return true; + } + + if (!HasRoom()) return false; + _variables[key] = new VariableEntry { Status = write.StatusCode, TimestampUtc = write.SourceTimestampUtc }; + return true; + } + + public bool Alarm(AlarmStateUpdate msg) => Put(_alarms, (msg.Realm, msg.AlarmNodeId), msg); + + public bool AlarmQuality(AlarmQualityUpdate msg) => Put(_qualities, (msg.Realm, msg.AlarmNodeId), msg); + + public (List Values, List Statuses) VariableWrites() + { + var values = new List(); + var statuses = new List(); + foreach (var ((realm, id), e) in _variables) + { + if (e.HasValue) values.Add(new ValueWrite(id, e.Value, e.Status, e.TimestampUtc, realm)); + else statuses.Add(new StatusWrite(id, e.Status, e.TimestampUtc, realm)); + } + + return (values, statuses); + } + + public void Clear() + { + _variables.Clear(); + _alarms.Clear(); + _qualities.Clear(); + Dropped = 0; + } + + private bool Put(Dictionary<(AddressSpaceRealm, string), T> map, (AddressSpaceRealm, string) key, T value) + { + if (string.IsNullOrEmpty(key.Item2)) return true; + if (!map.ContainsKey(key) && !HasRoom()) return false; + map[key] = value; + return true; + } + + private bool HasRoom() + { + if (Count < Capacity) return true; + Dropped++; + return false; + } } /// @@ -631,6 +919,10 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers _log.Error(ex, "OpcUaPublish: sink.CompleteAddressSpaceApply threw (correlation={Correlation})", msg.Correlation); } + // Land what arrived while the plan was in flight on the nodes the apply just created — before the answer, + // so "applied" means the deferred first values are on the address space too (review fix C1). + ReplayDeferredWrites(); + if (msg.ReportOutcome) requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason)); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs index 6cacc60e..6893fc2e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs @@ -139,6 +139,10 @@ public sealed class StaleMonitoredNodeWireTests "the new item must receive a value written after its creation"); lock (gate) newValues.ShouldNotContain(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); + // Review fix I3: the cache entry the new item holds is ITS reference alone — the old item's was released + // by the removal. + CacheRefCount(nm, nodeId).ShouldBe(1, "one live item, one component-cache reference"); + // The old item was told, and the dead node's fan-out is gone. await WaitUntilAsync(() => { lock (gate) return oldValues.Any(v => v.StatusCode == StatusCodes.BadNodeIdUnknown); }, TimeSpan.FromSeconds(5), "the item on the removed node must be told BadNodeIdUnknown"); @@ -150,9 +154,18 @@ public sealed class StaleMonitoredNodeWireTests (oldItem.Status.Error is null || ServiceResult.IsGood(oldItem.Status.Error)).ShouldBeTrue( $"deleting the orphaned item must succeed, got {oldItem.Status.Error}"); + // The SDK releases a deleted item's cache reference on a Good delete; the old item's was released by the + // removal already, so a second release would evict the NEW item's live entry (review fix I3). + CacheRefCount(nm, nodeId).ShouldBe(1, "deleting the orphaned item must not take the new item's reference"); + // And the new item is unaffected by that delete. nm.WriteValue(Tag, 44, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw); await WaitUntilAsync(() => { lock (gate) return newValues.Any(v => Equals(v.Value, 44)); }, TimeSpan.FromSeconds(5)); + + // Positive control: deleting the live item gives its reference back. + subscription.RemoveItem(newItem); + await subscription.ApplyChangesAsync(ct); + CacheRefCount(nm, nodeId).ShouldBe(0); } finally { @@ -160,6 +173,28 @@ public sealed class StaleMonitoredNodeWireTests } } + /// The SDK's component-cache reference count for (private SDK state, read by + /// reflection: CustomNodeManager2.m_componentCache → CacheEntry.RefCount); 0 when no entry. + private static int CacheRefCount(OtOpcUaNodeManager nm, NodeId nodeId) + { + const System.Reflection.BindingFlags Any = + System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic; + var field = typeof(Opc.Ua.Server.CustomNodeManager2).GetField("m_componentCache", Any) + .ShouldNotBeNull("the SDK's component cache field (SDK internals moved?)"); + lock (nm.Lock) + { + if (field.GetValue(nm) is not System.Collections.IDictionary cache || !cache.Contains(nodeId)) return 0; + var entry = cache[nodeId]!; + var member = (object?)entry.GetType().GetField("RefCount", Any) ?? entry.GetType().GetProperty("RefCount", Any); + return member switch + { + System.Reflection.FieldInfo f => (int)f.GetValue(entry)!, + System.Reflection.PropertyInfo p => (int)p.GetValue(entry)!, + _ => throw new InvalidOperationException("CacheEntry.RefCount not found"), + }; + } + } + private static void Materialise(OtOpcUaNodeManager nm) { nm.EnsureFolder(Device, null, "dev1", AddressSpaceRealm.Raw); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs index da4f1a57..1ddf9a28 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/History/HistoryUpdateTests.cs @@ -254,6 +254,38 @@ public sealed class HistoryUpdateTests : IDisposable finally { await host.DisposeAsync(); } } + /// + /// Review fix I4 — the override's OWN per-request updater check, which the bit retraction above now hides. + /// It is the B7 race window: a request the SDK base admitted on the HistoryWrite bit just before the updater + /// was cleared. Reconstructed by restoring the bit after the clear, so the request reaches the override with + /// no updater; it must answer unsupported on both slots (the override sets results itself) and never reach the + /// old updater. + /// + [Fact] + public async Task The_override_rechecks_the_updater_per_request_when_the_bit_is_still_set() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + var updater = new FakeUpdater(); + nm.HistorianDataUpdater = updater; + WireAcl(nm, enabled: false, NodePermissions.HistoryUpdate); + var nodeId = Historized(nm, AddressSpaceRealm.Raw, RawPath); + nm.HistorianDataUpdater = null; + var variable = nm.TryGetVariable(RawPath, AddressSpaceRealm.Raw)!; + variable.AccessLevel |= AccessLevels.HistoryWrite; + variable.UserAccessLevel |= AccessLevels.HistoryWrite; + + var (results, errors) = Insert(nm, KeyIdentity(OpcUaDataPlaneRoles.HistoryUpdate), nodeId, Value(T1, 1d)); + + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override, not left for the master node manager"); + updater.UpdateCalls.ShouldBeEmpty(); + } + finally { await host.DisposeAsync(); } + } + /// With an updater wired, both the raw node and its UNS mirror carry HistoryWrite; a non-historized node does not. [Fact] public async Task An_updater_grants_history_write_to_raw_and_uns_historized_nodes_only() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs index 74096a4f..4875ac61 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadBoundsTests.cs @@ -321,6 +321,54 @@ public sealed class NodeManagerHistoryReadBoundsTests(BoundsServerFixture fx) : cpWhole.ShouldBeNull(); } + /// + /// Lane B review fix round 1 (minor): an UNCAPPED read (N = 0) against a backend with a row ceiling L pages at + /// L, and MaxReturnDataValues advertises L — so no page, the tie-cluster slices and their end bound included, + /// may carry more than L values; and the pages still concatenate to the uncapped single-shot answer. Swept + /// over ceilings and tie-cluster sizes up to what one over-fetch read can hold (L − 1). + /// + [Fact] + public async Task An_uncapped_read_never_returns_a_page_larger_than_the_backend_ceiling() + { + foreach (var ceiling in new[] { 2, 3, 4, 6 }) + { + for (var ties = 1; ties < ceiling; ties++) + { + var series = new List { Snap("5:00", 0) }; + for (var i = 0; i < ties; i++) series.Add(Snap("5:02", 10 + i)); + series.Add(Snap("5:03", 3)); + for (var i = 0; i < ties; i++) series.Add(Snap("5:05", 50 + i)); + series.Add(Snap("5:06", 6)); + + foreach (var (start, end) in new[] { ("5:01", "5:04"), ("5:00", "5:06"), ("5:01", "5:07"), ("5:06", "5:01") }) + { + var expected = Render( + (await ReadAsync(new SeriesSource(series, reverse: true, inclusiveForwardEnd: false, resolution: 1), T(start), T(end), 0, true, null)).Values, + T(start), T(end)); + + var capped = new SeriesSource(series, reverse: true, inclusiveForwardEnd: false, resolution: 1, ceiling: ceiling); + var all = new List(); + byte[]? cp = null; + var pages = 0; + do + { + var (values, error, next) = await ReadAsync(capped, T(start), T(end), 0, true, cp); + error.StatusCode.Code.ShouldBe(StatusCodes.Good, $"L={ceiling} ties={ties} {start}->{end}"); + values.Count.ShouldBeLessThanOrEqualTo(ceiling, $"L={ceiling} ties={ties} {start}->{end} page {pages}"); + all.AddRange(values); + cp = next; + (++pages).ShouldBeLessThan(60, "paging must terminate"); + } + while (cp is not null); + + Render(all, T(start), T(end)).ShouldBe(expected, $"L={ceiling} ties={ties} {start}->{end}"); + } + } + } + + static DataValueSnapshot Snap(string at, double v) => new(v, StatusCodes.Good, T(at), T(at)); + } + /// /// #691 review: no data AND no bound found ⇒ GoodNoData with no values — the answer a tag with no history (and a /// disabled historian) gave before bounds (§6.5.3.2: bounds make the result Good only "if one or both exist"). Only diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs index 1984fdb8..3df484bc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerHistoryReadEventsTests.cs @@ -249,6 +249,45 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable await host.DisposeAsync(); } + /// + /// Review fix I4 — the HistoryReadEvents override's OWN source guard. Every other "unsupported" case here is + /// answered by the SDK base, which admits only notifiers carrying the EventNotifier.HistoryRead bit — + /// and since B7 the bit and the source registration move together. So the guard is reached only by a request + /// the base admitted on the bit just before a source reset took the registration back; reconstructed by + /// restoring the bit after the reset. The override must answer unsupported on both slots and reach no source. + /// + [Fact] + public async Task The_event_override_refuses_a_notifier_with_the_bit_but_no_registered_source() + { + var (host, server) = await BootAsync(); + var nm = server.NodeManager!; + var fake = new RecordingHistorianDataSource(); + nm.HistorianDataSource = fake; + nm.EnsureFolder("eq-guard", parentNodeId: null, displayName: "Equipment", realm: AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("alarm-g", "eq-guard", "Cond", "OffNormalAlarm", severity: 200, realm: AddressSpaceRealm.Uns); + nm.IsEventHistorySource("eq-guard").ShouldBeTrue("non-vacuity: registered while the source was wired"); + + nm.HistorianDataSource = null!; + var folder = nm.TryGetFolder("eq-guard")!; + nm.IsEventHistorySource("eq-guard").ShouldBeFalse(); + folder.EventNotifier |= EventNotifiers.HistoryRead; + + var details = new ReadEventDetails + { + StartTime = DateTime.UtcNow.AddHours(-1), + EndTime = DateTime.UtcNow, + NumValuesPerNode = 10, + Filter = SelectFilter("EventId"), + }; + var (results, errors) = InvokeHistoryRead(server, nm, details, folder.NodeId); + + errors[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported); + results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override's guard"); + fake.EventsCalled.ShouldBeFalse(); + + await host.DisposeAsync(); + } + /// A variable node targeted by a HistoryReadEvents request ⇒ BadHistoryOperationUnsupported; /// the source is never invoked. /// @@ -259,8 +298,8 @@ public sealed class NodeManagerHistoryReadEventsTests : IDisposable /// SDK base therefore rejects it and does NOT pass it to our HistoryReadEvents override; /// the Bad result comes from the base's pre-seeding, not from our source-guard. This test pins /// that base-level rejection of variable nodes for event reads. - /// The override's own source-guard (miss in _eventNotifierSources) is exercised by the - /// Events_folder_promoted_without_source_is_unsupported_until_a_source_is_wired test instead. + /// The override's own source-guard (a miss in _eventNotifierSources) is exercised by + /// The_event_override_refuses_a_notifier_with_the_bit_but_no_registered_source. /// /// [Fact] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs index 848529f3..865bef48 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs @@ -20,6 +20,11 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// The wall-clock budgets are generous for CI hardware; the before/after numbers are in the commit /// message. P3 is pinned by the bookkeeping's own visit counter, which does not depend on the machine. +/// Kept in the default run on purpose (not a Benchmark category the CI filter would have to learn to exclude): +/// measured after the fix at 5–6 ms (P1) and 58–64 ms (P2) on a loaded dev machine against the 1 s budget — +/// ≥ 15× headroom, so it does not flake on slow CI. The flip side: the pre-fix code (75–150 ms / 470–680 ms) also +/// fits 1 s at these sizes, so the wall-clock legs guard against gross (order-of-magnitude) regressions only; the +/// machine-independent P3 visit counter is the strict pin. /// [Trait("Category", "Benchmark")] public sealed class NodeManagerLockTimeBenchmarkTests(ITestOutputHelper output) : IDisposable diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs index fcd966e5..5ddd6b71 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs @@ -93,6 +93,39 @@ public sealed class NodeManagerStaleMonitoredNodeTests : IDisposable finally { await host.DisposeAsync(); } } + /// + /// Review fix round 1 (minor): the same-id kind swap (scripted ⇄ native) in MaterialiseAlarmCondition + /// drops the old condition instance — one more removal path, so it tombstones like the others. Believed + /// unreachable from a composition (native ids are RawPaths in Raw, scripted ids live in Uns), so it is driven + /// directly. + /// + [Fact] + public async Task A_condition_kind_swap_tells_and_forgets_items_on_the_dropped_instance() + { + var (host, server) = await BootAsync(); + try + { + var nm = server.NodeManager!; + nm.EnsureFolder("eq-1", null, "Equipment", AddressSpaceRealm.Uns); + nm.MaterialiseAlarmCondition("al-1", "eq-1", "High", "OffNormalAlarm", 500, AddressSpaceRealm.Uns, isNative: false); + var nodeId = new NodeId("al-1", nm.TryGetFolder("eq-1")!.NodeId.NamespaceIndex); + var handle = (NodeHandle)nm.GetManagerHandle(nodeId); + handle.ShouldNotBeNull("non-vacuity: the condition is materialised"); + var item = new StubItem(handle, Identity()); + nm.TrackLiveMonitoredItem(handle, item); + nm.Authorizer = Authorizer(permits: false); + nm.RevalidateMonitoredItems(); + item.Statuses.ShouldContain((StatusCode)StatusCodes.BadUserAccessDenied, "non-vacuity: stood down"); + + nm.MaterialiseAlarmCondition("al-1", "eq-1", "High", "OffNormalAlarm", 500, AddressSpaceRealm.Uns, isNative: true); + + item.Statuses.ShouldContain((StatusCode)StatusCodes.BadNodeIdUnknown, "the dropped instance's item is told"); + nm.LiveItemCount.ShouldBe(0); + ((NodeHandle)nm.GetManagerHandle(nodeId)).ShouldNotBeNull("positive control: the swapped-in condition exists"); + } + finally { await host.DisposeAsync(); } + } + /// Registers a stub item on and stands it down through a denying pass. private static async Task<(StubItem Item, NodeId NodeId)> StandDownAsync(OtOpcUaNodeManager nm, string rawPath) { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs new file mode 100644 index 00000000..7b795e3d --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs @@ -0,0 +1,438 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Akka.Actor; +using Microsoft.Extensions.Logging.Abstractions; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Core.Authorization; +using ZB.MOM.WW.OtOpcUa.OpcUaServer; +using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.OpcUa; + +/// +/// Lane B review fix round 1. C1: the plan runs off the pinned thread (R02:P5) while writes are non-creating +/// (R01:L1), so a value, status or alarm write for a node the IN-FLIGHT plan adds used to be skipped — and a +/// change-driven driver never republishes it. The actor now remembers the latest write per node while a plan +/// is in flight and replays it after the apply. Every test holds the plan open with a gate, so "during the +/// plan" is deterministic, and runs against a sink that — like the real node manager — drops writes for nodes +/// that do not exist. +/// Also here: I2 (an authority parse throw never fails the apply), the bytes-less request queued behind an +/// in-flight plan, and a plan piped back to a restarted actor. +/// +public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase +{ + private const string NewTag = "Plant/Modbus/dev1/T1"; + private static readonly TimeSpan PlanBudget = TimeSpan.FromSeconds(30); + + [Fact] + public void A_value_for_a_node_the_in_flight_plan_adds_lands_on_that_node_after_the_apply() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + + actor.Tell(Rebuild(RawTagArtifact(3))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate( + [new ValueWrite(NewTag, 7, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)])); + AwaitAssert(() => sink.SkippedWrites.ShouldContain(NewTag), PlanBudget); // non-vacuity: it really missed + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.Exists(NewTag).ShouldBeTrue("the plan materialised the node"); + sink.ValueOf(NewTag).ShouldBe((7, OpcUaStatus.Good, at), "the value sent while the plan was in flight must end up on the node"); + } + + [Fact] + public void A_status_after_a_value_composes_onto_the_new_node_in_order() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + + actor.Tell(Rebuild(RawTagArtifact(3))); + var t1 = DateTime.UtcNow; + var t2 = t1.AddSeconds(1); + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite(NewTag, 7, OpcUaStatus.Good, t1, AddressSpaceRealm.Raw)])); + actor.Tell(new OpcUaPublishActor.AttributeStatusBatchUpdate([new StatusWrite(NewTag, OpcUaStatus.BadNoCommunication, t2, AddressSpaceRealm.Raw)])); + gate.Set(); + ExpectMsg(PlanBudget); + + sink.ValueOf(NewTag).ShouldBe((7, OpcUaStatus.BadNoCommunication, t2), "last known value under the later status"); + } + + [Fact] + public void Existing_nodes_keep_updating_while_the_plan_computes() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(RawTagArtifact(1))); + gate.Set(); + ExpectMsg(PlanBudget); + var existing = "Plant/Modbus/dev1/T0"; + sink.Exists(existing).ShouldBeTrue(); + + gate.Reset(); + actor.Tell(Rebuild(RawTagArtifact(3))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite(existing, 5, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)])); + + // Applied immediately, while the plan is still held open. + AwaitAssert(() => sink.ValueOf(existing).ShouldBe((5, OpcUaStatus.Good, at)), PlanBudget); + sink.Exists(NewTag).ShouldBeFalse("the plan is still in flight"); + + gate.Set(); + ExpectMsg(PlanBudget); + sink.ValueOf(existing).ShouldBe((5, OpcUaStatus.Good, at)); + } + + [Fact] + public void An_alarm_state_sent_during_the_plan_lands_on_the_condition_it_materialises() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + + actor.Tell(Rebuild(AlarmArtifact())); + var state = new AlarmConditionSnapshot(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, AlarmShelvingKind.Unshelved, 500, "high"); + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", state, DateTime.UtcNow, AddressSpaceRealm.Uns)); + actor.Tell(new OpcUaPublishActor.AlarmQualityUpdate("al-1", OpcUaStatus.BadNoCommunication, DateTime.UtcNow, AddressSpaceRealm.Uns)); + AwaitAssert(() => sink.SkippedAlarms.ShouldContain("al-1"), PlanBudget); + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.ConditionExists("al-1").ShouldBeTrue("the plan materialised the condition"); + sink.AlarmStateOf("al-1").ShouldBeSameAs(state); + sink.AlarmQualityOf("al-1").ShouldBe(OpcUaStatus.BadNoCommunication); + } + + [Fact] + public void The_deferred_buffer_is_bounded_under_a_flood_and_keeps_the_earliest_keys() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( + sink, new AddressSpaceApplier(sink, NullLogger.Instance), + new OpcUaPublishActor.TestHooks { BeforePlan = () => gate.Wait() }, deferredWriteCapacity: 2)); + + actor.Tell(Rebuild(RawTagArtifact(5))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate(Enumerable.Range(0, 5) + .Select(i => new ValueWrite($"Plant/Modbus/dev1/T{i}", i, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)).ToList())); + // An existing key still updates when the buffer is full. + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite("Plant/Modbus/dev1/T0", 100, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)])); + AwaitAssert(() => sink.SkippedWrites.Count.ShouldBe(6), PlanBudget); + + gate.Set(); + ExpectMsg(PlanBudget); + + sink.ValueOf("Plant/Modbus/dev1/T0")!.Value.Value.ShouldBe(100); + sink.ValueOf("Plant/Modbus/dev1/T1")!.Value.Value.ShouldBe(1); + for (var i = 2; i < 5; i++) + sink.ValueOf($"Plant/Modbus/dev1/T{i}").ShouldBeNull("beyond the bound a new key is not held"); + sink.ReplayedItems.ShouldBe(2); + } + + [Fact] + public void Writes_after_the_pipeline_drains_are_not_replayed_again() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(RawTagArtifact(3))); + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite(NewTag, 7, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw)])); + gate.Set(); + ExpectMsg(PlanBudget); + var replayed = sink.ReplayedItems; + replayed.ShouldBe(1); + + // A later plan replays only what arrived during IT. + actor.Tell(Rebuild(RawTagArtifact(3))); + ExpectMsg(PlanBudget); + sink.ReplayedItems.ShouldBe(replayed); + } + + [Fact] + public void A_throwing_authority_parse_does_not_fail_the_apply() + { + var sink = new ExistenceSink(); + var authority = new RecordingAuthoritySink(); + var actor = Sys.ActorOf(OpcUaPublishActor.PropsForTests( + sink, new AddressSpaceApplier(sink, NullLogger.Instance), + new OpcUaPublishActor.TestHooks { ParseAuthority = (_, _) => throw new InvalidOperationException("bad grants") }, + dataPlaneAuthority: authority)); + + actor.Tell(Rebuild(RawTagArtifact(2))); + + var completed = ExpectMsg(PlanBudget); + completed.Degraded.ShouldBeFalse(completed.DegradedReason); + sink.Exists("Plant/Modbus/dev1/T1").ShouldBeTrue("the address space is applied"); + authority.Applies.ShouldBe(0, "nothing is installed from an authority that could not be read"); + + // Positive control: the same actor installs an authority that CAN be read. + var healthy = Sys.ActorOf(OpcUaPublishActor.PropsForTests( + new ExistenceSink(), new AddressSpaceApplier(new ExistenceSink(), NullLogger.Instance), + hooks: null, dataPlaneAuthority: authority)); + healthy.Tell(Rebuild(RawTagArtifact(2))); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + authority.Applies.ShouldBe(1); + } + + [Fact] + public void A_bytes_less_rebuild_arriving_mid_plan_answers_after_the_plan_ahead_of_it() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + + var first = Rebuild(RawTagArtifact(2)); + var second = new OpcUaPublishActor.RebuildAddressSpace(CorrelationId.NewId(), DeploymentId.NewId(), Artifact: null, ReportOutcome: true); + actor.Tell(first); + actor.Tell(second); + ExpectNoMsg(TimeSpan.FromMilliseconds(300)); // the bytes-less one must not overtake the held plan + + gate.Set(); + var a = ExpectMsg(PlanBudget); + var b = ExpectMsg(PlanBudget); + a.Correlation.ShouldBe(first.Correlation); + a.Degraded.ShouldBeFalse(); + b.Correlation.ShouldBe(second.Correlation); + b.Degraded.ShouldBeTrue(); + } + + [Fact] + public void A_plan_piped_back_to_a_restarted_actor_is_not_applied() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var childProps = OpcUaPublishActor.PropsForTests(sink, applier, new OpcUaPublishActor.TestHooks { BeforePlan = () => gate.Wait() }); + var parent = Sys.ActorOf(Props.Create(() => new RestartingParent(childProps))); + parent.Tell(RestartingParent.GetChild.Instance); + var child = ExpectMsg(); + + var stale = Rebuild(RawTagArtifact(3)); + child.Tell(stale); + child.Tell(Kill.Instance); // the parent's strategy RESTARTS on any failure, ActorKilledException included + ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + + gate.Set(); + var answer = ExpectMsg(PlanBudget); + answer.Correlation.ShouldBe(stale.Correlation); + answer.Degraded.ShouldBeTrue("the requester is still answered"); + sink.Exists(NewTag).ShouldBeFalse("a plan from the previous incarnation must not be applied"); + + // Positive control: the restarted instance applies a fresh rebuild. + child.Tell(Rebuild(RawTagArtifact(3))); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + sink.Exists(NewTag).ShouldBeTrue(); + } + + // ───────────────────────────── helpers ───────────────────────────── + + private IActorRef Spawn(ExistenceSink sink, ManualResetEventSlim gate) => + Sys.ActorOf(OpcUaPublishActor.PropsForTests( + sink, new AddressSpaceApplier(sink, NullLogger.Instance), + new OpcUaPublishActor.TestHooks { BeforePlan = () => gate.Wait() })); + + private static OpcUaPublishActor.RebuildAddressSpace Rebuild(byte[] artifact) => + new(CorrelationId.NewId(), DeploymentId.NewId(), artifact, ReportOutcome: true); + + private static byte[] RawTagArtifact(int tagCount) => + JsonSerializer.SerializeToUtf8Bytes(new + { + UnsAreas = Array.Empty(), + UnsLines = Array.Empty(), + Equipment = Array.Empty(), + RawFolders = new[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } }, + Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, + TagGroups = Array.Empty(), + Tags = Enumerable.Range(0, tagCount).Select(i => new + { + TagId = $"t-{i}", DeviceId = "dev-1", TagGroupId = (string?)null, Name = $"T{i}", + DataType = "Float", AccessLevel = 0, TagConfig = "{}", + }).ToArray(), + UnsTagReferences = Array.Empty(), + ScriptedAlarms = Array.Empty(), + }); + + private static byte[] AlarmArtifact() => + JsonSerializer.SerializeToUtf8Bytes(new + { + UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } }, + UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } }, + Equipment = new[] + { + new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = "Eq", MachineCode = "EQ-1" }, + }, + DriverInstances = Array.Empty(), + Tags = Array.Empty(), + ScriptedAlarms = new[] + { + new + { + ScriptedAlarmId = "al-1", EquipmentId = "eq-1", Name = "High", AlarmType = "AlarmCondition", + Severity = 500, MessageTemplate = "high", PredicateScriptId = "s-1", HistorizeToAveva = false, + Retain = true, Enabled = true, + }, + }, + Scripts = new[] { new { ScriptId = "s-1", SourceCode = "return false;" } }, + }); + + /// Restarts its one child on any failure, and lets the test reach the child. + private sealed class RestartingParent : ReceiveActor + { + public sealed class GetChild { public static readonly GetChild Instance = new(); } + + public RestartingParent(Props childProps) + { + var child = Context.ActorOf(childProps, "publish"); + Receive(_ => Sender.Tell(child)); + } + + protected override SupervisorStrategy SupervisorStrategy() => + new OneForOneStrategy(_ => Directive.Restart); + } + + /// An authority sink that counts installs. + private sealed class RecordingAuthoritySink : IDataPlaneAuthoritySink + { + private int _applies; + public int Applies => Volatile.Read(ref _applies); + + public DataPlaneAuthoritySnapshot Apply( + string? clusterId, string revision, IReadOnlyList keys, + IReadOnlyList grants, + IReadOnlyDictionary> scopePaths) + { + Interlocked.Increment(ref _applies); + return new DataPlaneAuthorityState().Apply(clusterId, revision, keys, grants, scopePaths); + } + } + + /// + /// Models the node manager's non-creating writes: a write to a node that was never ensured (or was removed) + /// is skipped and recorded; a full sink rebuild forgets every node. counts the + /// items written after an apply closed and before the next apply starts — the actor's replay, since the + /// tests send nothing between releasing a plan and its answer. + /// + private sealed class ExistenceSink : IOpcUaAddressSpaceSink, ISurgicalAddressSpaceSink + { + private readonly object _gate = new(); + private readonly HashSet _variables = new(); + private readonly HashSet _conditions = new(); + private readonly Dictionary _values = new(); + private readonly Dictionary _alarmStates = new(); + private readonly Dictionary _alarmQualities = new(); + + public ConcurrentQueue SkippedWrites { get; } = new(); + public ConcurrentQueue SkippedAlarms { get; } = new(); + public int ReplayedItems; + + public bool Exists(string id) { lock (_gate) return _variables.Contains(id); } + public bool ConditionExists(string id) { lock (_gate) return _conditions.Contains(id); } + public (object? Value, uint Status, DateTime At)? ValueOf(string id) { lock (_gate) return _values.TryGetValue(id, out var v) ? v : null; } + public AlarmConditionSnapshot? AlarmStateOf(string id) { lock (_gate) return _alarmStates.GetValueOrDefault(id); } + public uint? AlarmQualityOf(string id) { lock (_gate) return _alarmQualities.TryGetValue(id, out var q) ? q : null; } + + public void WriteValue(string nodeId, object? value, uint statusCode, DateTime ts, AddressSpaceRealm realm) => + WriteValues([new ValueWrite(nodeId, value, statusCode, ts, realm)]); + + public void WriteValues(IReadOnlyList values) + { + lock (_gate) + { + if (_applyClosed) ReplayedItems += values.Count; + foreach (var v in values) + { + if (!_variables.Contains(v.NodeId)) { SkippedWrites.Enqueue(v.NodeId); continue; } + _values[v.NodeId] = (v.Value, v.StatusCode, v.SourceTimestampUtc); + } + } + } + + public void WriteStatuses(IReadOnlyList statuses) + { + lock (_gate) + { + if (_applyClosed) ReplayedItems += statuses.Count; + foreach (var s in statuses) + { + if (!_variables.Contains(s.NodeId)) { SkippedWrites.Enqueue(s.NodeId); continue; } + var current = _values.GetValueOrDefault(s.NodeId); + _values[s.NodeId] = (current.Value, s.StatusCode, s.SourceTimestampUtc); + } + } + } + + public void WriteAlarmCondition(string alarmNodeId, AlarmConditionSnapshot state, DateTime ts, AddressSpaceRealm realm) + { + lock (_gate) + { + if (!_conditions.Contains(alarmNodeId)) { SkippedAlarms.Enqueue(alarmNodeId); return; } + _alarmStates[alarmNodeId] = state; + } + } + + public void WriteAlarmQuality(string alarmNodeId, uint statusCode, DateTime ts, AddressSpaceRealm realm) + { + lock (_gate) + { + if (!_conditions.Contains(alarmNodeId)) { SkippedAlarms.Enqueue(alarmNodeId); return; } + _alarmQualities[alarmNodeId] = statusCode; + } + } + + public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) + { + lock (_gate) { _applyClosed = false; _conditions.Add(alarmNodeId); } + } + + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) + { + lock (_gate) _applyClosed = false; + } + + public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) + { + lock (_gate) { _applyClosed = false; _variables.Add(variableNodeId); } + } + + public void RebuildAddressSpace() + { + lock (_gate) + { + _applyClosed = false; + _variables.Clear(); + _conditions.Clear(); + _values.Clear(); + _alarmStates.Clear(); + _alarmQualities.Clear(); + } + } + + private bool _applyClosed; + + public void CompleteAddressSpaceApply() + { + lock (_gate) _applyClosed = true; + } + + public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { } + public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } + public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) => true; + public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm) => true; + public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm) { lock (_gate) return _variables.Remove(variableNodeId); } + public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm) { lock (_gate) return _conditions.Remove(alarmNodeId); } + public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm) => true; + } +} From d2cc0715178c7ba94fab34d70397a446c165d413 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:31:03 -0400 Subject: [PATCH 26/67] feat(scripting): expose the run token to scripts as ctx.CancellationToken ScriptContext.CancellationToken is backed by an AsyncLocal that ScriptEvaluator.RunAsync scopes around the compiled delegate, so it is exactly the token ScriptRunGuard cancels on timeout. A polling script can now end its own abandoned run and free the slot. AsyncLocal rather than a field because the scripted-alarm engine reuses one context per alarm: an orphan keeps seeing its own cancelled token. The scoping entry point is internal (IVT to Core.Scripting) so a script cannot replace its own token. Editor parity: ScriptAnalysisService dot-completion now walks the receiver's base chain (instance members only, stopping before System.Object). Before this, a member declared only on ScriptContext compiled and published but never completed. DiagnoseTests pins the member as allow-listed; CompletionTests pins the completion. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- docs/ScriptEditor.md | 7 ++ .../ScriptContext.cs | 43 ++++++++++++ ...OtOpcUa.Core.Scripting.Abstractions.csproj | 6 ++ .../ScriptEvaluator.cs | 8 ++- .../ScriptAnalysis/ScriptAnalysisService.cs | 9 ++- .../ScriptCancellationTokenTests.cs | 67 +++++++++++++++++++ .../ScriptAnalysis/CompletionTests.cs | 1 + .../ScriptAnalysis/DiagnoseTests.cs | 1 + 8 files changed, 140 insertions(+), 2 deletions(-) create mode 100644 tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptCancellationTokenTests.cs diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md index eb6985e2..b6472f00 100644 --- a/docs/ScriptEditor.md +++ b/docs/ScriptEditor.md @@ -198,6 +198,13 @@ scripted-alarm predicates) runs scripts through the shared `ScriptRunGuard`: 2 s default) quarantines immediately, through the same path (Error log, metric `reason=stuck`, Bad). A short overrun (a young orphan) stays a plain refusal and the script resumes once the orphan completes. +- **Cooperative cancellation (`ctx.CancellationToken`).** The guard cancels the run + token when a run times out, and scripts see it as `ctx.CancellationToken`. A + long-running loop that polls `ctx.CancellationToken.IsCancellationRequested` (or calls + `ctx.CancellationToken.ThrowIfCancellationRequested()`) ends its own abandoned run + and frees the slot, instead of holding a pool thread until it returns. A script that + ignores the token is still an orphan. The editor completes the member, and + `CancellationToken` is on the sandbox allow-list. - **Allocation budget.** A single run allocating more than `Scripting:MaxAllocBytesPerRun` (64 MB) is quarantined. This is sampled after the run returns, so a run that OOMs the process first is not stopped — that needs an diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptContext.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptContext.cs index 6a73c622..e293ff98 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptContext.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptContext.cs @@ -80,6 +80,49 @@ public abstract class ScriptContext /// public abstract ILogger Logger { get; } + /// + /// The current run's cancellation token. The run guard cancels it when the run exceeds its wall-clock + /// budget, so a long-running script can poll ctx.CancellationToken.IsCancellationRequested (or call + /// ThrowIfCancellationRequested()) and end its own abandoned run instead of holding a pool thread + /// until it returns. Cooperative only: a script that never looks at it still runs to completion as an + /// orphan. outside a run. + /// + /// + /// Scoped per run through an , not stored on the context, because a context + /// object can be reused across runs (the scripted-alarm engine keeps one per alarm). An abandoned orphan + /// therefore keeps seeing its own cancelled token even after a newer run starts on the same context. + /// + public CancellationToken CancellationToken => s_runToken.Value; + + private static readonly AsyncLocal s_runToken = new(); + + /// + /// Makes the script-visible on the calling + /// execution context until the returned scope is disposed. Called by ScriptEvaluator.RunAsync + /// around the compiled delegate. + /// + /// The run token. + /// A scope that restores the previous token. + internal static RunTokenScope EnterRun(CancellationToken token) + { + var previous = s_runToken.Value; + s_runToken.Value = token; + return new RunTokenScope(previous); + } + + /// Restores the token that was current before . + internal readonly struct RunTokenScope : IDisposable + { + private readonly CancellationToken _previous; + + /// Captures the token to restore. + /// The token current before the run. + public RunTokenScope(CancellationToken previous) => _previous = previous; + + /// + public void Dispose() => s_runToken.Value = _previous; + } + /// /// Deadband helper — returns true when differs /// from by more than . diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj index f9305356..0f07e95c 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions.csproj @@ -16,4 +16,10 @@ + + + + + diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs index c7ac8280..a33fba6f 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting/ScriptEvaluator.cs @@ -185,7 +185,13 @@ public sealed class ScriptEvaluator : IDisposable // TResult directly). We surface a Task only to keep the existing // RunAsync contract consumers depend on. TimedScriptEvaluator wraps this in // Task.Run so a long-running script still honours its wall-clock budget. - var result = _func(globals); + // The run token is the script-visible ctx.CancellationToken for exactly this call. ScriptRunGuard passes + // the token it cancels on timeout, so a polling script can end its own abandoned run. + TResult result; + using (ScriptContext.EnterRun(ct)) + { + result = _func(globals); + } return Task.FromResult(result); } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs index ba69b0da..9df80b09 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -268,7 +268,14 @@ public sealed class ScriptAnalysisService var typeInfo = model.GetTypeInfo(memberAccess.Expression); var type = typeInfo.Type ?? typeInfo.ConvertedType; if (type == null) return null; - return type.GetMembers() + // The type's own members, then the INSTANCE members it inherits (stopping before System.Object, whose + // ToString/GetHashCode noise was never offered). Without the base chain, a member declared only on + // ScriptContext — ctx.CancellationToken — compiled and published but never completed. Inherited statics + // (ScriptContext.Deadband) are left out: they are not reachable through an instance receiver. + var inherited = new List(); + for (var b = type.BaseType; b is not null && b.SpecialType != SpecialType.System_Object; b = b.BaseType) + inherited.AddRange(b.GetMembers().Where(m => !m.IsStatic)); + return type.GetMembers().Concat(inherited) .Where(m => m.CanBeReferencedByName && !m.IsImplicitlyDeclared) // NotApplicable covers members (e.g. some BCL/special members) that carry no explicit accessibility but are referenceable. .Where(m => m.DeclaredAccessibility == Accessibility.Public || m.DeclaredAccessibility == Accessibility.NotApplicable) diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptCancellationTokenTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptCancellationTokenTests.cs new file mode 100644 index 00000000..a465d2d4 --- /dev/null +++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests/ScriptCancellationTokenTests.cs @@ -0,0 +1,67 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Core.Scripting.Tests; + +/// +/// ctx.CancellationToken — the run token, visible to scripts. already +/// cancels the token it hands when a run times out; +/// a script that polls it can now end its own abandoned run instead of burning a pool thread until it +/// returns. Cooperative only: a script that ignores it is still an orphan. +/// +[Trait("Category", "Unit")] +public sealed class ScriptCancellationTokenTests +{ + /// Spins until cancelled, but ends by itself after 10 s so a broken implementation cannot pin a + /// pool thread past the test. + private const string CooperativeSpin = + """ + var end = System.DateTime.UtcNow.AddSeconds(10); + while (!ctx.CancellationToken.IsCancellationRequested && System.DateTime.UtcNow < end) { } + return ctx.CancellationToken.IsCancellationRequested ? -1 : 1; + """; + + [Fact] + public async Task A_script_sees_an_uncancelled_token_on_a_normal_run() + { + using var eval = ScriptEvaluator.Compile( + "return ctx.CancellationToken.CanBeCanceled && !ctx.CancellationToken.IsCancellationRequested;"); + using var cts = new CancellationTokenSource(); + + (await eval.RunAsync(new FakeScriptContext(), cts.Token)).ShouldBeTrue(); + } + + [Fact] + public async Task Outside_a_run_the_token_is_None() + { + using var eval = ScriptEvaluator.Compile("return 1;"); + var ctx = new FakeScriptContext(); + (await eval.RunAsync(ctx)).ShouldBe(1); + + ctx.CancellationToken.ShouldBe(CancellationToken.None, "the token is scoped to the run, not left on the context"); + } + + [Fact] + public async Task A_timed_out_run_whose_script_polls_the_token_ends_its_own_orphan() + { + var guard = new ScriptRunGuard(); + using var eval = ScriptEvaluator.Compile(CooperativeSpin); + var ct = TestContext.Current.CancellationToken; + + await Should.ThrowAsync( + () => guard.RunAsync("k", CooperativeSpin, eval, new FakeScriptContext(), TimeSpan.FromMilliseconds(100), ct)); + + // The guard cancelled the run token on timeout; the script saw it and returned, which frees the slot well + // inside its own 10 s fallback. Without ctx.CancellationToken the orphan would hold it for the full 10 s. + var deadline = DateTime.UtcNow.AddSeconds(3); + while (guard.InFlightCount != 0 && DateTime.UtcNow < deadline) + await Task.Delay(20, ct); + guard.InFlightCount.ShouldBe(0, "the cooperative script ended its abandoned run"); + + // Positive control: the slot is free, so the next run is dispatched rather than refused. Same source + // identity on purpose: a different one would reset the key and dispatch even behind a live orphan. + using var fast = ScriptEvaluator.Compile("return 5;"); + (await guard.RunAsync("k", CooperativeSpin, fast, new FakeScriptContext(), TimeSpan.FromSeconds(5), ct)) + .ShouldBe(5); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs index 63b319da..be424a2a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/CompletionTests.cs @@ -18,6 +18,7 @@ public sealed class CompletionTests labels.ShouldContain("SetVirtualTag"); labels.ShouldContain("Now"); labels.ShouldContain("Logger"); + labels.ShouldContain("CancellationToken"); } [Fact] public async Task Scope_completion_includes_the_ctx_local() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs index 94784a45..27c8679e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/ScriptAnalysis/DiagnoseTests.cs @@ -48,6 +48,7 @@ public sealed class DiagnoseTests [InlineData("""var x = 41; System.Threading.Interlocked.Increment(ref x); return x;""")] [InlineData("""var x = 42; return System.Threading.Volatile.Read(ref x);""")] [InlineData("""object o = 42; return o.GetType().IsValueType ? 1 : 0;""")] + [InlineData("""ctx.CancellationToken.ThrowIfCancellationRequested(); return ctx.CancellationToken.IsCancellationRequested ? 1 : 0;""")] public void Allow_listed_forms_are_not_flagged_in_the_editor(string code) => Diag(code).ShouldBeEmpty(); From fc94b876341dde05c1cff42de274fb4fd04d874c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:31:32 -0400 Subject: [PATCH 27/67] perf(abcip): cache the declaration-only UDT layout per member list P11: AbCipUdtReadPlanner called AbCipUdtMemberLayout.TryBuild(parent.Members) for every member reference on every poll - O(members^2) per parent per tick, a dictionary allocation each time. GetOrBuild caches the layout (including a "not groupable" verdict) in a ConditionalWeakTable keyed by the member list's identity, which is immutable for the life of the tag table; a re-init's new lists replace the entries and the old ones are collected with them. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipUdtMemberLayout.cs | 24 +++++++++++++++++++ .../AbCipUdtReadPlanner.cs | 2 +- .../AbCipUdtMemberLayoutTests.cs | 22 +++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs index b4b271f3..3c44f80f 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs @@ -25,6 +25,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// public static class AbCipUdtMemberLayout { + /// Layouts already computed, keyed by the parent definition's member list (reference identity). + private static readonly System.Runtime.CompilerServices.ConditionalWeakTable, LayoutBox> Cache = new(); + + /// Holds a computed layout, including a "not groupable" (null) verdict. + private sealed class LayoutBox(IReadOnlyDictionary? offsets) + { + public IReadOnlyDictionary? Offsets { get; } = offsets; + } + + /// + /// , computed once per member list (review 07 P11: the read planner rebuilt the layout — + /// a dictionary allocation and an O(members) walk — for every member reference on every poll, O(members²) per + /// parent per tick). A parent definition's member list is immutable for the life of the tag table that holds + /// it, so caching by list identity is exact; a re-init builds new lists and the old entries are collected with + /// them (). + /// + /// The parent definition's declared members. + /// The member offsets, or null when the members cannot be laid out by declaration. + public static IReadOnlyDictionary? GetOrBuild(IReadOnlyList members) + { + ArgumentNullException.ThrowIfNull(members); + return Cache.GetValue(members, m => new LayoutBox(TryBuild(m))).Offsets; + } + /// /// Try to compute member offsets for the supplied declared members. Returns null /// if any member type is unsupported for declaration-only layout. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs index 28bc181f..338d905e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs @@ -67,7 +67,7 @@ public static class AbCipUdtReadPlanner continue; } - var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members); + var offsets = AbCipUdtMemberLayout.GetOrBuild(parent.Members); if (offsets is null || !offsets.TryGetValue(memberName, out var offset)) { fallback.Add(new AbCipUdtReadFallback(i, name)); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberLayoutTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberLayoutTests.cs index b895b2cc..4841c4f6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberLayoutTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipUdtMemberLayoutTests.cs @@ -99,4 +99,26 @@ public sealed class AbCipUdtMemberLayoutTests new AbCipStructureMember("Setpoints", AbCipDataType.Real, ElementCount: 4), }).ShouldBeNull(); } + + /// Review 07 P11: the layout is computed once per member list, not once per member per poll. + [Fact] + public void GetOrBuild_computes_a_layout_once_per_member_list() + { + IReadOnlyList members = + [ + new AbCipStructureMember("A", AbCipDataType.DInt), + new AbCipStructureMember("B", AbCipDataType.Real), + ]; + var first = AbCipUdtMemberLayout.GetOrBuild(members); + first.ShouldNotBeNull(); + first["B"].ShouldBe(4); + AbCipUdtMemberLayout.GetOrBuild(members).ShouldBeSameAs(first); + + // Positive control: a different (equal-content) list is its own entry, and a not-groupable verdict caches too. + IReadOnlyList copy = [.. members]; + AbCipUdtMemberLayout.GetOrBuild(copy).ShouldNotBeSameAs(first); + IReadOnlyList withBool = [new AbCipStructureMember("F", AbCipDataType.Bool)]; + AbCipUdtMemberLayout.GetOrBuild(withBool).ShouldBeNull(); + AbCipUdtMemberLayout.GetOrBuild(withBool).ShouldBeNull(); + } } From aabe027d10c963d3ddd60ed464c66c29bf6b38b2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:32:31 -0400 Subject: [PATCH 28/67] test(abcip): native end-to-end proof a NotFound tag causes no lease churn L4 evidence over the real native runtime and a fake CIP target: a renamed tag that is the device's only lease holder is created once and Forward-Opened once across five polls, with no Forward Close, every poll BadNodeIdUnknown and the driver Healthy. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipDeviceRefusalTests.cs | 47 +++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDeviceRefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDeviceRefusalTests.cs index 2cb690f5..6803d3ab 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDeviceRefusalTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDeviceRefusalTests.cs @@ -2,6 +2,7 @@ using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes; namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; @@ -178,4 +179,50 @@ public sealed class AbCipDeviceRefusalTests factory.Tags["ReadOnlyTag"].Disposed.ShouldBeFalse(); drv.GetHealth().State.ShouldBe(DriverState.Healthy); } + + /// + /// L4 end to end over the REAL native runtime + a private pool: a renamed tag (CIP 0x04) that is the device's + /// ONLY lease holder used to be evicted every poll — the last release, so Forward Close, then a throttled Forward + /// Open on the next poll. One runtime and one Forward Open across N polls and no Forward Close proves no churn. + /// + [Fact] + public async Task A_native_NotFound_tag_causes_no_runtime_or_connection_churn() + { + await using var target = new FakeCipTarget(); + target.ConnectedHandler = req => Task.FromResult( + req[0] == 0x52 ? [0xD2, 0x00, 0x04, 0x00] : target.ServiceReply(req)); + var host = $"ab://127.0.0.1:{target.Port}/1,0"; + var creates = 0; + var native = new NativeAbCipTagFactory(new DeviceSessionPool()); + var factory = new CountingFactory(p => { Interlocked.Increment(ref creates); return native.Create(p); }); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(host, AbCipPlcFamily.ControlLogix)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Renamed", host, "Renamed", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions { Enabled = false }, + Timeout = TimeSpan.FromSeconds(5), + }, "abcip-l4-native", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + try + { + for (var n = 0; n < 5; n++) + (await drv.ReadAsync(["Renamed"], CancellationToken.None))[0].StatusCode + .ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); + + Volatile.Read(ref creates).ShouldBe(1); + target.ForwardOpens.ShouldBe(1); + target.ForwardCloses.ShouldBe(0); + target.ConnectedRequests.ShouldBeGreaterThanOrEqualTo(5); // positive control: every poll hit the wire + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + finally + { + await drv.DisposeAsync(); + } + } + + private sealed class CountingFactory(Func create) : IAbCipTagFactory + { + public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams); + } } From a33145fe71c1758bbfcc22e5656dd2707466a965 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:32:53 -0400 Subject: [PATCH 29/67] refactor(alarms): spawn the scripted-alarm host through its factory Props DriverHostActor now hands ScriptedAlarmHostActor an engine factory instead of a pre-built engine, so every incarnation (a supervision restart included) builds, owns and disposes its own upstream + engine. The legacy overload was already restart-safe in the detach sense, so this is hygiene rather than a live hole; the factory path is the one ScriptedAlarmHostActorRestartTests covers end to end. The closure captures locals only and carries the node-wide ScriptingOptions. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../Drivers/DriverHostActor.cs | 24 +++++++++++++------ 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index eee3a095..17a43f9d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -1157,8 +1157,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS /// null and ApplyScriptedAlarms becomes a no-op. The engine is built around a fresh /// + the wired (the /// replicated LocalDbAlarmConditionStateStore on a driver-role node); when no store was - /// wired the host is skipped. The host (spawned as a child) owns + disposes the engine in its - /// PostStop, so it stops with the driver host. + /// wired the host is skipped. The host is spawned through the factory Props, so EACH + /// incarnation (a supervision restart included) builds, owns and disposes its own engine; it stops + /// with the driver host. /// private void SpawnScriptedAlarmHost() { @@ -1189,14 +1190,23 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS return; } + // Factory Props: every incarnation of the host (a supervision restart included) builds and owns a fresh + // (upstream, engine) pair, so a restart never reuses an engine the previous incarnation disposed or left + // half-loaded. The closure captures locals only; it runs in the child's constructor. var store = _alarmStateStore; - var upstream = new DependencyMuxTagUpstreamSource(); - var engine = new ScriptedAlarmEngine( - upstream, store, new ScriptLoggerFactory(_scriptRootLogger.Logger), _scriptRootLogger.Logger, - scripting: _scripting); + var scriptLogger = _scriptRootLogger.Logger; + var scripting = _scripting; _scriptedAlarmHost = Context.ActorOf( ScriptedAlarmHostActor.Props( - _opcUaPublishActor, _dependencyMux, upstream, engine, _localNode, telemetryHub: _telemetryHub), + _opcUaPublishActor, _dependencyMux, + () => + { + var upstream = new DependencyMuxTagUpstreamSource(); + var engine = new ScriptedAlarmEngine( + upstream, store, new ScriptLoggerFactory(scriptLogger), scriptLogger, scripting: scripting); + return (upstream, engine); + }, + _localNode, telemetryHub: _telemetryHub), "scripted-alarm-host"); } From e5527d02017137b906e1a1ef8c0f9ea7438ba078 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:33:54 -0400 Subject: [PATCH 30/67] =?UTF-8?q?fix(ablegacy):=20isolate=20a=20dead=20dev?= =?UTF-8?q?ice=20=E2=80=94=20parallel=20devices,=20per-device=20backoff,?= =?UTF-8?q?=20single-flight=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 07 P7 (High): reads walked every reference of every device serially with no backoff, so a dead device cost Timeout per tag per poll (50 tags x 2 s = 100 s) and starved every healthy device long enough to trip the comms-loss watchdog against a reachable one. - ReadAsync buckets references by device and reads up to 8 devices concurrently; each device's tags are still read in order. - Each device has a ConnectionBackoff (1 s doubling to 30 s). Two consecutive comms faults mark it down: the rest of that pass and every later pass fail fast with BadCommunicationError and touch no wire until the window expires or any answer (data path or probe) resets it. One timing-out tag does not blacken its answering siblings. - Cold connects are single-flight per device (ConnectGate), replacing the per-key creation locks; a cached handle never touches the gate. - A status/exception proving the device ANSWERED with a deterministic refusal (not found, not allowed, out of bounds, type/size mismatch) is per-tag Bad, keeps its handle, warns once per tag and advances LastSuccessfulRead instead of evicting and degrading. Writes classify the same way. - Health is written once per read: Degraded if any device faulted, Healthy if anything read Good, LastSuccessfulRead advanced when any device answered. Adds an env-gated ab_server case with a blackholed sibling device. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriver.cs | 574 +++++++++++++----- .../AbLegacyDeadDeviceIsolationTests.cs | 65 ++ .../AbLegacyDeviceIsolationTests.cs | 259 ++++++++ 3 files changed, 738 insertions(+), 160 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 97754d94..60da49b5 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -423,7 +423,50 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover // ---- IReadable ---- + /// + /// How many devices one reads concurrently. Each device is still read serially (its + /// tags share one PCCC connection, and SLC/MicroLogix controllers accept few), so this bounds the number of + /// devices — not requests — in flight, which is what keeps a dead device from starving a healthy one. + /// + internal const int MaxConcurrentDevices = 8; + + /// + /// Consecutive comms faults within one device that mark it DOWN (opening its backoff window and failing the + /// rest of the pass fast). Two, not one: a single tag that times out (an odd address, a slow file) must not + /// blacken every sibling on a device that is answering. + /// + internal const int FaultsToMarkDeviceDown = 2; + + /// One resolved reference queued for its device's pass. + private sealed record ReadItem(int Index, string Reference, AbLegacyTagDefinition Def, AbLegacyAddress Parsed); + + /// + /// What one wire operation proved. = the device replied (a Good value OR a + /// deterministic device refusal such as "file not found") — the evidence that advances + /// LastSuccessfulRead; = a comms failure (timeout, dropped connection, garbage + /// reply); = failed fast inside the device's backoff window (Bad, Degraded, but no new + /// evidence for the backoff); = nothing reached the wire or it says nothing about + /// reachability (a local refusal, a bad index value). + /// + private enum OpVerdict { Neutral, Answered, Fault, Throttled } + + /// The health evidence one device pass produced (collected, then written once per read). + private struct DeviceReadOutcome + { + public bool AnyGood; + public bool AnyAnswered; + public string? FaultMessage; + } + /// + /// + /// Review 07 P7: references are bucketed by device and the devices are read concurrently (at most + /// ); within a device the tags are read in order. A device that produces + /// consecutive comms faults opens its + /// window and every remaining (and every later) read on it fails fast with BadCommunicationError, + /// touching no wire, until the window expires or the probe sees it answer. Health is written ONCE per call: + /// worst outcome wins, and LastSuccessfulRead advances when ANY device answered. + /// public async Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { @@ -431,124 +474,255 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover var epoch = CurrentEpoch; var now = DateTime.UtcNow; var results = new DataValueSnapshot[fullReferences.Count]; + var devices = _devices; // one snapshot of the published table for the whole call + var buckets = new Dictionary>(ReferenceEqualityComparer.Instance); for (var i = 0; i < fullReferences.Count; i++) { var reference = fullReferences[i]; - if (!_resolver.TryResolve(reference, out var def)) + if (!_resolver.TryResolve(reference, out var def) || !devices.TryGetValue(def.DeviceHostAddress, out var device)) { results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); continue; } - if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); + if (parsed is null) { + WarnMalformedOnce(device, def); results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); continue; } + if (!buckets.TryGetValue(device, out var list)) buckets[device] = list = []; + list.Add(new ReadItem(i, reference, def, parsed)); + } + if (buckets.Count == 0) return results; - try + var passes = buckets.ToArray(); + var outcomes = new DeviceReadOutcome[passes.Length]; + using (var window = new SemaphoreSlim(MaxConcurrentDevices, MaxConcurrentDevices)) + { + var tasks = new Task[passes.Length]; + for (var d = 0; d < passes.Length; d++) + tasks[d] = RunPassAsync(d); + // WhenAll completes only once every pass has finished (it never short-circuits on a fault), so the + // window cannot be disposed under a pass still waiting on it. + await Task.WhenAll(tasks).ConfigureAwait(false); + + async Task RunPassAsync(int d) { - var preParsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); - if (preParsed is null) - { - WarnMalformedOnce(device, def); - results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); - continue; - } - if (preParsed.IsIndirect) - { - // Indirect: read the index word(s) NOW (never cached - the index can change between - // polls), compose the concrete address, then run the normal path against it. - var res = await ResolveIndirectAsync(device, def, preParsed, cancellationToken).ConfigureAwait(false); - if (res.Def is null) - { - results[i] = new DataValueSnapshot(null, res.Status, null, now); - if (res.TransportFault) - SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, - $"transport fault resolving indirect address for {reference}")); - continue; - } - def = res.Def; - } - - var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); - - int status; - object? value; - // Serialise the Read → GetStatus → DecodeValue sequence against the shared - // runtime — the server read path and the poll loop both call ReadAsync against - // the same cached libplctag Tag handle, which is not concurrency-safe. - var opLock = device.GetRuntimeLock(def.Name); - await opLock.WaitAsync(cancellationToken).ConfigureAwait(false); + await window.WaitAsync(cancellationToken).ConfigureAwait(false); try { - await runtime.ReadAsync(cancellationToken).ConfigureAwait(false); - status = runtime.GetStatus(); - var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); - if (status != 0) - value = null; - else if (IsArrayTag(def)) - value = runtime.DecodeArray(def.DataType, EffectiveArrayLength(def)); - else - value = runtime.DecodeValue(def.DataType, parsed?.BitIndex); + outcomes[d] = await ReadDevicePassAsync(passes[d].Key, passes[d].Value, results, now, cancellationToken) + .ConfigureAwait(false); } finally { - opLock.Release(); + window.Release(); } - - if (status != 0) - { - // Evict the stale handle so the next call re-creates it (mirrors AbCip). - EvictRuntime(device, def.Name); - results[i] = new DataValueSnapshot(null, - AbLegacyStatusMapper.MapStatus(status), null, now); - SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, - $"transport status {status} reading {reference}")); - if (!device.FirstNonZeroStatusLogged) - { - device.FirstNonZeroStatusLogged = true; - _logger.LogWarning( - "AbLegacy non-zero transport status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}", - _driverInstanceId, def.DeviceHostAddress, reference, status); - } - continue; - } - // Healthy read — re-arm the per-device first-failure log so a future non-zero - // status logs again rather than being suppressed by an old flag from a prior outage. - device.FirstNonZeroStatusLogged = false; - - results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now); - SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Healthy, now, null)); - } - catch (OperationCanceledException) { throw; } - catch (AbTransportException ex) when (IsLocalRefusal(ex)) - { - // The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read). - // Not a comms fault: BadNotSupported, keep the handle, leave health alone. - WarnRefusalOnce(device, def, "read", ex.Message); - results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); - } - catch (NotSupportedException nse) - { - // A deterministic decode refusal (review 07 H16) — the same answer every poll. BadNotSupported, keep - // the handle (re-creating it only re-paid the init read), warn once, and NO health write. - WarnRefusalOnce(device, def, "read", nse.Message); - results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); - } - catch (Exception ex) - { - // Transport exception — evict so the next read creates a fresh handle (mirrors AbCip). - EvictRuntime(device, def.Name); - results[i] = new DataValueSnapshot(null, - AbLegacyStatusMapper.BadCommunicationError, null, now); - SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message)); } } + ApplyReadHealth(epoch, outcomes, now); return results; } + /// + /// Writes the read's health once, after every device pass: Degraded if any pass hit a comms fault, else + /// Healthy if anything read Good; LastSuccessfulRead advances when any device answered (a Good read or a + /// deterministic device refusal — both prove the device is reachable). A read that reached no device's + /// wire, or only drew refusals, does not change the state. + /// + private void ApplyReadHealth(long epoch, DeviceReadOutcome[] outcomes, DateTime now) + { + var anyGood = false; + var anyAnswered = false; + string? fault = null; + foreach (var o in outcomes) + { + anyGood |= o.AnyGood; + anyAnswered |= o.AnyAnswered; + fault ??= o.FaultMessage; + } + if (fault is null && !anyGood && !anyAnswered) return; + + lock (_lifecycleLock) + { + if (epoch != _lifecycleEpoch) return; + var current = _health; + var lastRead = anyAnswered ? now : current.LastSuccessfulRead; + if (fault is not null) + _health = new DriverHealth(DriverState.Degraded, lastRead, fault); + else if (anyGood) + _health = new DriverHealth(DriverState.Healthy, lastRead, null); + else + _health = current with { LastSuccessfulRead = lastRead }; + } + } + + /// Reads one device's references in order, failing fast once the device is marked down. + private async Task ReadDevicePassAsync( + DeviceState device, List items, DataValueSnapshot[] results, DateTime now, CancellationToken ct) + { + var outcome = new DeviceReadOutcome(); + foreach (var item in items) + { + if (device.IsThrottled(DateTime.UtcNow)) + { + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); + outcome.FaultMessage ??= $"device {device.Options.HostAddress} is in its reconnect backoff window"; + continue; + } + + var (verdict, message) = await ReadOneAsync(device, item, results, now, ct).ConfigureAwait(false); + switch (verdict) + { + case OpVerdict.Answered: + outcome.AnyAnswered = true; + if (results[item.Index].StatusCode == AbLegacyStatusMapper.Good) outcome.AnyGood = true; + device.RecordAnswered(); + break; + case OpVerdict.Fault: + outcome.FaultMessage ??= message; + device.RecordCommsFault(DateTime.UtcNow); + break; + case OpVerdict.Throttled: + outcome.FaultMessage ??= message; + break; + } + } + return outcome; + } + + /// Reads one reference; writes its snapshot and reports what the operation proved. + private async Task<(OpVerdict Verdict, string? Message)> ReadOneAsync( + DeviceState device, ReadItem item, DataValueSnapshot[] results, DateTime now, CancellationToken ct) + { + var def = item.Def; + var reference = item.Reference; + try + { + if (item.Parsed.IsIndirect) + { + // Indirect: read the index word(s) NOW (never cached - the index can change between + // polls), compose the concrete address, then run the normal path against it. + var res = await ResolveIndirectAsync(device, def, item.Parsed, ct).ConfigureAwait(false); + if (res.Def is null) + { + results[item.Index] = new DataValueSnapshot(null, res.Status, null, now); + return res.TransportFault + ? (OpVerdict.Fault, $"transport fault resolving indirect address for {reference}") + : (OpVerdict.Neutral, null); + } + def = res.Def; + } + + var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false); + + int status; + object? value; + // Serialise the Read → GetStatus → DecodeValue sequence against the shared + // runtime — the server read path and the poll loop both call ReadAsync against + // the same cached libplctag Tag handle, which is not concurrency-safe. + var opLock = device.GetRuntimeLock(def.Name); + await opLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await runtime.ReadAsync(ct).ConfigureAwait(false); + status = runtime.GetStatus(); + var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); + if (status != 0) + value = null; + else if (IsArrayTag(def)) + value = runtime.DecodeArray(def.DataType, EffectiveArrayLength(def)); + else + value = runtime.DecodeValue(def.DataType, parsed?.BitIndex); + } + finally + { + opLock.Release(); + } + + if (status != 0) + { + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(status), null, now); + if (IsDeviceRefusal((AbStatus)status)) + { + // The device ANSWERED with a deterministic refusal (file/element missing, out of bounds…): per-tag + // Bad, the handle is kept (re-creating it would only re-pay the init read), warned once per tag, + // and it is evidence of reachability rather than a comms fault. + WarnRefusalOnce(device, def, "read", $"device status {(AbStatus)status}"); + return (OpVerdict.Answered, null); + } + // Evict the stale handle so the next call re-creates it (mirrors AbCip). + EvictRuntime(device, def.Name); + if (!device.FirstNonZeroStatusLogged) + { + device.FirstNonZeroStatusLogged = true; + _logger.LogWarning( + "AbLegacy non-zero transport status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}", + _driverInstanceId, def.DeviceHostAddress, reference, status); + } + return (OpVerdict.Fault, $"transport status {status} reading {reference}"); + } + // Healthy read — re-arm the per-device first-failure log so a future non-zero + // status logs again rather than being suppressed by an old flag from a prior outage. + device.FirstNonZeroStatusLogged = false; + results[item.Index] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now); + return (OpVerdict.Answered, null); + } + catch (OperationCanceledException) { throw; } + catch (ConnectionThrottledException tex) + { + // A cold connect refused inside the backoff window: no wire, but the tag has no value either. + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); + return (OpVerdict.Throttled, tex.Message); + } + catch (AbTransportException ex) when (IsLocalRefusal(ex)) + { + // The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read). + // Not a comms fault: BadNotSupported, keep the handle, leave health alone. + WarnRefusalOnce(device, def, "read", ex.Message); + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); + return (OpVerdict.Neutral, null); + } + catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status)) + { + // The native runtime raises a device's refusal (e.g. STS 0x20 "file not found") as an exception. + WarnRefusalOnce(device, def, "read", ex.Message); + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now); + return (OpVerdict.Answered, null); + } + catch (NotSupportedException nse) + { + // A deterministic decode refusal (review 07 H16) — the same answer every poll. BadNotSupported, keep + // the handle (re-creating it only re-paid the init read), warn once, and NO health write. + WarnRefusalOnce(device, def, "read", nse.Message); + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); + return (OpVerdict.Neutral, null); + } + catch (Exception ex) + { + // Transport exception — evict so the next read creates a fresh handle (mirrors AbCip). + EvictRuntime(device, def.Name); + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); + return (OpVerdict.Fault, ex.Message); + } + } + + /// + /// True for a status that proves the DEVICE answered and refused this one operation deterministically — the + /// file or element does not exist, the access is not allowed, the data type or size is wrong. Such a status + /// is per-tag, keeps the handle, and is not a comms fault. Everything else non-zero (timeouts, dropped or + /// refused connections, malformed or partial replies, unknown codes) is treated as a comms fault. + /// is classified separately (). + /// + /// The transport status. + /// for a deterministic device refusal. + internal static bool IsDeviceRefusal(AbStatus status) => status is + AbStatus.NotFound or AbStatus.NotAllowed or AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall + or AbStatus.TypeMismatch or AbStatus.DeviceStateConflict; + // ---- IWritable ---- /// @@ -558,6 +732,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover ArgumentNullException.ThrowIfNull(writes); var epoch = CurrentEpoch; var results = new WriteResult[writes.Count]; + var devices = _devices; // one snapshot of the published table for the whole call for (var i = 0; i < writes.Count; i++) { @@ -572,7 +747,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable); continue; } - if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + if (!devices.TryGetValue(def.DeviceHostAddress, out var device)) { results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown); continue; @@ -619,14 +794,6 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover continue; } - // PCCC bit-within-word writes — RMW against a parallel parent-word runtime (strip the /N - // bit suffix). The per-parent-word lock serialises concurrent bit writers. Applies to every - // bit-addressable PCCC file: N-file (N7:0/3), B-file (B3:0/0), and the I/O image files - // (I:0/0, O:1/2); L-file bits RMW a 32-bit parent, the rest a 16-bit word. T/C/R sub-elements - // (T4:0.DN, even when authored Bit-typed) never reach this path: AbLegacyAddress.TryParse rejects a - // sub-element combined with a /N bit index, so their BitIndex is always null. NOTE: an Input-image (I) write is sent - // to the PLC like any other write — the device drives the input image from physical inputs and - // may reject it; we surface that real PCCC status rather than pre-rejecting at the driver. // Array writes (review 07 U10): convert and length-check BEFORE any runtime exists, then encode every // element. A bit / sub-element array has no flat encoding. Array? arrayValue = null; @@ -643,6 +810,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover continue; } } + // PCCC bit-within-word writes — RMW against a parallel parent-word runtime (strip the /N + // bit suffix). The per-parent-word lock serialises concurrent bit writers. Applies to every + // bit-addressable PCCC file: N-file (N7:0/3), B-file (B3:0/0), and the I/O image files + // (I:0/0, O:1/2); L-file bits RMW a 32-bit parent, the rest a 16-bit word. T/C/R sub-elements + // (T4:0.DN, even when authored Bit-typed) never reach this path: AbLegacyAddress.TryParse rejects a + // sub-element combined with a /N bit index, so their BitIndex is always null. NOTE: an Input-image (I) write is sent + // to the PLC like any other write — the device drives the input image from physical inputs and + // may reject it; we surface that real PCCC status rather than pre-rejecting at the driver. else if (def.DataType == AbLegacyDataType.Bit && parsed.BitIndex is int bit) { results[i] = new WriteResult( @@ -669,18 +844,38 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover opLock.Release(); } - if (status != 0) + if (status == 0) { - // Evict the stale handle so the next call re-creates it (mirrors AbCip). - EvictRuntime(device, def.Name); + device.RecordAnswered(); + results[i] = new WriteResult(AbLegacyStatusMapper.Good); + } + else if (IsDeviceRefusal((AbStatus)status)) + { + // The device answered and refused this write deterministically: keep the handle, warn once. + device.RecordAnswered(); + WarnRefusalOnce(device, def, "write", $"device status {(AbStatus)status}"); results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status)); } else { - results[i] = new WriteResult(AbLegacyStatusMapper.Good); + // Evict the stale handle so the next call re-creates it (mirrors AbCip). + EvictRuntime(device, def.Name); + device.RecordCommsFault(DateTime.UtcNow); + results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status)); } } catch (OperationCanceledException) { throw; } + catch (ConnectionThrottledException) + { + // The device is in its reconnect backoff window: fail fast, no wire, no new evidence. + results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); + } + catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status)) + { + device.RecordAnswered(); + WarnRefusalOnce(device, def, "write", ex.Message); + results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(ex.Status)); + } catch (NotSupportedException nse) { // Deterministic (review 07 H16): nothing reached the wire, so it says nothing about the device. @@ -706,6 +901,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { // Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip). EvictRuntime(device, def.Name); + device.RecordCommsFault(DateTime.UtcNow); results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message)); } @@ -799,14 +995,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover state.ProbeInitialized = true; } await probeRuntime.ReadAsync(ct).ConfigureAwait(false); - success = probeRuntime.GetStatus() == 0; + var status = probeRuntime.GetStatus(); + success = status == 0; + // The probe's verdict feeds the data path's backoff (review 07 P7): an answer resets the window so a + // recovered device is read again at once; a comms fault counts toward marking it down. + if (success || IsDeviceRefusal((AbStatus)status)) state.RecordAnswered(); + else state.RecordCommsFault(DateTime.UtcNow); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } - catch + catch (Exception ex) { try { probeRuntime?.Dispose(); } catch { } probeRuntime = null; state.ProbeInitialized = false; + if (ex is AbTransportException ate && IsDeviceRefusal(ate.Status)) state.RecordAnswered(); + else if (!IsLocalRefusal(ex)) state.RecordCommsFault(DateTime.UtcNow); } TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); @@ -1043,10 +1246,16 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover await runtime.ReadAsync(ct).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } + catch (ConnectionThrottledException) { throw; } // the caller answers it as a fail-fast catch (AbTransportException ex) when (IsLocalRefusal(ex)) { return (null, AbLegacyStatusMapper.BadNotSupported, false); } + catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status)) + { + // The device answered: the index file/element does not exist. Per-tag, not a comms fault. + return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false); + } catch { EvictParentRuntime(device, runtimeName); @@ -1054,6 +1263,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover } var status = runtime.GetStatus(); + if (status != 0 && IsDeviceRefusal((AbStatus)status)) + return (null, AbLegacyStatusMapper.MapStatus(status), false); if (status != 0) { EvictParentRuntime(device, runtimeName); @@ -1093,32 +1304,56 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover private async Task EnsureParentRuntimeAsync( AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct) { - // Fast path: runtime already cached. - if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing; - - // Slow path: serialise creation per key so concurrent callers don't each create a - // runtime and one of them gets overwritten + leaked. Only one caller initialises; the - // others find the entry on the second TryGetValue inside the lock. - var creationLock = device.GetCreationLock($"parent:{parentName}"); - await creationLock.WaitAsync(ct).ConfigureAwait(false); - try - { - if (device.ParentRuntimes.TryGetValue(parentName, out existing)) return existing; - - var runtime = _tagFactory.Create(new AbLegacyTagCreateParams( + var timeout = _options.Timeout; + return await CreateSingleFlightAsync(device, device.ParentRuntimes, parentName, () => _tagFactory.Create( + new AbLegacyTagCreateParams( Gateway: device.ParsedAddress.Gateway, Port: device.ParsedAddress.Port, CipPath: device.EffectiveCipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: parentName, - Timeout: _options.Timeout)); - await InitializeOrKeepRefusedAsync(runtime, () => device.Cache(device.ParentRuntimes, parentName, runtime), ct) - .ConfigureAwait(false); + Timeout: timeout)), ct).ConfigureAwait(false); + } + + /// + /// Thrown when a cold connect is refused because the device's window is open + /// — a fail-fast that costs no wire traffic. Reads and writes answer it with BadCommunicationError. + /// + internal sealed class ConnectionThrottledException(string message) : Exception(message); + + /// + /// Returns the cached runtime for , or creates and initialises one. The COLD path is + /// single-flight per DEVICE (, review 07 P7): concurrent callers for + /// different cold tags of one device connect one at a time, so a down device sees one connect attempt instead + /// of one per caller, and inside an open backoff window no attempt is made at all + /// (). The HOT path (cached handle) never touches the gate. The + /// caller records the outcome on the device's backoff (once — recording here as well would double-count). + /// + private static async Task CreateSingleFlightAsync( + DeviceState device, + System.Collections.Concurrent.ConcurrentDictionary table, + string key, + Func create, + CancellationToken ct) + { + if (table.TryGetValue(key, out var existing)) return existing; + + await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false); + try + { + // Re-check under the gate: a sibling caller may have connected this very key while we waited. + if (table.TryGetValue(key, out existing)) return existing; + if (device.IsThrottled(DateTime.UtcNow)) + throw new ConnectionThrottledException( + $"AbLegacy connect for '{key}' on {device.Options.HostAddress} throttled: the device is in its reconnect backoff window."); + + var runtime = create(); + await InitializeOrKeepRefusedAsync(runtime, () => device.Cache(table, key, runtime), ct).ConfigureAwait(false); return runtime; } finally { - creationLock.Release(); + device.ConnectGate.Release(); } } @@ -1214,30 +1449,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover private async Task EnsureTagRuntimeAsync( DeviceState device, AbLegacyTagDefinition def, CancellationToken ct) { - // Fast path: runtime already cached. if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing; - // Slow path: serialise creation per tag name so concurrent callers for the same tag - // (server read path + poll loop) don't both create a runtime and one gets leaked. - var creationLock = device.GetCreationLock($"tag:{def.Name}"); - await creationLock.WaitAsync(ct).ConfigureAwait(false); - try - { - if (device.Runtimes.TryGetValue(def.Name, out existing)) return existing; - - var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily) - ?? throw new InvalidOperationException( - $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}."); - - var runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed)); - await InitializeOrKeepRefusedAsync(runtime, () => device.Cache(device.Runtimes, def.Name, runtime), ct) - .ConfigureAwait(false); - return runtime; - } - finally - { - creationLock.Release(); - } + var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily) + ?? throw new InvalidOperationException( + $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}."); + var createParams = CreateParamsFor(device, def, parsed); + return await CreateSingleFlightAsync(device, device.Runtimes, def.Name, () => _tagFactory.Create(createParams), ct) + .ConfigureAwait(false); } /// @@ -1329,7 +1548,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// avoids the check-then-act race present on a plain Dictionary: two concurrent /// EnsureTagRuntimeAsync callers for the same key both miss the lookup on a /// plain dict and both create + store a runtime, leaking the loser. Access is guarded - /// by a per-key creation semaphore () so exactly one + /// by the per-device connect gate () so exactly one /// runtime is created per tag name. /// public System.Collections.Concurrent.ConcurrentDictionary Runtimes { get; } = @@ -1348,20 +1567,56 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover new(StringComparer.OrdinalIgnoreCase); /// - /// Per-key creation locks for and . - /// A caller holds this before the TryGetValue + Create + InitializeAsync + TryAdd - /// sequence so that a concurrent caller waits rather than creating a duplicate runtime - /// that would be leaked on . + /// Per-device connect gate (review 07 P7): every COLD runtime create + initialise on this device runs under + /// it, so connects are single-flight per device — a down device sees one attempt at a time, not one per + /// concurrent caller — and a concurrent caller for the same key finds the winner's runtime instead of + /// creating (and leaking) a duplicate. A cached runtime never touches it. Not disposed on retirement, for + /// the same reason as the per-key gates (an in-flight caller may still release it). /// - private readonly System.Collections.Concurrent.ConcurrentDictionary _creationLocks = new(StringComparer.OrdinalIgnoreCase); + public SemaphoreSlim ConnectGate { get; } = new(1, 1); /// - /// Gets or creates the creation lock for the specified key. + /// Per-device reconnect throttle: opened after consecutive comms faults, + /// 1 s doubling to 30 s, reset by any answer from the device (data path or probe). Not internally + /// synchronised — every touch is under . /// - /// The lock key. - /// The semaphore slim for the key. - public SemaphoreSlim GetCreationLock(string key) => - _creationLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + private readonly ConnectionBackoff _backoff = new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30)); + + private readonly object _backoffLock = new(); + private int _faultStreak; + + /// True while the device's backoff window is open: operations fail fast without the wire. + /// The current UTC time. + /// when no attempt should be made now. + public bool IsThrottled(DateTime nowUtc) + { + lock (_backoffLock) return !_backoff.ShouldAttempt(nowUtc); + } + + /// The device answered (a Good value or a deterministic refusal): reset the streak and the window. + public void RecordAnswered() + { + lock (_backoffLock) + { + _faultStreak = 0; + _backoff.RecordSuccess(); + } + } + + /// + /// A comms fault: the th consecutive one marks the device down — opens + /// (or, after a failed retry, lengthens) the backoff window and restarts the streak. + /// + /// The current UTC time (the window is measured from here). + public void RecordCommsFault(DateTime nowUtc) + { + lock (_backoffLock) + { + if (++_faultStreak < FaultsToMarkDeviceDown) return; + _faultStreak = 0; + _backoff.RecordFailure(nowUtc); + } + } private readonly System.Collections.Concurrent.ConcurrentDictionary _rmwLocks = new(); @@ -1445,11 +1700,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover public void ForgetGates(string runtimeKey) { _runtimeLocks.TryRemove(runtimeKey, out _); - _creationLocks.TryRemove($"tag:{runtimeKey}", out _); } - /// The number of per-key gates currently held (runtime + creation + RMW). Tests only. - internal int GateCount => _runtimeLocks.Count + _creationLocks.Count + _rmwLocks.Count; + /// The number of per-key gates currently held (runtime + RMW). Tests only. + internal int GateCount => _runtimeLocks.Count + _rmwLocks.Count; private volatile bool _retired; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs new file mode 100644 index 00000000..2e5be188 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs @@ -0,0 +1,65 @@ +using System.Diagnostics; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; + +/// +/// Review 07 P7 (High), wire level: one driver serving a live ab_server device AND a dead device (a +/// blackholed address, so every connect waits out the full timeout). Before the fix the dead device's 20 tags +/// cost 20 x Timeout per poll and the live device was read once per cycle; now the live device reads Good in +/// the same call, the dead device costs at most two timeouts, and the next poll does not touch it at all. +/// Read-only. +/// +[Collection(AbLegacyServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Simulator", "ab_server-PCCC")] +public sealed class AbLegacyDeadDeviceIsolationTests(AbLegacyServerFixture sim) +{ + // TEST-NET-1 (RFC 5737): never routed, so a connect blackholes until the driver's timeout. + private const string DeadUri = "ab://192.0.2.1:44818/1,0"; + + /// The live device is served promptly while a blackholed sibling is throttled. + /// The simulator profile. + /// The transport stack the driver runs on. + [AbLegacyTheory] + [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))] + public async Task Dead_device_does_not_stall_the_live_one(AbLegacyServerProfile profile, AbLegacyTransport transport) + { + if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip); + + var liveUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}"; + var timeout = TimeSpan.FromSeconds(2); + var dead = Enumerable.Range(0, 20) + .Select(i => new AbLegacyTagDefinition($"Dead{i}", DeadUri, $"N7:{i}", AbLegacyDataType.Int)) + .ToArray(); + await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(liveUri, profile.Family), new AbLegacyDeviceOptions(DeadUri, profile.Family)], + RawTags = AbLegacyRawTags.Entries( + [new AbLegacyTagDefinition("Live", liveUri, "N7:0", AbLegacyDataType.Int), .. dead]), + Timeout = timeout, + Probe = new AbLegacyProbeOptions { Enabled = false }, + Transport = transport, + }, driverInstanceId: $"ablegacy-p7-{profile.Family}-{transport}"); + var ct = TestContext.Current.CancellationToken; + await drv.InitializeAsync("{}", ct); + string[] refs = ["Live", .. dead.Select(d => d.Name)]; + + var sw = Stopwatch.StartNew(); + var first = await drv.ReadAsync(refs, ct); + sw.Stop(); + + first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, "the live device must be served in the same poll"); + first.Skip(1).ShouldAllBe(s => s.StatusCode != AbLegacyStatusMapper.Good); + sw.Elapsed.ShouldBeLessThan(timeout * 3, "a dead device may cost at most two timeouts per backoff window"); + + sw.Restart(); + var second = await drv.ReadAsync(refs, ct); + sw.Stop(); + second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + sw.Elapsed.ShouldBeLessThan(timeout, "inside the backoff window the dead device is not dialled"); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs new file mode 100644 index 00000000..a28b8b60 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs @@ -0,0 +1,259 @@ +using System.Diagnostics; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// Review 07 P7 (High): reads were strictly serial across every device with no per-device backoff, so one dead +/// device cost Timeout per tag per poll and starved every healthy device — long enough to trip the +/// comms-loss watchdog against a reachable one. Devices are now read in parallel (bounded), each device pass +/// stops paying for a dead device after two consecutive comms faults and opens a per-device +/// window, and cold connects are single-flight per device. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyDeviceIsolationTests +{ + private const string HostA = "ab://10.0.0.5/1,0"; + private const string HostB = "ab://10.0.0.6/1,0"; + + /// A tag on a dead device: each wire operation burns and then fails. + private sealed class DeadTag(AbLegacyTagCreateParams p, TimeSpan cost) : FakeAbLegacyTag(p) + { + public static int WireOps; + + public override async Task InitializeAsync(CancellationToken ct) + { + Interlocked.Increment(ref WireOps); + await Task.Delay(cost, ct); + throw new System.Net.Sockets.SocketException(10060); + } + + public override async Task ReadAsync(CancellationToken ct) + { + Interlocked.Increment(ref WireOps); + await Task.Delay(cost, ct); + throw new System.Net.Sockets.SocketException(10060); + } + } + + private static AbLegacyDriver NewDriver(FakeAbLegacyTagFactory factory, params AbLegacyTagDefinition[] tags) => + new(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(HostA), new AbLegacyDeviceOptions(HostB)], + RawTags = AbLegacyRawTags.Entries(tags), + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-p7", factory); + + private static AbLegacyTagDefinition[] Tags(string prefix, string host, int n, string file = "N7") => + [.. Enumerable.Range(0, n).Select(i => new AbLegacyTagDefinition($"{prefix}{i}", host, $"{file}:{i * 3}", AbLegacyDataType.Int))]; + + [Fact] + public async Task Dead_device_costs_at_most_two_timeouts_then_fails_fast_and_never_stalls_the_healthy_one() + { + var cost = TimeSpan.FromMilliseconds(150); + var factory = new FakeAbLegacyTagFactory + { + Customise = p => p.Gateway == "10.0.0.6" ? new DeadTag(p, cost) : new FakeAbLegacyTag(p) { Value = 7 }, + }; + var a = Tags("A", HostA, 5); + var b = Tags("B", HostB, 20); + var drv = NewDriver(factory, [.. a, .. b]); + await drv.InitializeAsync("{}", CancellationToken.None); + var refs = a.Concat(b).Select(t => t.Name).ToList(); + var before = drv.GetHealth().LastSuccessfulRead; + Interlocked.Exchange(ref DeadTag.WireOps, 0); + + var sw = Stopwatch.StartNew(); + var first = await drv.ReadAsync(refs, CancellationToken.None); + sw.Stop(); + + // Serial-with-no-backoff would pay 20 x 150 ms = 3 s on device B alone. + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1.5)); + Volatile.Read(ref DeadTag.WireOps).ShouldBeLessThanOrEqualTo(2); + first.Take(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); // positive control: A is served + first.Skip(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError); + var health = drv.GetHealth(); + health.State.ShouldBe(DriverState.Degraded); + health.LastSuccessfulRead.ShouldNotBeNull(); + health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before ?? DateTime.MinValue); // A's success advances it + + // Inside B's backoff window the next poll touches B's wire not at all. + var opsAfterFirst = Volatile.Read(ref DeadTag.WireOps); + sw.Restart(); + var second = await drv.ReadAsync(refs, CancellationToken.None); + sw.Stop(); + Volatile.Read(ref DeadTag.WireOps).ShouldBe(opsAfterFirst); + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(500)); + second.Take(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Devices_are_read_in_parallel() + { + // Device A's read cannot complete until device B's read has STARTED: a serial walk deadlocks here. + var bStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var factory = new FakeAbLegacyTagFactory + { + Customise = p => p.Gateway == "10.0.0.5" + ? new GatedTag(p, wait: bStarted.Task) { Value = 1 } + : new GatedTag(p, signal: bStarted) { Value = 2 }, + }; + var drv = NewDriver(factory, + new AbLegacyTagDefinition("A", HostA, "N7:0", AbLegacyDataType.Int), + new AbLegacyTagDefinition("B", HostB, "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync(["A", "B"], CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5)); + + r[0].Value.ShouldBe(1); + r[1].Value.ShouldBe(2); + await drv.ShutdownAsync(CancellationToken.None); + } + + private sealed class GatedTag(AbLegacyTagCreateParams p, Task? wait = null, TaskCompletionSource? signal = null) + : FakeAbLegacyTag(p) + { + public override async Task ReadAsync(CancellationToken ct) + { + signal?.TrySetResult(); + if (wait is not null) await wait.WaitAsync(ct); + await base.ReadAsync(ct); + } + } + + [Fact] + public async Task One_timing_out_tag_does_not_blacken_the_rest_of_its_device() + { + var factory = new FakeAbLegacyTagFactory + { + Customise = p => p.TagName == "N7:0" + ? new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new TimeoutException() } + : new FakeAbLegacyTag(p) { Value = 5 }, + }; + var drv = NewDriver(factory, Tags("A", HostA, 4)); + await drv.InitializeAsync("{}", CancellationToken.None); + + for (var poll = 0; poll < 3; poll++) + { + var r = await drv.ReadAsync(["A0", "A1", "A2", "A3"], CancellationToken.None); + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); + r.Skip(1).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good, $"poll {poll}"); + } + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Device_refusal_is_per_tag_Bad_advances_LastSuccessfulRead_and_keeps_the_handle() + { + var logger = new AbLegacyTestLogger(); + var factory = new FakeAbLegacyTagFactory + { + Customise = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound }, + }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(HostA)], + RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", HostA, "N7:0", AbLegacyDataType.Int)), + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-p7", factory, logger); + await drv.InitializeAsync("{}", CancellationToken.None); + var initHealth = drv.GetHealth(); + await Task.Delay(20); + + var r1 = await drv.ReadAsync(["X"], CancellationToken.None); + var r2 = await drv.ReadAsync(["X"], CancellationToken.None); + + r1[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); + r2[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); + var health = drv.GetHealth(); + health.State.ShouldBe(DriverState.Healthy); // the device answered: not a comms fault + health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(initHealth.LastSuccessfulRead!.Value); + factory.Tags["N7:0"].InitializeCount.ShouldBe(1); + factory.Tags["N7:0"].ReadCount.ShouldBe(2); // positive control: both polls really asked the device + logger.Warnings.Count(w => w.Contains("Reference=X")).ShouldBe(1); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Cold_connects_on_one_device_are_single_flight() + { + var inFlight = 0; + var maxInFlight = 0; + var factory = new FakeAbLegacyTagFactory + { + Customise = p => new CountingInitTag(p, () => + { + var now = Interlocked.Increment(ref inFlight); + int seen; + while (now > (seen = Volatile.Read(ref maxInFlight)) && Interlocked.CompareExchange(ref maxInFlight, now, seen) != seen) { } + }, () => Interlocked.Decrement(ref inFlight)) { Value = 1 }, + }; + var tags = Tags("A", HostA, 6); + var drv = NewDriver(factory, tags); + await drv.InitializeAsync("{}", CancellationToken.None); + + // Three concurrent callers (poll loop, server read, write) each hitting different cold tags of one device. + await Task.WhenAll( + drv.ReadAsync(["A0", "A1"], CancellationToken.None), + drv.ReadAsync(["A2", "A3"], CancellationToken.None), + drv.WriteAsync([new WriteRequest("A4", 3), new WriteRequest("A5", 4)], CancellationToken.None)); + + maxInFlight.ShouldBe(1); + factory.Tags.Values.Count(t => t.InitializeCount == 1).ShouldBe(6); // positive control: all six connected + await drv.ShutdownAsync(CancellationToken.None); + } + + private sealed class CountingInitTag(AbLegacyTagCreateParams p, Action enter, Action exit) : FakeAbLegacyTag(p) + { + public override async Task InitializeAsync(CancellationToken ct) + { + enter(); + try + { + await Task.Delay(30, ct); + await base.InitializeAsync(ct); + } + finally { exit(); } + } + } + + [Fact] + public async Task A_recovered_device_is_read_again_once_its_window_expires() + { + var dead = true; + var creates = 0; + var factory = new FakeAbLegacyTagFactory + { + Customise = p => + { + Interlocked.Increment(ref creates); + return dead + ? new FakeAbLegacyTag(p) { ThrowOnInitialize = true, Exception = new System.Net.Sockets.SocketException(10061) } + : new FakeAbLegacyTag(p) { Value = 9 }; + }, + }; + var drv = NewDriver(factory, new AbLegacyTagDefinition("B", HostB, "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + + // Two consecutive comms faults mark the device down and open its first (1 s) window. + (await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); + (await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); + var attempts = Volatile.Read(ref creates); + dead = false; + // Inside the window: fails fast without a connect attempt, although the device is back. + (await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); + Volatile.Read(ref creates).ShouldBe(attempts); + await Task.Delay(TimeSpan.FromMilliseconds(1100)); + + var recovered = await drv.ReadAsync(["B"], CancellationToken.None); + recovered[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + recovered[0].Value.ShouldBe(9); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + await drv.ShutdownAsync(CancellationToken.None); + } +} From 8a941de4cd79e86957e00a44d73768d24dbaebfd Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:34:53 -0400 Subject: [PATCH 31/67] fix(abcip): keep a sync Dispose, but one that never blocks on the teardown F10 follow-through: removing IDisposable outright broke a synchronous drv.Dispose() call site outside this lane (Runtime.Tests AbCipAlmaNativeAlarmEndToEndTests). The sync Dispose() is back, but it starts the same teardown as DisposeAsync and returns instead of parking the caller on DisposeAsync().AsTask().GetAwaiter().GetResult(); a background fault is logged. The runtime still takes the IAsyncDisposable path (preferred, never both). Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipDriver.cs | 22 ++++++++++++-- .../AbCipLifecycleConcurrencyTests.cs | 29 ++++++++++++++++--- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 43afe57f..8e19daed 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -27,7 +27,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// handles GC-collected tags. /// public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable, - IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IAsyncDisposable + IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IAsyncDisposable, IDisposable { /// Authored tags this driver could not build into its poll table. private readonly SkippedTagLedger _skippedTags = new(); @@ -2268,8 +2268,8 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// Asynchronously releases all resources used by the driver — the same complete teardown as /// . Idempotent, and safe CONCURRENTLY with a still-running /// (the runtime's final stop abandons a shutdown that overruns its budget and - /// disposes anyway): each teardown retires a disjoint device table. There is deliberately no synchronous - /// Dispose() — it could only block on this teardown (sync-over-async). + /// disposes anyway): each teardown retires a disjoint device table. This is the dispose the runtime calls + /// (it prefers ). /// /// A value task representing the asynchronous disposal. public async ValueTask DisposeAsync() @@ -2277,6 +2277,22 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } + /// + /// Starts the same teardown as WITHOUT blocking the calling thread on it (review + /// 07 F10: the old DisposeAsync().AsTask().GetAwaiter().GetResult() parked the caller — a dispatcher + /// thread — for up to the probe join, with the usual sync-over-async deadlock exposure). The teardown runs to + /// completion in the background and any fault is logged. Callers that need completion use + /// ; kept only for synchronous using / Dispose() call sites. + /// + public void Dispose() + { + var teardown = DisposeAsync().AsTask(); + if (teardown.IsCompleted) { teardown.GetAwaiter().GetResult(); return; } + _ = teardown.ContinueWith( + t => _logger.LogWarning(t.Exception, "AbCip driver {DriverInstanceId}: background dispose failed", _driverInstanceId), + CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default); + } + /// /// Per-device runtime state. Holds the parsed host address, family profile, and the /// live libplctag.NET instances keyed by tag name. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs index eea409d2..2b009a7f 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs @@ -175,12 +175,10 @@ public sealed class AbCipLifecycleConcurrencyTests created.Where(t => !t.Disposed).ShouldBeEmpty("every runtime a reader created must be disposed by some teardown"); } - /// F10: the driver exposes only an async dispose; a sync Dispose() that blocked on the async - /// teardown (sync-over-async) is gone, and the runtime takes the path. + /// F10: DisposeAsync is the complete, idempotent teardown (the runtime prefers it). [Fact] - public async Task Driver_is_async_disposable_only_and_DisposeAsync_stops_the_probe() + public async Task DisposeAsync_is_a_complete_idempotent_teardown() { - typeof(IDisposable).IsAssignableFrom(typeof(AbCipDriver)).ShouldBeFalse(); var (drv, factory) = Build(probeReadDelay: TimeSpan.Zero); await drv.InitializeAsync("{}", CancellationToken.None); await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started"); @@ -192,4 +190,27 @@ public sealed class AbCipLifecycleConcurrencyTests loop.IsCompleted.ShouldBeTrue(); factory.Tags["ProbeTag"].Disposed.ShouldBeTrue(); } + + /// + /// F10: the sync Dispose() no longer blocks the caller's thread on the async teardown (sync-over-async — + /// a dispatcher thread for up to the probe join). It starts the same teardown and returns; the teardown still + /// completes. + /// + [Fact] + public async Task Sync_Dispose_does_not_block_on_the_teardown_but_still_completes_it() + { + var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(500)); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started"); + var loop = drv.GetDeviceState(Host)!.ProbeTask!; + await Task.Delay(20); // the probe is inside its slow, cancellation-deaf read + + var sw = System.Diagnostics.Stopwatch.StartNew(); + ((IDisposable)drv).Dispose(); + sw.Stop(); + + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(250)); // did not wait out the 500 ms probe read + await WaitUntilAsync(() => loop.IsCompleted && factory.Tags["ProbeTag"].Disposed, "teardown never completed"); + drv.GetDeviceState(Host).ShouldBeNull(); + } } From 5de1d661bb01d10caf74475c85ff8aeb848aea72 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:36:54 -0400 Subject: [PATCH 32/67] fix(runtime): evict a stopped driver's retained values from the dependency mux DriverHostActor now tells the mux SourceRemoved(driverInstanceId) wherever a driver object ends: StopChild (deploy removal and the stop half of a config-change respawn) and an operator Restart. It is not sent on a supervision restart, which re-uses the same driver. Before this nothing sent it, so a removed driver's last Good stayed in the mux and replayed to the next replay-opting subscriber as current. DriverHostActorSourceRemovedTests drives a real mux with two real driver children: after a deploy drops DI-1 only DI-2's value replays (positive control: both replay before the removal), and a supervision restart of DI-1 leaves its value replayable. Residual (documented on EvictRetainedValues): a burst the old child enqueued just before it stopped can still be forwarded after the eviction; the next publish, a comms-loss fan or the grace sweep bounds it. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../Drivers/DriverHostActor.cs | 23 ++++ .../VirtualTags/DependencyMuxActor.cs | 8 +- .../DriverHostActorSourceRemovedTests.cs | 119 ++++++++++++++++++ 3 files changed, 145 insertions(+), 5 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorSourceRemovedTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 17a43f9d..b3339e42 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -3840,6 +3840,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS StopDependencyAdapter(driverInstanceId); Context.Stop(entry.Actor); _children.Remove(driverInstanceId); + EvictRetainedValues(driverInstanceId); // The instance left the plan (StopChild is only reached via plan.ToStop), so its cached // Health/Resilience snapshots must not replay to new telemetry subscribers. Release the shared // resilience state FIRST: the resilience publisher re-emits the tracker's snapshots every tick, so @@ -3858,6 +3859,25 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS _log.Info("DriverHost {Node}: stopped driver child {Id}", _localNode, driverInstanceId); } + /// + /// Tells the dependency mux to drop every retained value this driver instance produced, so a stopped + /// driver's last Good is never replayed to a later replay-opting subscriber as if it were current. Sent + /// wherever a driver OBJECT ends: a deploy removal, the stop half of a config-change respawn + /// () and an operator Restart (a new driver object). Deliberately NOT sent on a + /// supervision restart (): that re-uses the same driver, whose last value is + /// still its current one. Sent before any respawn, on the same host→mux pair the new incarnation's + /// values travel, so the eviction is ordered ahead of them. + /// + /// + /// Residual: a burst the old child enqueued on this host just before it stopped can still be forwarded + /// after the eviction and re-retain a value. The window is one mailbox drain; a comms-loss fan or the + /// next real publish overwrites it, and the mux's grace sweep bounds it once no replay subscriber holds + /// the ref. + /// + /// The driver instance that ended. + private void EvictRetainedValues(string driverInstanceId) => + _dependencyMux?.Tell(new DependencyMuxActor.SourceRemoved(driverInstanceId)); + private static string ActorNameFor(string driverInstanceId, long generation) { // Akka actor names cannot contain '/', ':', or whitespace. Mangle defensively. The monotonic @@ -3905,6 +3925,9 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS StopDependencyAdapter(msg.DriverInstanceId); Context.Stop(entry.Actor); _children.Remove(msg.DriverInstanceId); + // An operator Restart builds a NEW driver object, so the old one's retained values are evicted like a + // removal's; the respawned driver republishes its own. + EvictRetainedValues(msg.DriverInstanceId); // The old incarnation's health must not read as the new one's until it publishes its own. _healthPublisher.Forget(msg.DriverInstanceId); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs index 40e9bf19..3dd8441e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs @@ -52,11 +52,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; /// Source removal. The mux records which driver instance last published each retained ref; /// evicts every value that driver produced, so a removed driver's last /// Good is never replayed as current. A comms-loss fan (a Bad batch) already overwrites the retained -/// value with Bad. ⚠️ As of fix round 1 no production actor sends yet — -/// DriverHostActor (outside the scripting lane) must Tell it when it stops / removes a driver -/// child. Until then a removed driver's last value can still be replayed to a replay-opting -/// subscriber that re-registers for that ref while another replay-opting subscriber holds it, or -/// within the grace window. +/// value with Bad. DriverHostActor sends wherever a driver object +/// ends (a deploy removal, the stop half of a config-change respawn, an operator Restart), and never +/// on a supervision restart, which re-uses the same driver. /// /// public sealed class DependencyMuxActor : ReceiveActor, IWithTimers diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorSourceRemovedTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorSourceRemovedTests.cs new file mode 100644 index 00000000..a7a49f7a --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorSourceRemovedTests.cs @@ -0,0 +1,119 @@ +using Akka.Actor; +using Akka.TestKit; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Engines; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; +using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// The dependency mux retains a ref's last value for replay-opting subscribers and evicts a driver's values on +/// . Before this, nothing in production sent that message, so a +/// driver removed by a deploy kept its last Good in the mux and replayed it to the next replay-opting +/// subscriber as if it were current. DriverHostActor.StopChild now sends it. A supervision restart +/// re-uses the same driver and must NOT send it. All through a REAL mux and real driver children. +/// +public sealed class DriverHostActorSourceRemovedTests : RuntimeActorTestBase +{ + private static readonly NodeId TestNode = NodeId.Parse("driver-sourceremoved-test"); + private static readonly RevisionHash RevA = RevisionHash.Parse(new string('a', 64)); + private static readonly RevisionHash RevB = RevisionHash.Parse(new string('b', 64)); + private const string Path1 = "Plant/Modbus/dev1/speed"; + private const string Path2 = "Plant/Modbus2/dev2/flow"; + private static readonly (string, string, string, string, string?)[] NoRefs = []; + + private static readonly (string Driver, string DriverName, string Device, string Tag, string DataType, bool Writable)[] BothDrivers = + [ + ("DI-1", "Modbus", "dev1", "speed", "Double", false), + ("DI-2", "Modbus2", "dev2", "flow", "Double", false), + ]; + + /// Spawns a host over a real mux with two enabled drivers, waits for both children to subscribe, + /// registers a replay-opting holder (so the mux retains both refs) and publishes one value per driver. + private (IActorRef Host, TestProbe Coordinator, RecordingSubscribableDriverFactory Factory) ArrangeTwoDriversWithRetainedValues( + Akka.TestKit.TestProbe holder) + { + var db = NewInMemoryDbFactory(); + _db = db; + var revA = V3ArtifactSeeder.SeedV3Deployment(db, RevA, BothDrivers, NoRefs, enabled: true); + var factory = new RecordingSubscribableDriverFactory("Modbus"); + var mux = Sys.ActorOf(DependencyMuxActor.Props()); + var coordinator = CreateTestProbe(); + var host = Sys.ActorOf(DriverHostActor.Props( + db, TestNode, coordinator.Ref, + driverFactory: factory, + localRoles: new HashSet { "driver" }, + dependencyMux: mux, + virtualTagEvaluator: NullVirtualTagEvaluator.Instance, + virtualTagHostOverride: CreateTestProbe().Ref)); + V3ArtifactSeeder.Dispatch(host, coordinator, revA, RevA); + AwaitAssert(() => factory.Created.Count.ShouldBe(2), PresenceBudget); + foreach (var d in factory.Created) AwaitAssert(() => d.SubscribeCount.ShouldBeGreaterThanOrEqualTo(1), PresenceBudget); + + mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { Path1, Path2 }, holder.Ref, ReplayRetained: true)); + Driver(factory, "DI-1").FireDataChange(Path1, 11.0); + Driver(factory, "DI-2").FireDataChange(Path2, 22.0); + var seen = new HashSet(); + while (seen.Count < 2) + foreach (var item in holder.ExpectMsg(PresenceBudget).Items) + seen.Add(item.TagId); + + _mux = mux; + return (host, coordinator, factory); + } + + private IActorRef _mux = ActorRefs.Nobody; + private Microsoft.EntityFrameworkCore.IDbContextFactory _db = null!; + + private static RecordingSubscribableDriver Driver(RecordingSubscribableDriverFactory f, string id) => + f.Created.Last(d => d.DriverInstanceId == id); + + /// A fresh replay-opting subscriber for both refs; returns the refs it was replayed. + private string[] ReplayedRefs() + { + var probe = CreateTestProbe(); + _mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { Path1, Path2 }, probe.Ref, ReplayRetained: true)); + return probe.ExpectMsg(PresenceBudget) + .Items.Select(i => i.TagId).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + } + + [Fact] + public void A_driver_removed_by_a_deploy_is_no_longer_replayed_while_a_still_present_driver_is() + { + var holder = CreateTestProbe(); + var (host, coordinator, _) = ArrangeTwoDriversWithRetainedValues(holder); + + // Positive control before the removal: both values replay. + ReplayedRefs().ShouldBe(new[] { Path2, Path1 }.OrderBy(x => x, StringComparer.Ordinal).ToArray()); + + // Deploy B drops DI-1 (StopChild). + var revB = V3ArtifactSeeder.SeedV3Deployment( + _db, RevB, [("DI-2", "Modbus2", "dev2", "flow", "Double", false)], NoRefs, enabled: true); + V3ArtifactSeeder.Dispatch(host, coordinator, revB, RevB); + + AwaitAssert( + () => ReplayedRefs().ShouldBe(new[] { Path2 }, "DI-1 was removed: its last Good must not replay; DI-2's must"), + PresenceBudget); + } + + [Fact] + public void A_supervision_restart_does_not_evict_the_drivers_retained_value() + { + var holder = CreateTestProbe(); + var (_, _, factory) = ArrangeTwoDriversWithRetainedValues(holder); + var di1 = Driver(factory, "DI-1"); + + // Crash DI-1's child: Akka restarts it around the SAME driver object, and the host reprovisions it. + di1.FireMalformedDataChange(Path1); + AwaitAssert(() => di1.InitializeCount.ShouldBe(2), PresenceBudget); + factory.Created.Count.ShouldBe(2, "a supervision restart re-uses the driver; no new one is created"); + + ReplayedRefs().ShouldBe( + new[] { Path2, Path1 }.OrderBy(x => x, StringComparer.Ordinal).ToArray(), + "a restart is the same driver instance: its last value stays replayable"); + } +} From 6b863cd524a0d778061be596e889539362ddef99 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:37:18 -0400 Subject: [PATCH 33/67] test(abcip): ab_server cases for the probe blackout, refusals, array writes Integration cases (both transports, skipped when the fixture is unreachable): - H10: a typo'd probe tag leaves the host Running and a cold data tag created after many probe ticks still reads Good; - H1/L4: a missing tag reads BadNodeIdUnknown every poll beside a Good sibling, the driver stays Healthy and LastSuccessfulRead advances even when only the refused tag is read; - U10 (AB_SERVER_ALLOW_WRITES=1): an int[16] written to TestArray (DINT[16]) reads back element for element through a fresh driver; a 15-element write is BadOutOfRange. Not run here: the 10.100.0.35:44818 fixture was down during this lane (Wave 4). Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipRefusalLiveTests.cs | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipRefusalLiveTests.cs diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipRefusalLiveTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipRefusalLiveTests.cs new file mode 100644 index 00000000..c5aebf8d --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests/AbCipRefusalLiveTests.cs @@ -0,0 +1,172 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.IntegrationTests; + +/// +/// Review 07 (Gitea #737) against a running ab_server on both transports: a controller refusal is a per-tag +/// config error, not a comms fault (H10 probe, H1/L4 data path), and a whole-array write round-trips (U10). +/// Skipped when the fixture is unreachable (); the array write additionally +/// needs AB_SERVER_ALLOW_WRITES=1 (). +/// +[Trait("Category", "Integration")] +[Trait("Requires", "AbServer")] +public sealed class AbCipRefusalLiveTests +{ + private static string Device(AbServerFixture fixture) => $"ab://{fixture.Host}:{fixture.Port}/1,0"; + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout, string what) + { + var deadline = DateTime.UtcNow + timeout; + while (!condition()) + { + if (DateTime.UtcNow > deadline) throw new TimeoutException(what); + await Task.Delay(50); + } + } + + /// + /// H10: a typo'd probe tag on a reachable controller leaves the host Running, and a COLD data tag created + /// after many probe ticks still connects (the backoff window was never opened by the probe's NotFound). + /// + /// The transport stack the driver runs on. + [AbServerTheory] + [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] + public async Task A_typod_probe_tag_does_not_black_out_a_reachable_device(AbCipTransport transport) + { + var device = Device(new AbServerFixture()); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Counter", device, "TestDINT", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions + { + Enabled = true, + ProbeTagPath = "NoSuchProbeTag_xyz", + Interval = TimeSpan.FromMilliseconds(200), + Timeout = TimeSpan.FromSeconds(2), + }, + Timeout = TimeSpan.FromSeconds(5), + Transport = transport, + }, $"drv-h10-{transport}"); + await drv.InitializeAsync("{}", CancellationToken.None); + try + { + await WaitUntilAsync(() => drv.GetHostStatuses().Single().State == HostState.Running, + TimeSpan.FromSeconds(10), "the host never went Running on a refused probe tag"); + await Task.Delay(1_000); // several probe ticks — each one used to record a Forward-Open failure + + var r = await drv.ReadAsync(["Counter"], CancellationToken.None); // a cold create + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + drv.GetHostStatuses().Single().State.ShouldBe(HostState.Running); + } + finally + { + await drv.DisposeAsync(); + } + } + + /// + /// H1/L4: a tag the controller does not have reads BadNodeIdUnknown every poll while a sibling reads Good, and + /// the driver stays Healthy with LastSuccessfulRead advancing (it used to latch Degraded and, with no other + /// tag, trip the comms-loss watchdog against a reachable controller). + /// + /// The transport stack the driver runs on. + [AbServerTheory] + [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] + public async Task A_missing_tag_is_per_tag_Bad_and_never_degrades_the_driver(AbCipTransport transport) + { + var device = Device(new AbServerFixture()); + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)], + RawTags = AbCipRawTags.From( + new AbCipTagDefinition("Typo", device, "NoSuchTag_xyz", AbCipDataType.DInt), + new AbCipTagDefinition("Counter", device, "TestDINT", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions { Enabled = false }, + Timeout = TimeSpan.FromSeconds(5), + Transport = transport, + }, $"drv-h1-{transport}"); + await drv.InitializeAsync("{}", CancellationToken.None); + try + { + DateTime? last = null; + for (var i = 0; i < 3; i++) + { + var r = await drv.ReadAsync(["Typo", "Counter"], CancellationToken.None); + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); + r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good); + var health = drv.GetHealth(); + health.State.ShouldBe(DriverState.Healthy); + if (last is { } previous) health.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(previous); + last = health.LastSuccessfulRead; + } + + // Only the refused tag: still Healthy, still answering (the watchdog conjunction can never trip). + var before = drv.GetHealth().LastSuccessfulRead!.Value; + await Task.Delay(20); + (await drv.ReadAsync(["Typo"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before); + } + finally + { + await drv.DisposeAsync(); + } + } + + /// + /// U10: a whole int[] written to the 16-element TestArray (DINT[16]) reads back element for element + /// through a fresh driver; a wrong-length write is refused BadOutOfRange and changes nothing. + /// + /// The transport stack the driver runs on. + [AbServerTheory] + [MemberData(nameof(TransportMatrix.Transports), MemberType = typeof(TransportMatrix))] + public async Task A_whole_array_write_round_trips(AbCipTransport transport) + { + IntegrationWriteGate.SkipUnlessWritesAllowed(); + var device = Device(new AbServerFixture()); + var seed = Random.Shared.Next(1_000, 1_000_000); + var values = Enumerable.Range(0, 16).Select(i => seed + i * 7).ToArray(); + + AbCipDriver Make(string id) => new(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(device, AbCipPlcFamily.ControlLogix)], + RawTags = AbCipRawTags.From( + new AbCipTagDefinition("Arr", device, "TestArray", AbCipDataType.DInt, ElementCount: 16, IsArray: true)), + Probe = new AbCipProbeOptions { Enabled = false }, + Timeout = TimeSpan.FromSeconds(5), + Transport = transport, + }, $"drv-u10-{id}-{transport}"); + + var writer = Make("writer"); + await writer.InitializeAsync("{}", CancellationToken.None); + try + { + var w = await writer.WriteAsync([new WriteRequest("Arr", values)], CancellationToken.None); + w[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + var bad = await writer.WriteAsync([new WriteRequest("Arr", values.Take(15).ToArray())], CancellationToken.None); + bad[0].StatusCode.ShouldBe(AbCipStatusMapper.BadOutOfRange); + writer.GetHealth().State.ShouldBe(DriverState.Healthy); + } + finally + { + await writer.DisposeAsync(); + } + + var reader = Make("reader"); + await reader.InitializeAsync("{}", CancellationToken.None); + try + { + var r = await reader.ReadAsync(["Arr"], CancellationToken.None); + r[0].StatusCode.ShouldBe(AbCipStatusMapper.Good); + r[0].Value.ShouldBe(values); + } + finally + { + await reader.DisposeAsync(); + } + } +} From 047560319252ec3ff5e483592e9d4773ca3d0b55 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:38:33 -0400 Subject: [PATCH 34/67] refactor(runtime): drop DriverHostActor's unused ILoggerFactory slot The parameter was retained only for signature stability after the EfAlarmConditionStateStore fallback that consumed it was retired. It is removed from Props, the constructor and the positional Props.Create forwarding list together, and from its one caller in WithOtOpcUaRuntimeActors. No test passed it. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs | 8 +------- .../ServiceCollectionExtensions.cs | 1 - 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index b3339e42..27bc6a70 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -644,8 +644,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS /// VirtualTag host instead of spawning a real child, so tests /// can intercept the message. Null in /// production (the real host is spawned). - /// Retained for constructor-signature stability; unused since the - /// EfAlarmConditionStateStore fallback that consumed it was retired. Defaults to null. /// Optional root script logger required to spawn the ScriptedAlarm /// host (the engine + its script logging hang off it). When null the ScriptedAlarm host is left /// unspawned — the graceful dev/None-deployment path. @@ -712,7 +710,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS IVirtualTagEvaluator? virtualTagEvaluator = null, IHistoryWriter? historyWriter = null, IActorRef? virtualTagHostOverride = null, - ILoggerFactory? loggerFactory = null, ScriptRootLogger? scriptRootLogger = null, IActorRef? scriptedAlarmHostOverride = null, IDriverCapabilityInvokerFactory? invokerFactory = null, @@ -743,7 +740,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS Akka.Actor.Props.Create(() => new DriverHostActor( dbFactory, localNode, ackRouter, driverFactory, localRoles, dependencyMux, opcUaPublishActor, healthPublisher, virtualTagEvaluator, historyWriter, virtualTagHostOverride, - loggerFactory, scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider, + scriptRootLogger, scriptedAlarmHostOverride, invokerFactory, driverMemberCountProvider, deploymentArtifactCache, redundancyRoleView, replicationPeerHost, fetchAndCacheMode, artifactFetcher, alarmStateStore, telemetryHub, primaryRoleSource, historizationRoleView, sharesHistorizationScopeWithLocal, applyWedgeTimeout, reprovisionBackoff, commsRecoveryAuditDelay, @@ -767,8 +764,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS /// for historized VirtualTag results; defaults to . /// Test seam: when supplied, used as the VirtualTag host /// instead of spawning a real child. - /// Retained for constructor-signature stability; unused since the - /// EfAlarmConditionStateStore fallback that consumed it was retired. Defaults to null. /// Optional root script logger required to spawn the ScriptedAlarm /// host; when null the host is left unspawned. /// Test seam: when supplied, used as the ScriptedAlarm host @@ -851,7 +846,6 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS IVirtualTagEvaluator? virtualTagEvaluator = null, IHistoryWriter? historyWriter = null, IActorRef? virtualTagHostOverride = null, - ILoggerFactory? loggerFactory = null, ScriptRootLogger? scriptRootLogger = null, IActorRef? scriptedAlarmHostOverride = null, IDriverCapabilityInvokerFactory? invokerFactory = null, diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs index 17237391..ea327b42 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs @@ -609,7 +609,6 @@ public static class ServiceCollectionExtensions healthPublisher: healthPublisher, virtualTagEvaluator: virtualTagEvaluator, historyWriter: historyWriter, - loggerFactory: loggerFactory, scriptRootLogger: scriptRootLogger, invokerFactory: invokerFactory, deploymentArtifactCache: deploymentArtifactCache, From 530f4bdcd4935a879bbae6e9afc3e91fba20790d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:41:20 -0400 Subject: [PATCH 35/67] perf(ablegacy): coalesce adjacent N/B/F/L scalar reads into one PCCC request Review 07 P7 (rest): adjacent words on one data file were never coalesced (one round trip per tag) although the array path proves the transport can read a span. Within a device pass, scalar tags on one N/B/F/L file within 8 elements of each other (block <= 200 data bytes, under the 236-byte typed-read limit) now ride one multi-element read and are decoded from their offsets with exactly the CLR types the single-tag path returns (int / float / bool). Only shapes the single path decodes identically are coalesced: indirect, sub-element, array, file-number-less (S:/I:/O:) and width-mismatched tags keep the single path. A block the device refuses (an authored element past the end of the file, a native PLC-5 non-N file) falls back to per-tag reads for its members and its shape is remembered; a comms fault fails the members together. New driver-config key coalesceReads (default true) with an AdminUI switch (Razor change: AbLegacyDriverForm, for the Wave 4 live check). Adds an env-gated ab_server parity case (coalesced == per-tag values). Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriverOptions.cs | 7 + .../AbLegacyDriver.Coalescing.cs | 242 ++++++++++++++++++ .../AbLegacyDriver.cs | 35 ++- .../AbLegacyDriverFactoryExtensions.cs | 4 + .../Drivers/Forms/AbLegacyDriverForm.razor | 12 +- .../AbLegacyReadCoalescingIntegrationTests.cs | 69 +++++ .../AbLegacyDeviceIsolationTests.cs | 1 + .../AbLegacyReadCoalescingTests.cs | 182 +++++++++++++ ...bLegacyDriverPageFormSerializationTests.cs | 17 ++ 9 files changed, 563 insertions(+), 6 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs index 6f9cde21..36b21186 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs @@ -48,6 +48,13 @@ public sealed class AbLegacyDriverOptions /// Gets or sets the default timeout for read/write operations. public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2); + /// + /// Whether scalar tags on adjacent elements of one N / B / F / L file are read as ONE multi-element PCCC read + /// (driver-config key coalesceReads, default ). A block the device refuses falls + /// back to per-tag reads on its own; set this false to read every tag singly. + /// + public bool CoalesceReads { get; init; } = true; + /// /// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a /// 60s server-side maximum; this default is what the form pre-fills for new instances. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs new file mode 100644 index 00000000..c1c6fac1 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs @@ -0,0 +1,242 @@ +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Adjacent-element read coalescing (review 07 P7): scalar tags on the same N / B / F / L data file whose +/// elements are close together are read as ONE multi-element PCCC read (the same path array tags already use), +/// then each tag is decoded from its offset in the block. One round trip instead of one per tag. +/// +/// +/// Only shapes the single-tag path decodes identically are coalesced: a word tag +/// (Int/AnalogInt) or a bit of a word (Bit, with or without /n) on an N or B file; a +/// Float on an F file; a Long or a bit of a long on an L file. Indirect addresses, sub-elements +/// (T4:0.ACC), arrays, the file-number-less system files (S:, I:, O:) and any tag +/// authored with a type that does not match its file's element width keep the single-tag path, so a +/// mis-authored tag still gets exactly the answer it got before. +/// The span is safe to read. A block runs from its lowest to its highest member, so every element +/// it reads lies between two authored elements of the same file — inside the file whenever the authored tags +/// are. A device REFUSAL of a block (an authored element past the end of the file, a PLC-5 native non-N file) +/// falls back to single reads for that block's members, so one bad tag never blackens its neighbours, and the +/// block shape is remembered so it is not retried every poll. A comms fault fails the members together — they +/// share the connection — exactly as their single reads would have. +/// Turned off with the driver-config key coalesceReads: false. +/// +public sealed partial class AbLegacyDriver +{ + /// Largest block, in data bytes. Below the 236-byte PCCC typed-read limit, so libplctag never needs + /// to fragment a coalesced read (the native runtime would chunk it anyway). + internal const int MaxCoalescedBytes = 200; + + /// Largest run of UNAUTHORED elements a block may read across to join two members. + internal const int MaxCoalesceGap = 8; + + /// One unit of a device pass: a single tag, or a coalesced block of several. + private sealed record ReadUnit(ReadItem? Single, CoalescedBlock? Block); + + /// A coalesced block: elements of one file from . + private sealed record CoalescedBlock( + string FileLetter, int FileNumber, int Start, int Count, AbLegacyDataType ElementType, List Members) + { + /// The runtime-cache key; distinct from any RawPath (RawPaths never contain '|'). + public string Key => $"|block|{FileLetter}{FileNumber}:{Start}+{Count}"; + + /// The block's base address in libplctag form. + public string StartName => $"{FileLetter}{FileNumber}:{Start}"; + } + + /// + /// The element type a scalar tag is decoded from when it rides a coalesced block, or + /// when the tag keeps the single-tag path. + /// + private static AbLegacyDataType? CoalescedElementType(ReadItem item) + { + var a = item.Parsed; + if (a.IsIndirect || a.SubElement is not null || a.FileNumber is null || IsArrayTag(item.Def)) return null; + return (a.FileLetter, item.Def.DataType) switch + { + ("N" or "B", AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit) => AbLegacyDataType.Int, + ("F", AbLegacyDataType.Float) => AbLegacyDataType.Float, + ("L", AbLegacyDataType.Long) => AbLegacyDataType.Long, + ("L", AbLegacyDataType.Bit) when a.BitIndex is not null => AbLegacyDataType.Long, + _ => null, + }; + } + + /// + /// Plans a device pass: coalescable tags grouped per file into runs of nearby elements (a run of two or more + /// tags becomes a block), everything else a single unit. Block shapes the device refused before stay single. + /// + private List PlanReadUnits(DeviceState device, List items) + { + if (!_options.CoalesceReads || items.Count < 2) return [.. items.Select(i => new ReadUnit(i, null))]; + + var units = new List(items.Count); + var groups = new Dictionary<(string Letter, int File, AbLegacyDataType Type), List>(); + foreach (var item in items) + { + if (CoalescedElementType(item) is not { } type) + { + units.Add(new ReadUnit(item, null)); + continue; + } + var key = (item.Parsed.FileLetter, item.Parsed.FileNumber!.Value, type); + if (!groups.TryGetValue(key, out var list)) groups[key] = list = []; + list.Add(item); + } + + foreach (var ((letter, file, type), members) in groups) + { + var width = type == AbLegacyDataType.Int ? 2 : 4; + var maxElements = MaxCoalescedBytes / width; + var ordered = members.OrderBy(m => m.Parsed.WordNumber).ToList(); + var run = new List { ordered[0] }; + for (var i = 1; i <= ordered.Count; i++) + { + var next = i < ordered.Count ? ordered[i] : null; + var start = run[0].Parsed.WordNumber; + if (next is not null + && next.Parsed.WordNumber - run[^1].Parsed.WordNumber <= MaxCoalesceGap + 1 + && next.Parsed.WordNumber - start + 1 <= maxElements) + { + run.Add(next); + continue; + } + var count = run[^1].Parsed.WordNumber - start + 1; + var block = new CoalescedBlock(letter, file, start, count, type, run); + if (run.Count < 2 || device.RefusedBlocks.ContainsKey(block.Key)) + units.AddRange(run.Select(r => new ReadUnit(r, null))); + else + units.Add(new ReadUnit(null, block)); + if (next is not null) run = [next]; + } + } + return units; + } + + /// What a block read concluded; = read the members singly instead. + private enum BlockOutcome { Done, Fallback } + + /// + /// Reads one coalesced block and decodes each member from its offset. A device refusal or a local refusal + /// returns (and remembers the shape) without writing any member's result. + /// + private async Task<(BlockOutcome Outcome, OpVerdict Verdict, string? Message)> ReadBlockAsync( + DeviceState device, CoalescedBlock block, DataValueSnapshot[] results, DateTime now, CancellationToken ct) + { + void FailAll(uint status) + { + foreach (var m in block.Members) results[m.Index] = new DataValueSnapshot(null, status, null, now); + } + + (BlockOutcome, OpVerdict, string?) Refused(string why) + { + device.RefusedBlocks.TryAdd(block.Key, 0); + _logger.LogInformation( + "AbLegacy coalesced read {Block} refused ({Reason}); its {Count} tags read singly from now on. Driver={DriverInstanceId} Device={DeviceHostAddress}", + block.StartName + "+" + block.Count, why, block.Members.Count, _driverInstanceId, device.Options.HostAddress); + return (BlockOutcome.Fallback, OpVerdict.Neutral, null); + } + + try + { + var createParams = new AbLegacyTagCreateParams( + Gateway: device.ParsedAddress.Gateway, + Port: device.ParsedAddress.Port, + CipPath: device.EffectiveCipPath, + LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, + TagName: block.StartName, + Timeout: _options.Timeout, + ElementCount: block.Count); + var runtime = await CreateSingleFlightAsync( + device, device.Runtimes, block.Key, () => _tagFactory.Create(createParams), ct).ConfigureAwait(false); + + int status; + Array? data = null; + var opLock = device.GetRuntimeLock(block.Key); + await opLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await runtime.ReadAsync(ct).ConfigureAwait(false); + status = runtime.GetStatus(); + if (status == 0) data = runtime.DecodeArray(block.ElementType, block.Count) as Array; + } + finally + { + opLock.Release(); + } + + if (status != 0) + { + if (IsDeviceRefusal((AbStatus)status) || (AbStatus)status == AbStatus.Unsupported) + { + EvictRuntime(device, block.Key); + return Refused($"device status {(AbStatus)status}"); + } + EvictRuntime(device, block.Key); + FailAll(AbLegacyStatusMapper.MapStatus(status)); + return (BlockOutcome.Done, OpVerdict.Fault, $"transport status {status} reading {block.StartName}+{block.Count}"); + } + if (data is null || data.Length < block.Count) + return Refused("the block decoded short"); + + foreach (var m in block.Members) + results[m.Index] = new DataValueSnapshot(DecodeMember(block, m, data), AbLegacyStatusMapper.Good, now, now); + device.FirstNonZeroStatusLogged = false; + return (BlockOutcome.Done, OpVerdict.Answered, null); + } + catch (OperationCanceledException) { throw; } + catch (ConnectionThrottledException tex) + { + FailAll(AbLegacyStatusMapper.BadCommunicationError); + return (BlockOutcome.Done, OpVerdict.Throttled, tex.Message); + } + catch (AbTransportException ex) when (IsLocalRefusal(ex) || IsDeviceRefusal(ex.Status)) + { + EvictRuntime(device, block.Key); + return Refused(ex.Message); + } + catch (NotSupportedException nse) + { + EvictRuntime(device, block.Key); + return Refused(nse.Message); + } + catch (Exception ex) + { + EvictRuntime(device, block.Key); + FailAll(AbLegacyStatusMapper.BadCommunicationError); + return (BlockOutcome.Done, OpVerdict.Fault, ex.Message); + } + } + + /// + /// Decodes one member from the block, returning exactly the CLR type and value the single-tag path's + /// would (int for a word or long, float, bool). + /// + private static object DecodeMember(CoalescedBlock block, ReadItem m, Array data) + { + var offset = m.Parsed.WordNumber - block.Start; + var bit = m.Parsed.BitIndex; + switch (data) + { + case short[] words: + { + var w = words[offset]; + return m.Def.DataType == AbLegacyDataType.Bit + ? ((ushort)w >> (bit ?? 0) & 1) != 0 + : (int)w; + } + case int[] longs: + { + var l = longs[offset]; + return m.Def.DataType == AbLegacyDataType.Bit ? ((uint)l >> bit!.Value & 1) != 0 : l; + } + case float[] floats: + return floats[offset]; + default: + throw new InvalidOperationException($"Unexpected block buffer {data.GetType().Name}."); + } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 60da49b5..16f626da 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// only at PR 1 time; read / write / discovery / subscribe / probe / /// host-resolver capabilities ship in PRs 2 and 3. /// -public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, +public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable { /// Authored tags this driver could not build into its poll table. @@ -565,21 +565,42 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover DeviceState device, List items, DataValueSnapshot[] results, DateTime now, CancellationToken ct) { var outcome = new DeviceReadOutcome(); - foreach (var item in items) + var work = new Queue(PlanReadUnits(device, items)); + while (work.TryDequeue(out var unit)) { + var members = unit.Block?.Members ?? [unit.Single!]; if (device.IsThrottled(DateTime.UtcNow)) { - results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); + foreach (var m in members) + results[m.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); outcome.FaultMessage ??= $"device {device.Options.HostAddress} is in its reconnect backoff window"; continue; } - var (verdict, message) = await ReadOneAsync(device, item, results, now, ct).ConfigureAwait(false); + OpVerdict verdict; + string? message; + if (unit.Block is { } block) + { + (var blockOutcome, verdict, message) = await ReadBlockAsync(device, block, results, now, ct).ConfigureAwait(false); + if (blockOutcome == BlockOutcome.Fallback) + { + // Refused as a block: read its members singly, now, so each gets its own answer. + var rest = work.ToList(); + work.Clear(); + foreach (var m in block.Members) work.Enqueue(new ReadUnit(m, null)); + foreach (var r in rest) work.Enqueue(r); + continue; + } + } + else + { + (verdict, message) = await ReadOneAsync(device, unit.Single!, results, now, ct).ConfigureAwait(false); + } switch (verdict) { case OpVerdict.Answered: outcome.AnyAnswered = true; - if (results[item.Index].StatusCode == AbLegacyStatusMapper.Good) outcome.AnyGood = true; + if (members.Any(m => results[m.Index].StatusCode == AbLegacyStatusMapper.Good)) outcome.AnyGood = true; device.RecordAnswered(); break; case OpVerdict.Fault: @@ -1685,6 +1706,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// public bool FirstNonZeroStatusLogged { get; set; } + /// Coalesced block shapes the device refused; their members read singly from then on (bounded by the + /// distinct subscription shapes, released with the state). + public System.Collections.Concurrent.ConcurrentDictionary RefusedBlocks { get; } = new(StringComparer.Ordinal); + /// Tag operations already warned about for a deterministic refusal (once-per-tag-and-operation log). public System.Collections.Concurrent.ConcurrentDictionary RefusalLogged { get; } = new(StringComparer.Ordinal); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs index 9dfd9707..d06effd9 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs @@ -97,6 +97,7 @@ public static class AbLegacyDriverFactoryExtensions ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0", }, Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000), + CoalesceReads = dto.CoalesceReads ?? true, // Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver); // a bad value still fails exactly as any other bad enum field does. Transport = string.IsNullOrWhiteSpace(dto.Transport) @@ -157,6 +158,9 @@ public static class AbLegacyDriverFactoryExtensions /// public string? Transport { get; init; } + /// Gets or sets whether adjacent scalar reads are coalesced; absent means . + public bool? CoalesceReads { get; init; } + /// /// Gets or sets the list of devices to connect to. /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor index 9db2085a..a938652b 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor @@ -28,6 +28,13 @@
Which protocol stack talks to the controller. Default omits the setting (libplctag). Native is opt-in: wire-proven only for N-file reads/writes and F8 reads. Takes effect on the next deploy.
+
+
+ + +
+
Read nearby scalar tags on one N/B/F/L file as a single PCCC request. A block the PLC refuses falls back to per-tag reads automatically. Default on.
+
@@ -97,7 +104,7 @@ /// JSON paths this form edits; everything else in the stored config is preserved. internal static readonly string[] OwnedPaths = [ - "transport", "probeTimeoutSeconds", + "transport", "probeTimeoutSeconds", "coalesceReads", "probe.enabled", "probe.probeAddress", ]; @@ -163,6 +170,7 @@ // PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin // every edited driver to it. public AbLegacyTransport? Transport { get; set; } + public bool CoalesceReads { get; set; } = true; // Probe public bool ProbeEnabled { get; set; } = true; @@ -200,6 +208,7 @@ _loadedProbeTimeout = o.Probe.Timeout, TimeoutSeconds = (int)o.Timeout.TotalSeconds, Transport = o.Transport, + CoalesceReads = o.CoalesceReads, ProbeEnabled = o.Probe.Enabled, ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, @@ -220,6 +229,7 @@ }, Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout), Transport = Transport, + CoalesceReads = CoalesceReads, ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, }; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs new file mode 100644 index 00000000..2558adc3 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs @@ -0,0 +1,69 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; + +/// +/// Review 07 P7: a coalesced read of nearby N7 words and bits must decode exactly what per-tag reads return, +/// on the wire, on both transports. Seeds distinct values first when writes are allowed (simulator only); +/// otherwise compares whatever the simulator holds. +/// +[Collection(AbLegacyServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Simulator", "ab_server-PCCC")] +public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture sim) +{ + /// Coalesced and per-tag reads of the same tags agree value for value. + /// The simulator profile. + /// The transport stack the driver runs on. + [AbLegacyTheory] + [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))] + public async Task Coalesced_reads_match_single_reads(AbLegacyServerProfile profile, AbLegacyTransport transport) + { + if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip); + var ct = TestContext.Current.CancellationToken; + var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}"; + AbLegacyTagDefinition[] tags = + [ + new("W0", uri, "N7:0", AbLegacyDataType.Int), + new("W1", uri, "N7:1", AbLegacyDataType.Int), + new("W4", uri, "N7:4", AbLegacyDataType.Int), + new("W4b3", uri, "N7:4/3", AbLegacyDataType.Bit), + ]; + string[] refs = [.. tags.Select(t => t.Name)]; + + AbLegacyDriver Make(bool coalesce) => new(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(uri, profile.Family)], + RawTags = AbLegacyRawTags.Entries(tags), + Timeout = TimeSpan.FromSeconds(5), + Probe = new AbLegacyProbeOptions { Enabled = false }, + Transport = transport, + CoalesceReads = coalesce, + }, driverInstanceId: $"ablegacy-coalesce-{coalesce}-{profile.Family}-{transport}"); + + await using var single = Make(coalesce: false); + await using var coalesced = Make(coalesce: true); + await single.InitializeAsync("{}", ct); + await coalesced.InitializeAsync("{}", ct); + + var writesRefused = profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native; + if (Environment.GetEnvironmentVariable(IntegrationWriteGate.AllowWritesVar) == "1" + && Environment.GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is not { Length: > 0 } + && !writesRefused) + { + (await single.WriteAsync( + [new WriteRequest("W0", (short)101), new WriteRequest("W1", (short)-202), new WriteRequest("W4", (short)0x0008)], ct)) + .ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good); + } + + var a = await single.ReadAsync(refs, ct); + var b = await coalesced.ReadAsync(refs, ct); + + a.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); + b.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); + b.Select(s => s.Value).ShouldBe(a.Select(s => s.Value)); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs index a28b8b60..213fea69 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs @@ -45,6 +45,7 @@ public sealed class AbLegacyDeviceIsolationTests Devices = [new AbLegacyDeviceOptions(HostA), new AbLegacyDeviceOptions(HostB)], RawTags = AbLegacyRawTags.Entries(tags), Probe = new AbLegacyProbeOptions { Enabled = false }, + CoalesceReads = false, // per-tag isolation is under test here; coalescing has its own tests }, "drv-p7", factory); private static AbLegacyTagDefinition[] Tags(string prefix, string host, int n, string file = "N7") => diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs new file mode 100644 index 00000000..59fddba0 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs @@ -0,0 +1,182 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// Review 07 P7: adjacent N/B/F/L elements of one device were never coalesced (one PCCC round trip per tag), +/// although the array path proves the transport can read a span. Scalar tags near each other on one file now +/// ride ONE multi-element read and are decoded from their offsets — with exactly the CLR types the single-tag +/// path produces — and a block the device refuses falls back to per-tag reads. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyReadCoalescingTests +{ + private const string Host = "ab://10.0.0.5/1,0"; + + private static (AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory, List Created) NewDriver( + Func make, bool coalesce, params AbLegacyTagDefinition[] tags) + { + var created = new List(); + var factory = new FakeAbLegacyTagFactory + { + Customise = p => + { + lock (created) created.Add(p); + return make(p); + }, + }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host)], + RawTags = AbLegacyRawTags.Entries(tags), + Probe = new AbLegacyProbeOptions { Enabled = false }, + CoalesceReads = coalesce, + }, "drv-coalesce", factory); + return (drv, factory, created); + } + + private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type) => + new(name, Host, address, type); + + [Fact] + public async Task Nearby_N_file_words_and_bits_ride_one_read_and_decode_like_single_reads() + { + var (drv, _, created) = NewDriver( + p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 10, 11, 12, 4, 14, -15 } }, + coalesce: true, + Tag("W0", "N7:0", AbLegacyDataType.Int), + Tag("W1", "N7:1", AbLegacyDataType.AnalogInt), + Tag("B32", "N7:3/2", AbLegacyDataType.Bit), + Tag("B31", "N7:3/1", AbLegacyDataType.Bit), + Tag("W5", "N7:5", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync(["W0", "W1", "B32", "B31", "W5"], CancellationToken.None); + + r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); + r[0].Value.ShouldBe(10); // int, exactly as DecodeValue(Int) returns + r[1].Value.ShouldBe(11); + r[2].Value.ShouldBe(true); // word 3 = 0b100: bit 2 set + r[3].Value.ShouldBe(false); + r[4].Value.ShouldBe(-15); + created.Count.ShouldBe(1); + created[0].TagName.ShouldBe("N7:0"); + created[0].ElementCount.ShouldBe(6); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Float_and_long_files_coalesce_at_their_element_width() + { + var (drv, _, created) = NewDriver( + p => p.TagName.StartsWith('F') + ? new FakeAbLegacyTag(p) { ArrayValue = new[] { 1.5f, 0f, 2.5f } } + : new FakeAbLegacyTag(p) { ArrayValue = new[] { 7, int.MinValue } }, + coalesce: true, + Tag("F0", "F8:0", AbLegacyDataType.Float), + Tag("F2", "F8:2", AbLegacyDataType.Float), + Tag("L0", "L9:0", AbLegacyDataType.Long), + Tag("L1b31", "L9:1/31", AbLegacyDataType.Bit)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync(["F0", "F2", "L0", "L1b31"], CancellationToken.None); + + r.Select(s => s.Value).ShouldBe(new object[] { 1.5f, 2.5f, 7, true }); + created.Select(c => (c.TagName, c.ElementCount)).OrderBy(x => x.TagName) + .ShouldBe(new[] { ("F8:0", 3), ("L9:0", 2) }); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Far_apart_or_ineligible_tags_keep_the_single_tag_path() + { + var (drv, _, created) = NewDriver( + p => new FakeAbLegacyTag(p) { Value = 1 }, + coalesce: true, + Tag("Near", "N7:0", AbLegacyDataType.Int), + Tag("Far", "N7:40", AbLegacyDataType.Int), // gap > MaxCoalesceGap + Tag("Mistyped", "N7:1", AbLegacyDataType.Float), // width mismatch: single path answers as before + Tag("Acc", "T4:0.ACC", AbLegacyDataType.Int), // sub-element + Tag("Status", "S:1", AbLegacyDataType.Int), // no file number + Tag("Ind", "N7:[N7:0]", AbLegacyDataType.Int)); // indirect + await drv.InitializeAsync("{}", CancellationToken.None); + + _ = await drv.ReadAsync(["Near", "Far", "Mistyped", "Acc", "Status", "Ind"], CancellationToken.None); + + created.ShouldAllBe(c => c.ElementCount == 1); + created.Select(c => c.TagName).ShouldContain("N7:40"); + created.Select(c => c.TagName).ShouldContain("T4:0.ACC"); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task A_refused_block_falls_back_to_single_reads_and_is_not_retried() + { + // N7:9 is past the end of the file: the BLOCK is refused; singly, only that tag is. + var (drv, _, created) = NewDriver( + p => p.ElementCount > 1 || p.TagName == "N7:9" + ? new FakeAbLegacyTag(p) { Status = (int)AbStatus.OutOfBounds } + : new FakeAbLegacyTag(p) { Value = 3 }, + coalesce: true, + Tag("A", "N7:0", AbLegacyDataType.Int), + Tag("B", "N7:2", AbLegacyDataType.Int), + Tag("C", "N7:9", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + + for (var poll = 0; poll < 3; poll++) + { + var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}"); + r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}"); + r[2].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(AbStatus.OutOfBounds)); + } + created.Count(c => c.ElementCount > 1).ShouldBe(1); // the refused shape is remembered + drv.GetHealth().State.ShouldBe(DriverState.Healthy); // refusals are not comms faults + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task A_block_comms_fault_fails_its_members_together_and_degrades() + { + var (drv, _, _) = NewDriver( + p => new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new System.Net.Sockets.SocketException(10054) }, + coalesce: true, + Tag("A", "N7:0", AbLegacyDataType.Int), + Tag("B", "N7:1", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync(["A", "B"], CancellationToken.None); + + r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task CoalesceReads_false_reads_every_tag_singly() + { + var (drv, _, created) = NewDriver( + p => new FakeAbLegacyTag(p) { Value = 1 }, + coalesce: false, + Tag("A", "N7:0", AbLegacyDataType.Int), + Tag("B", "N7:1", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync(["A", "B"], CancellationToken.None); + + r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); + created.Count.ShouldBe(2); + created.ShouldAllBe(c => c.ElementCount == 1); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Theory] + [InlineData("""{"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)] + [InlineData("""{"coalesceReads":false,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", false)] + [InlineData("""{"coalesceReads":true,"someFutureKey":1,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)] + public void ParseOptions_reads_coalesceReads_defaulting_true(string json, bool expected) => + AbLegacyDriverFactoryExtensions.ParseOptions("drv", json).CoalesceReads.ShouldBe(expected); +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs index f4297127..162336cc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs @@ -256,6 +256,23 @@ public sealed class AbLegacyDriverPageFormSerializationTests bound.Probe.ProbeAddress.ShouldBe("S:1"); } + /// Review 07 P7: the coalesceReads switch is authored here and consumed by the driver factory. + [Theory] + [InlineData("""{"devices":[]}""", true, false)] + [InlineData("""{"coalesceReads":false,"devices":[]}""", false, true)] + public void CoalesceReads_round_trips_to_the_factory_bound_options(string original, bool loadedValue, bool edited) + { + var loaded = AbLegacyDriverForm.TryDeserialize(original)!; + var form = AbLegacyDriverForm.FormModel.FromJson(original, loaded); + form.CoalesceReads.ShouldBe(loadedValue); + form.CoalesceReads = edited; + + var saved = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), original); + + ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.AbLegacyDriverFactoryExtensions.ParseOptions("t", saved) + .CoalesceReads.ShouldBe(edited); + } + [Fact] public void A_legacy_TimeSpan_string_key_is_loaded_then_migrated_to_ms_and_removed() { From 6636de1199a80271dd2327940671e1a3b85978f7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:42:08 -0400 Subject: [PATCH 36/67] refactor(runtime): delete the per-value AttributeValuePublished message It had no production sender: every driver data change is already a batch, and the comms-loss fan was converted earlier. DependencyMuxActor drops its per-value receive, DriverInstanceActor drops the record, and DependencyMuxActorTests' 13 sends move to the ValuePublishes.Single one-item batch (same semantics through the batch path). XML docs and the living docs (DriverLifecycle, VirtualTags, ScriptEditor) are reworded; historical plan docs are left as written. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- docs/DriverLifecycle.md | 10 +++--- docs/ScriptEditor.md | 2 +- docs/VirtualTags.md | 6 ++-- .../IDependencyConsumer.cs | 2 +- .../ScriptAnalysis/IScriptTagCatalog.cs | 2 +- .../Drivers/DriverHostActor.cs | 2 +- .../Drivers/DriverInstanceActor.cs | 15 +++------ .../VirtualTags/DependencyMuxActor.cs | 33 ++----------------- .../DriverInstanceActorBatchForwardTests.cs | 2 +- .../Harness/ValuePublishes.cs | 4 +-- .../ContinuousHistorizationRecorderTests.cs | 2 +- .../VirtualTags/DependencyMuxActorTests.cs | 28 ++++++++-------- 12 files changed, 38 insertions(+), 70 deletions(-) diff --git a/docs/DriverLifecycle.md b/docs/DriverLifecycle.md index ef029efb..fdfc8cf6 100644 --- a/docs/DriverLifecycle.md +++ b/docs/DriverLifecycle.md @@ -512,16 +512,16 @@ same pass (a UNS reference variable, say) are visible through the index with no ⚠️ **The value fan is now a single message.** It sends one `DriverInstanceActor.AttributeValueBatchPublished` carrying one item per RawPath, where it previously sent -one `AttributeValuePublished` per ref — 5 000 messages onto the mux that also carries the node's steady +one per-value message per ref — 5 000 messages onto the mux that also carries the node's steady value ingress, every time one driver flapped. Delivery is unchanged: the mux filters the batch per item against the same registered-interest map the per-value receive consulted, so only the framing moved. The **alarm** fan is indexed but deliberately not batched — one driver ref can back several condition NodeIds, so it still emits one `AlarmQualityUpdate` per condition. -**Consequence worth knowing: `AttributeValuePublished` now has no production sender at all.** This fan was -its last one — everything on the subscription path was already wrapped in `AttributeValueBatchPublished`. -`DependencyMuxActor` still registers the per-value receive, but in production it is dead code; it stays -for tests and direct senders. +**Consequence: the per-value `AttributeValuePublished` record is deleted.** This fan was its last +production sender (everything on the subscription path was already wrapped in +`AttributeValueBatchPublished`), so the record and `DependencyMuxActor`'s per-value receive were removed +(2026-10-22). A single value is a one-item batch; tests build one with `ValuePublishes.Single`. #### The third fan: the value nodes (archreview 03/S15) diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md index b6472f00..25fd73e2 100644 --- a/docs/ScriptEditor.md +++ b/docs/ScriptEditor.md @@ -250,7 +250,7 @@ prefix. The concrete implementation `ScriptTagCatalog` reads `Tag` + | Virtual tag | Leaf `Name` (best-effort) | **Why only resolvable keys?** The live runtime resolves a `ctx.GetTag("X")` -literal against `DriverInstanceActor.AttributeValuePublished.FullReference`, +literal against `DriverInstanceActor.AttributeValueItem.FullReference`, which is the `FullName` field from `Tag.TagConfig` (see `ScriptTagCatalog.ExtractFullNameFromTagConfig` + `EquipmentNodeWalker.ExtractFullName`). The former UNS-path cascade engine (`Core.VirtualTags.VirtualTagEngine`, keyed by the diff --git a/docs/VirtualTags.md b/docs/VirtualTags.md index 183fcef0..2b7dc28a 100644 --- a/docs/VirtualTags.md +++ b/docs/VirtualTags.md @@ -93,7 +93,7 @@ Virtual-tag nodes are materialised by `AddressSpaceApplier` and fed by `VirtualT `ITagUpstreamSource` and `IHistoryWriter` are the two ports value consumers require from their host. Both live in `Core.VirtualTags`. In the v2 actor system: -- **Upstream-tag feed.** `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) routes `DriverInstanceActor.AttributeValuePublished` events to the `VirtualTagActor` instances that declared interest in those tag refs. Each `VirtualTagActor` holds the in-memory per-tag dependency map; the `IVirtualTagEvaluator` (`RoslynVirtualTagEvaluator`) receives the dependency snapshot synchronously on the actor message thread. Reads of never-pushed dependency refs return `null` values in the dependency snapshot. +- **Upstream-tag feed.** `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) routes the items of each `DriverInstanceActor.AttributeValueBatchPublished` to the `VirtualTagActor` instances that declared interest in those tag refs. Each `VirtualTagActor` holds the in-memory per-tag dependency map; the `IVirtualTagEvaluator` (`RoslynVirtualTagEvaluator`) receives the dependency snapshot synchronously on the actor message thread. Reads of never-pushed dependency refs return `null` values in the dependency snapshot. - **`IHistoryWriter`** — the equipment-namespace path threads `Historize` end-to-end and `VirtualTagHostActor` invokes the injected writer on historized results (H5); the writer is resolved through `DriverHostActor` DI with a `NullHistoryWriter` default. No *durable* writer ships on this seam — durable continuous historization now flows through the separate `ContinuousHistorizationRecorder` → HistorianGateway `WriteLiveValues` path (see the `IHistoryWriter` section above and [Historian.md](Historian.md)). ## Composition @@ -104,7 +104,7 @@ In the v2 actor system, virtual-tag engine composition is owned by the driver-ro - `AddressSpaceComposer.Compose` emits `DriverInstancePlan` / `ScriptedAlarmPlan` records; the driver-role `DriverHostActor` spawns one `VirtualTagActor` per virtual-tag expression and one `ScriptedAlarmActor` per scripted alarm. - `RoslynVirtualTagEvaluator` (`src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs`) is injected into each `VirtualTagActor` as its `IVirtualTagEvaluator`. It holds a per-source `CompiledScriptCache` keyed by script source and compiles on first use. -- `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) receives every `DriverInstanceActor.AttributeValuePublished` event and routes it to the `VirtualTagActor` instances that registered interest in that tag ref. +- `DependencyMuxActor` (`src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs`) receives every `DriverInstanceActor.AttributeValueBatchPublished` and routes each item to the `VirtualTagActor` instances that registered interest in that tag ref. `ITagUpstreamSource` and `IHistoryWriter` remain available as `Core.VirtualTags` ports. @@ -125,7 +125,7 @@ In the v2 actor system, virtual-tag engine composition is owned by the driver-ro - `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/ITagUpstreamSource.cs` — driver-tag read + subscribe port - `src/Core/ZB.MOM.WW.OtOpcUa.Core.VirtualTags/IHistoryWriter.cs` — historize sink port + `NullHistoryWriter` - `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/VirtualTagActor.cs` — actor that receives `DependencyValueChanged` from the mux and invokes `IVirtualTagEvaluator` per expression -- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — routes `DriverInstanceActor.AttributeValuePublished` to interested `VirtualTagActor` subscribers +- `src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs` — routes `DriverInstanceActor.AttributeValueBatchPublished` items to interested `VirtualTagActor` subscribers - `src/Server/ZB.MOM.WW.OtOpcUa.Host/Engines/RoslynVirtualTagEvaluator.cs` — production `IVirtualTagEvaluator` binding; holds a per-source `CompiledScriptCache` - `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceComposer.cs` — pure data composer: config-DB entities → `AddressSpaceComposition` (UNS topology + raw subtree + driver/alarm plans) - `src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs` — applies the composed plan into the SDK node manager diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDependencyConsumer.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDependencyConsumer.cs index e3261ceb..bbe5adba 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDependencyConsumer.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Abstractions/IDependencyConsumer.cs @@ -11,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Core.Abstractions; /// /// The mechanism mirrors how a VirtualTagActor registers interest on the /// DependencyMuxActor: the mux already receives every driver's -/// AttributeValuePublished keyed by wire-ref (under v3, RawPath), so a driver that +/// published values (batched, one item per value) keyed by wire-ref (under v3, RawPath), so a driver that /// implements this interface simply piggy-backs on that fan-out. A calc tag's computed output /// re-enters the mux via the ordinary driver publish path, so calc-of-calc chains work with no /// extra machinery — which is exactly why the deploy-time cycle gate is mandatory. diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs index 6088edb3..f4b652e2 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/IScriptTagCatalog.cs @@ -50,7 +50,7 @@ public sealed record ScriptTagInfo(string Path, string Kind, string DataType, st /// else. Verified chain: AddressSpaceComposer harvests the ctx.GetTag("…") literals /// (via EquipmentScriptPaths.ExtractDependencyRefs) into DependencyRefs; those become /// VirtualTagActor._dependencyRefs, registered with DependencyMuxActor, whose -/// _byRef map is keyed (Ordinal) by DriverInstanceActor.AttributeValuePublished.FullReference +/// _byRef map is keyed (Ordinal) by DriverInstanceActor.AttributeValueItem.FullReference /// — and in v3 a driver's wire reference is the RawPath /// (DriverHostActor._nodeIdByDriverRef is keyed (DriverInstanceId, RawPath)). /// diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 27bc6a70..31f13546 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -54,7 +54,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Drivers; /// and the ONLY value ingress from driver children ( wraps even a /// single data change in an AttributeValueBatchPublished). /// fabricates a batch of the same shape straight to the dependency mux, so the host neither receives nor -/// sends the per-value AttributeValuePublished any more. +/// sends a per-value message any more (the per-value record is deleted). /// public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedStash { diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs index c1ad189b..287e0763 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs @@ -108,21 +108,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers /// state — a no-op when already Reconnecting or Connecting. /// public sealed record ForceReconnect; - /// One driver-published value. No production sender remains — the subscription path - /// batches every data change into an , and the comms-loss fan - /// (DriverHostActor.FanCommsLossToDependencyMux), which was the last sender to fabricate this - /// message, now fabricates a batch too. The dependency mux still accepts it for tests and direct senders. + /// One value within an . /// is the driver's full OPC UA status code including its sub-code /// — not a severity bucket. Classify it with and friends rather - /// than comparing it to . - public sealed record AttributeValuePublished(string DriverInstanceId, string FullReference, object? Value, uint StatusCode, DateTime TimestampUtc); - /// One value within an . + /// than comparing it to . /// A struct deliberately: an AttributeValueItem[] then holds its items inline, so a burst of /// N values costs one array allocation instead of one array plus N boxes. Indexed access through /// does not box. public readonly record struct AttributeValueItem(string FullReference, object? Value, uint StatusCode, DateTime TimestampUtc); - /// Batched form of — every changed attribute of ONE - /// driver production burst in one message. The parent fans the whole batch to the mux and the OPC UA + /// The ONLY value message a driver child sends: every changed attribute of ONE driver production + /// burst in one message (a single data change is a one-item batch). The parent fans the whole batch to the mux and the OPC UA /// publish path in one hop each. Status codes ride verbatim. public sealed record AttributeValueBatchPublished(string DriverInstanceId, IReadOnlyList Items); private sealed record DataChangeForward(string FullReference, DataValueSnapshot Snapshot); @@ -132,7 +127,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers private sealed record DataChangeBatchForward(IReadOnlyList Items); /// Published to the parent whenever the subscribed driver (an ) fires /// . The parent () projects + routes it - /// to the materialised Part 9 condition. Parallels . + /// to the materialised Part 9 condition. Parallels . public sealed record AttributeAlarmPublished(string DriverInstanceId, AlarmEventArgs Args); private sealed record NativeAlarmRaised(AlarmEventArgs Args); /// Self-sent on Connected entry / when alarm refs are (re)pushed, to establish the native-alarm diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs index 3dd8441e..51174b9e 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs @@ -15,11 +15,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; /// DriverHostActor forwards driver-child value publishes to this mux (one mux per driver-role /// node). The ONLY driver ingress is the batched /// — one envelope per driver -/// production burst, which the mux filters per item. The per-value -/// receive has no production sender left -/// at all: the comms-loss fan (DriverHostActor.FanCommsLossToDependencyMux) was its last -/// one and now fabricates an AttributeValueBatchPublished instead. The receive is kept for -/// tests and direct senders — treat it as dead in production. +/// production burst, which the mux filters per item. The comms-loss fan +/// (DriverHostActor.FanCommsLossToDependencyMux) fabricates one too. There is no per-value +/// ingress: a single value is a one-item batch (tests build one with ValuePublishes.Single). /// /// The mux emits ONLY batches, so a subscriber's /// batch receive is its complete hot path. Every subscriber keeps a per-value @@ -152,7 +150,6 @@ public sealed class DependencyMuxActor : ReceiveActor, IWithTimers Receive(msg => RemoveSubscriber(msg.Subscriber)); Receive(OnSourceRemoved); Receive(_ => OnSweep()); - Receive(OnAttributeValuePublished); Receive(OnAttributeValueBatchPublished); Receive(msg => RemoveSubscriber(msg.ActorRef)); } @@ -263,30 +260,6 @@ public sealed class DependencyMuxActor : ReceiveActor, IWithTimers _log.Debug("DependencyMux: source {Driver} removed — evicted {Count} retained value(s)", msg.DriverInstanceId, evicted); } - private void OnAttributeValuePublished(DriverInstanceActor.AttributeValuePublished msg) - { - if (!_byRef.TryGetValue(msg.FullReference, out var entry)) - { - // Nothing is registered for (or retaining) this ref — drop. Common in normal operation; the address - // space carries thousands of tags and only a fraction feed virtual-tag expressions. - return; - } - var dep = new VirtualTagActor.DependencyValueChanged(msg.FullReference, msg.Value, msg.TimestampUtc, msg.StatusCode); - if (entry.Retains) - { - entry.Last = dep; - entry.LastSource = msg.DriverInstanceId; - } - foreach (var sub in entry.Subscribers) - { - // A single-item batch, so the mux emits exactly ONE message shape and every subscriber needs - // only the batch receive on its hot path. One list per subscriber: batch ownership transfers - // to the receiver, so a shared list must never cross mailboxes. Nothing in production reaches - // this path (see the class doc) — the extra allocation is noise on a test-only seam. - sub.Tell(new VirtualTagActor.DependencyValueBatch(new[] { dep })); - } - } - private void OnAttributeValueBatchPublished(DriverInstanceActor.AttributeValueBatchPublished msg) { // Nothing registered and nothing retained — skip the whole burst rather than probing an empty map per item. diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorBatchForwardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorBatchForwardTests.cs index 1c470992..1efaed5e 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorBatchForwardTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorBatchForwardTests.cs @@ -110,7 +110,7 @@ public sealed class DriverInstanceActorBatchForwardTests : RuntimeActorTestBase batch.Items.Count.ShouldBe(2); // The per-tag raises for the SAME burst produced nothing extra — no second batch, no - // AttributeValuePublished. Anything here means both handlers were attached. + // single-item batch. Anything here means both handlers were attached. parent.ExpectNoMsg(TimeSpan.FromMilliseconds(400)); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/ValuePublishes.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/ValuePublishes.cs index d0582eff..4831643c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/ValuePublishes.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Harness/ValuePublishes.cs @@ -5,8 +5,8 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; /// /// Builds the ONE message shape a driver child sends for its values — a /// — for tests that push a single value into the -/// dependency mux by hand. The per-value has no -/// production sender (02/U4); tests should not keep one alive either. +/// dependency mux by hand. There is no per-value message: the former per-value record had no production +/// sender and was deleted (02/U4). /// public static class ValuePublishes { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs index 10bce23b..7cccf27d 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Historian/ContinuousHistorizationRecorderTests.cs @@ -19,7 +19,7 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Historian; /// /// Adapted from the plan's Task 17: the recorder handles the REAL fan-out message the mux emits — /// (TagId/Value/TimestampUtc/StatusCode) — -/// not DriverInstanceActor.AttributeValuePublished. The typed-value / real-status behaviour is +/// not the driver-side DriverInstanceActor.AttributeValueBatchPublished. The typed-value / real-status behaviour is /// pinned in . /// public sealed class ContinuousHistorizationRecorderTests : TestKit diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs index 76ad462f..79237192 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/VirtualTags/DependencyMuxActorTests.cs @@ -10,9 +10,9 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.VirtualTags; public sealed class DependencyMuxActorTests : RuntimeActorTestBase { - /// Verifies that AttributeValuePublished messages are forwarded only to subscribers interested in the specific reference. + /// Verifies that a published value is forwarded only to subscribers interested in the specific reference. [Fact] - public void AttributeValuePublished_is_forwarded_only_to_subscribers_interested_in_that_ref() + public void A_published_value_is_forwarded_only_to_subscribers_interested_in_that_ref() { var mux = Sys.ActorOf(DependencyMuxActor.Props()); var subA = CreateTestProbe(); @@ -21,9 +21,9 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1", "tag-2" }, subA.Ref)); mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-2", "tag-3" }, subB.Ref)); - mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow)); - mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-3", 30, OpcUaStatus.Good, DateTime.UtcNow)); - mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-2", 20, OpcUaStatus.Good, DateTime.UtcNow)); + mux.Tell(ValuePublishes.Single("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow)); + mux.Tell(ValuePublishes.Single("driver-1", "tag-3", 30, OpcUaStatus.Good, DateTime.UtcNow)); + mux.Tell(ValuePublishes.Single("driver-1", "tag-2", 20, OpcUaStatus.Good, DateTime.UtcNow)); // The mux emits ONLY batches — the per-value comms-loss ingress fans out as a single-item batch. // subA hears tag-1 + tag-2. @@ -54,7 +54,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase var sub = CreateTestProbe(); mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref)); - mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "nobody-cares", 99, OpcUaStatus.Good, DateTime.UtcNow)); + mux.Tell(ValuePublishes.Single("driver-1", "nobody-cares", 99, OpcUaStatus.Good, DateTime.UtcNow)); sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } @@ -67,11 +67,11 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase var sub = CreateTestProbe(); mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref)); - mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow)); + mux.Tell(ValuePublishes.Single("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow)); sub.ExpectMsg(); mux.Tell(new DependencyMuxActor.UnregisterInterest(sub.Ref)); - mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 20, OpcUaStatus.Good, DateTime.UtcNow)); + mux.Tell(ValuePublishes.Single("driver-1", "tag-1", 20, OpcUaStatus.Good, DateTime.UtcNow)); sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } @@ -85,10 +85,10 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref)); mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-2" }, sub.Ref)); // replaces tag-1 - mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow)); + mux.Tell(ValuePublishes.Single("driver-1", "tag-1", 10, OpcUaStatus.Good, DateTime.UtcNow)); sub.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); - mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "tag-2", 20, OpcUaStatus.Good, DateTime.UtcNow)); + mux.Tell(ValuePublishes.Single("driver-1", "tag-2", 20, OpcUaStatus.Good, DateTime.UtcNow)); sub.ExpectMsg().Items.ShouldHaveSingleItem().TagId.ShouldBe("tag-2"); } @@ -110,18 +110,18 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase // gets dropped (no subscriber for "ref-a" yet), so we re-publish each pass. AwaitAssert(() => { - mux.Tell(new DriverInstanceActor.AttributeValuePublished( + mux.Tell(ValuePublishes.Single( "driver-1", "ref-a", 10, OpcUaStatus.Good, DateTime.UtcNow)); parent.ExpectMsg(TimeSpan.FromMilliseconds(200)) .Value.ShouldBe(10); }, duration: PresenceBudget); // From here the actor is wired — second publish drives the sum. - mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "ref-b", 32, OpcUaStatus.Good, DateTime.UtcNow)); + mux.Tell(ValuePublishes.Single("driver-1", "ref-b", 32, OpcUaStatus.Good, DateTime.UtcNow)); parent.ExpectMsg(PresenceBudget).Value.ShouldBe(42); // Unrelated ref shouldn't fire eval. - mux.Tell(new DriverInstanceActor.AttributeValuePublished("driver-1", "ref-unrelated", 99, OpcUaStatus.Good, DateTime.UtcNow)); + mux.Tell(ValuePublishes.Single("driver-1", "ref-unrelated", 99, OpcUaStatus.Good, DateTime.UtcNow)); parent.ExpectNoMsg(TimeSpan.FromMilliseconds(200)); } @@ -221,7 +221,7 @@ public sealed class DependencyMuxActorTests : RuntimeActorTestBase var sub = CreateTestProbe(); mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { "tag-1" }, sub.Ref)); - mux.Tell(new DriverInstanceActor.AttributeValuePublished( + mux.Tell(ValuePublishes.Single( "driver-1", "tag-1", 10, OpcUaStatus.Bad, DateTime.UtcNow)); sub.ExpectMsg() From 582e82899bcb79064270ed1e64a65febf490651c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:42:23 -0400 Subject: [PATCH 37/67] feat(ablegacy): implement IBatchSubscribable Review 02 P1 / Wave 2 rule (d): AbLegacy runs PollGroupEngine but raised only the per-tag OnDataChange, so every changed tag paid its own trip through the actor chain. The engine's per-cycle batch is now raised as OnDataChangeBatch (one per poll cycle), alongside the per-tag event for legacy consumers. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriver.cs | 10 +++- .../AbLegacyBatchSubscribeTests.cs | 50 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBatchSubscribeTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 16f626da..773acc64 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// only at PR 1 time; read / write / discovery / subscribe / probe / /// host-resolver capabilities ship in PRs 2 and 3. /// -public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, +public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable, IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable { /// Authored tags this driver could not build into its poll table. @@ -64,6 +64,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// public event EventHandler? OnDataChange; + /// + /// One raise per poll cycle carrying every tag that changed in it (review 02 P1). Raised alongside the per-tag + /// ; a consumer subscribes to exactly one of the two. + /// + public event EventHandler? OnDataChangeBatch; + /// /// Occurs when host status changes. /// @@ -99,6 +105,8 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)), onError: HandlePollError, backoffCap: PollBackoffCap, + onBatch: (handle, items) => + OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)), // The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this // closure IS the production consumer of its overrun signal. onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBatchSubscribeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBatchSubscribeTests.cs new file mode 100644 index 00000000..08c422b0 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBatchSubscribeTests.cs @@ -0,0 +1,50 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// Review 02 P1: AbLegacy ran but did not implement , +/// so every changed tag paid its own trip through the actor chain. It now raises one +/// per poll cycle, alongside the per-tag event. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyBatchSubscribeTests +{ + private const string Host = "ab://10.0.0.5/1,0"; + + [Fact] + public async Task One_batch_per_poll_carries_every_changed_tag_and_per_tag_events_still_fire() + { + var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? 1 : 2 } }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host)], + RawTags = AbLegacyRawTags.Entries( + new AbLegacyTagDefinition("A", Host, "N7:0", AbLegacyDataType.Int), + new AbLegacyTagDefinition("B", Host, "N9:0", AbLegacyDataType.Int)), + Probe = new AbLegacyProbeOptions { Enabled = false }, + }, "drv-batch", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + IBatchSubscribable batching = drv; // the capability the actor prefers + + var batches = new System.Collections.Concurrent.ConcurrentQueue(); + var singles = new System.Collections.Concurrent.ConcurrentQueue(); + batching.OnDataChangeBatch += (_, e) => batches.Enqueue(e); + drv.OnDataChange += (_, e) => singles.Enqueue(e); + + var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None); + var deadline = DateTime.UtcNow.AddSeconds(5); + while (batches.IsEmpty && DateTime.UtcNow < deadline) await Task.Delay(10); + + batches.TryPeek(out var first).ShouldBeTrue(); + first!.SubscriptionHandle.ShouldBe(handle); + first.Items.Select(i => i.FullReference).ShouldBe(new[] { "A", "B" }, ignoreOrder: true); + first.Items.Single(i => i.FullReference == "A").Snapshot.Value.ShouldBe(1); + singles.Count.ShouldBeGreaterThanOrEqualTo(2); // positive control: the legacy per-tag event still fires + + await drv.UnsubscribeAsync(handle, CancellationToken.None); + await drv.ShutdownAsync(CancellationToken.None); + } +} From 66927a3faa77df2d6ec05930cbcf31bf7197dcd0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:43:23 -0400 Subject: [PATCH 38/67] fix(ablegacy): wire the authored probeTimeoutSeconds into Test Connect Dead config field (user decision: wire them all): the AdminUI form authors probeTimeoutSeconds ("Admin UI probe timeout", default 5) but AbLegacyDriverProbe never read it, so Test Connect always ran on the caller's budget. It now tightens that budget (never raises it), the same rule as the Galaxy probe; a non-positive value keeps the caller's budget. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriverProbe.cs | 20 +++++++++++ .../AbLegacyDriverProbeTests.cs | 34 +++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs index 172e1dea..ac46d8a7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs @@ -55,6 +55,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); } if (opts is null) return new(false, "Config JSON deserialized to null.", null); + // The authored "Admin UI probe timeout" (probeTimeoutSeconds) tightens the caller's budget. It was parsed by + // nothing before: Test Connect always ran on the caller's budget alone. + timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds); + var (parsed, firstDevice) = ExtractTarget(opts); if (parsed is null || string.IsNullOrWhiteSpace(parsed.Gateway) || parsed.Port <= 0) return new(false, "Config has no host/port to probe.", null); @@ -169,6 +173,22 @@ public sealed class AbLegacyDriverProbe : IDriverProbe /// Whether the status proves the controller answered. internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound; + /// + /// The deadline the probe runs under: the caller's budget, tightened by the config's + /// when that is positive and shorter (same rule as the + /// Galaxy probe). A non-positive caller budget is floored to 1 s first. + /// + /// The budget the host handed the probe. + /// The config's probeTimeoutSeconds; ignored when not positive. + /// The effective probe deadline. + internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds) + { + var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1); + if (configuredSeconds <= 0) return caller; + var configured = TimeSpan.FromSeconds(configuredSeconds); + return configured < caller ? configured : caller; + } + private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts) { // Parse the first device's ab:// host address to extract the gateway IP + EIP port. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs index f58536d7..c9c3aa51 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs @@ -192,6 +192,40 @@ public sealed class AbLegacyDriverProbeTests LibplctagLegacyName.Translate(status!).ShouldBe("S2:0"); } + private sealed class StallingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p) + { + public override Task InitializeAsync(CancellationToken ct) => Task.Delay(Timeout.Infinite, ct); + } + + /// + /// The authored probeTimeoutSeconds ("Admin UI probe timeout") was parsed by nothing — Test Connect ran + /// on the caller's budget alone. It now tightens that budget (never raises it); absent or non-positive keeps + /// the caller's budget (the positive control). + /// + [Theory] + [InlineData(",\"probeTimeoutSeconds\":1", "1s")] + [InlineData(",\"probeTimeoutSeconds\":30", "2s")] + [InlineData("", "2s")] + [InlineData(",\"probeTimeoutSeconds\":0", "2s")] + public async Task Configured_probe_timeout_tightens_the_caller_budget(string key, string expectedBudget) + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var factory = new FakeAbLegacyTagFactory { Customise = p => new StallingTag(p) }; + var json = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]{key}}}"; + + var result = await new AbLegacyDriverProbe(factory) + .ProbeAsync(json, TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken); + + result.Ok.ShouldBeFalse(); + result.Message.ShouldBe($"Probe timed out after {expectedBudget}."); + } + finally { listener.Stop(); } + } + [Fact] public async Task Unparseable_probe_address_is_a_clean_failure_not_an_exception() { From ffa5b2a4fac81b9bcfd5cf4644444ad35845133c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:44:51 -0400 Subject: [PATCH 39/67] fix(ablegacy-cli): Ctrl-C prints Cancelled instead of a stack trace Review 07 U7 (AbLegacy part): probe/read/write had no OperationCanceledException arm, so cancelling a slow read printed an unhandled-exception stack trace. The driver work now runs through AbLegacyCommandBase.RunCancellableAsync, which reports an operator cancellation as "Cancelled." and still propagates anything else. (The AbCip subscribe lock and SnapshotFormatter array rendering are other lanes' files.) Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyCommandBase.cs | 21 ++++++++ .../Commands/ProbeCommand.cs | 23 ++++---- .../Commands/ReadCommand.cs | 9 ++-- .../Commands/WriteCommand.cs | 9 ++-- .../CancellationHandlingTests.cs | 52 +++++++++++++++++++ 5 files changed, 98 insertions(+), 16 deletions(-) create mode 100644 tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/CancellationHandlingTests.cs diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs index e96b1a75..157caf2d 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs @@ -32,6 +32,27 @@ public abstract class AbLegacyCommandBase : DriverCommandBase "LogixPccc unverified).")] public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default; + /// + /// Runs a one-shot command's driver work so a Ctrl-C (cancellation of ) prints + /// Cancelled. instead of an unhandled stack trace (review 07 U7). + /// Any other exception — and an OCE the operator did not ask for — still propagates. + /// + /// The CLI console. + /// The console's Ctrl-C token. + /// The command body. + /// A task that completes when the body has finished or was cancelled. + internal static async ValueTask RunCancellableAsync(CliFx.Infrastructure.IConsole console, CancellationToken ct, Func body) + { + try + { + await body(); + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + await console.Error.WriteLineAsync("Cancelled."); + } + } + /// Gets the per-operation timeout in milliseconds. [CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")] public int TimeoutMs { get; init; } = 5000; diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs index c1ae0737..f89d28d1 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs @@ -43,17 +43,20 @@ public sealed class ProbeCommand : AbLegacyCommandBase var driver = new AbLegacyDriver(options, DriverInstanceId); try { - await driver.InitializeAsync("{}", ct); - var snapshot = await driver.ReadAsync(["__probe"], ct); - var health = driver.GetHealth(); + await RunCancellableAsync(console, ct, async () => + { + await driver.InitializeAsync("{}", ct); + var snapshot = await driver.ReadAsync(["__probe"], ct); + var health = driver.GetHealth(); - await console.Output.WriteLineAsync($"Gateway: {Gateway}"); - await console.Output.WriteLineAsync($"PLC type: {PlcType}"); - await console.Output.WriteLineAsync($"Health: {health.State}"); - if (health.LastError is { } err) - await console.Output.WriteLineAsync($"Last error: {err}"); - await console.Output.WriteLineAsync(); - await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0])); + await console.Output.WriteLineAsync($"Gateway: {Gateway}"); + await console.Output.WriteLineAsync($"PLC type: {PlcType}"); + await console.Output.WriteLineAsync($"Health: {health.State}"); + if (health.LastError is { } err) + await console.Output.WriteLineAsync($"Last error: {err}"); + await console.Output.WriteLineAsync(); + await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0])); + }); } finally { diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs index 48c381c5..89a1e071 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs @@ -45,9 +45,12 @@ public sealed class ReadCommand : AbLegacyCommandBase var driver = new AbLegacyDriver(options, DriverInstanceId); try { - await driver.InitializeAsync("{}", ct); - var snapshot = await driver.ReadAsync([tagName], ct); - await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0])); + await RunCancellableAsync(console, ct, async () => + { + await driver.InitializeAsync("{}", ct); + var snapshot = await driver.ReadAsync([tagName], ct); + await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0])); + }); } finally { diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs index 2a414bfc..d4c61ba4 100644 --- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs +++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs @@ -55,9 +55,12 @@ public sealed class WriteCommand : AbLegacyCommandBase var driver = new AbLegacyDriver(options, DriverInstanceId); try { - await driver.InitializeAsync("{}", ct); - var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct); - await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0])); + await RunCancellableAsync(console, ct, async () => + { + await driver.InitializeAsync("{}", ct); + var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct); + await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0])); + }); } finally { diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/CancellationHandlingTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/CancellationHandlingTests.cs new file mode 100644 index 00000000..bb42e5f6 --- /dev/null +++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/CancellationHandlingTests.cs @@ -0,0 +1,52 @@ +using CliFx.Infrastructure; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests; + +/// +/// Review 07 U7: the AbLegacy probe/read/write commands had no arm, so a +/// Ctrl-C printed a stack trace. They now run their driver work through +/// . +/// +[Trait("Category", "Unit")] +public sealed class CancellationHandlingTests +{ + [Fact] + public async Task Operator_cancellation_prints_Cancelled_instead_of_throwing() + { + using var console = new FakeInMemoryConsole(); + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await Should.NotThrowAsync(async () => await AbLegacyCommandBase.RunCancellableAsync( + console, cts.Token, () => Task.FromCanceled(cts.Token))); + + console.ReadErrorString().ShouldContain("Cancelled."); + } + + [Fact] + public async Task A_cancellation_the_operator_did_not_request_still_propagates() + { + using var console = new FakeInMemoryConsole(); + + await Should.ThrowAsync(async () => await AbLegacyCommandBase.RunCancellableAsync( + console, CancellationToken.None, () => throw new OperationCanceledException())); + } + + [Fact] + public async Task A_completed_body_runs_to_completion() + { + using var console = new FakeInMemoryConsole(); + var ran = false; + + await AbLegacyCommandBase.RunCancellableAsync(console, CancellationToken.None, () => + { + ran = true; + return Task.CompletedTask; + }); + + ran.ShouldBeTrue(); // positive control + console.ReadErrorString().ShouldBeEmpty(); + } +} From 61dd498eab13a41f7bb1a58b59996ac248649ab9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:46:04 -0400 Subject: [PATCH 40/67] test(ablegacy): pin the array-write capability row against the encoder DriverArrayWriteSupport (Core.Abstractions, not this lane's) still lists AbLegacy as scalar-only, so every AbLegacy array node is materialised read-only and the new array encoder is unreachable from OPC UA. Adds the Modbus/S7-style parity test, skipped until follow-up F1 adds the AbLegacy row (Int/AnalogInt/Long/Float with a declared length), plus an active test pinning exactly which element types the encoder accepts. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyArrayWriteSupportParityTests.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs new file mode 100644 index 00000000..acae0cdd --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs @@ -0,0 +1,58 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// Pins 's AbLegacy row against the REAL array encoder +/// (), the same way the Modbus and S7 parity tests do: for every element +/// type the server must advertise an array node writable exactly when the driver encodes it. +/// +/// +/// ⚠️ SKIPPED until follow-up F1 lands: the driver gained an array encoder in this lane (review 07 U10), but +/// (Core.Abstractions, not owned by the AbLegacy lane) still lists AbLegacy as +/// scalar-only, so every AbLegacy array node stays read-only and the encoder is unreachable from OPC UA. When F1 +/// adds the row, delete the Skip; this test then holds the table and the encoder together. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyArrayWriteSupportParityTests +{ + public static TheoryData AllTypes() => new(Enum.GetValues()); + + private static string AddressFor(AbLegacyDataType t) => t switch + { + AbLegacyDataType.Float => "F8:0", + AbLegacyDataType.Long => "L9:0", + AbLegacyDataType.Bit => "B3:0", + AbLegacyDataType.String => "ST9:0", + AbLegacyDataType.TimerElement => "T4:0", + AbLegacyDataType.CounterElement => "C5:0", + AbLegacyDataType.ControlElement => "R6:0", + _ => "N7:0", + }; + + [Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")] + [MemberData(nameof(AllTypes))] + public void Table_agrees_with_the_array_encoder(AbLegacyDataType dataType) + { + var json = $$"""{"address":"{{AddressFor(dataType)}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}"""; + var encoderAccepts = AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _) + && AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good; + + DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json).ShouldBe(encoderAccepts, dataType.ToString()); + } + + /// What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float). + [Theory] + [InlineData(AbLegacyDataType.Int, true)] + [InlineData(AbLegacyDataType.AnalogInt, true)] + [InlineData(AbLegacyDataType.Long, true)] + [InlineData(AbLegacyDataType.Float, true)] + [InlineData(AbLegacyDataType.Bit, false)] + [InlineData(AbLegacyDataType.String, false)] + [InlineData(AbLegacyDataType.TimerElement, false)] + public void Encoder_accepts_exactly_the_flat_numeric_element_types(AbLegacyDataType dataType, bool accepts) => + (AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good) + .ShouldBe(accepts); +} From 34e455f412652f07f2b652e52fdfa3e4fcda1555 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:48:35 -0400 Subject: [PATCH 41/67] test(ablegacy): dead-device gate tolerates a coalesced single-unit pass Live run against ab_server (slc500/micrologix/plc5 x libplctag/native): with coalescing on, the blackholed device's 20 tags are one block, i.e. one comms fault per poll, so its backoff window opens on the second poll rather than the first. The gate now asserts the actual contract: at most two timeouts across three polls, the live device Good on every poll, and the third poll fast. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDeadDeviceIsolationTests.cs | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs index 2e5be188..64eb19fb 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs @@ -47,19 +47,23 @@ public sealed class AbLegacyDeadDeviceIsolationTests(AbLegacyServerFixture sim) await drv.InitializeAsync("{}", ct); string[] refs = ["Live", .. dead.Select(d => d.Name)]; - var sw = Stopwatch.StartNew(); - var first = await drv.ReadAsync(refs, ct); - sw.Stop(); + // Two consecutive comms faults mark the device down. With coalescing on, the dead device's 20 tags are ONE + // block (one fault per poll), so the window can open on the second poll rather than the first — either way + // at most two timeouts are paid before the device stops being dialled. + var total = TimeSpan.Zero; + TimeSpan last = default; + for (var poll = 0; poll < 3; poll++) + { + var sw = Stopwatch.StartNew(); + var r = await drv.ReadAsync(refs, ct); + sw.Stop(); + total += last = sw.Elapsed; + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: the live device must be served in the same poll"); + r.Skip(1).ShouldAllBe(s => s.StatusCode != AbLegacyStatusMapper.Good); + } - first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, "the live device must be served in the same poll"); - first.Skip(1).ShouldAllBe(s => s.StatusCode != AbLegacyStatusMapper.Good); - sw.Elapsed.ShouldBeLessThan(timeout * 3, "a dead device may cost at most two timeouts per backoff window"); - - sw.Restart(); - var second = await drv.ReadAsync(refs, ct); - sw.Stop(); - second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); - sw.Elapsed.ShouldBeLessThan(timeout, "inside the backoff window the dead device is not dialled"); + total.ShouldBeLessThan(timeout * 2.5, "a dead device may cost at most two timeouts per backoff window"); + last.ShouldBeLessThan(timeout, "inside the backoff window the dead device is not dialled"); drv.GetHealth().State.ShouldBe(DriverState.Degraded); } } From 459cbb444ee2f5387bc24595239f78c79cf82b5f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:53:06 -0400 Subject: [PATCH 42/67] fix(abcip): review fix round 1 - BOOL-array fallback, local refusals, minors I1: the U11 Bits[n] -> Bits[n/32] rewrite was unconditional. The native runtime now keeps the original element path until the first reply settles the layout: 0xD3 confirms DWORD addressing (bit n%32); any other reply type (e.g. a 0xC1 per-element BOOL array) or a refusal of the DWORD index falls back to Bits[n] and re-reads, and a write settles the layout by a read first, so it can never decode or write element n/32 of an element-addressed array. I2: libplctag ErrorNoMatch/ErrorBadDevice no longer fold into NotFound (they now read as BadConnection: comms, Degraded, LastSuccessfulRead preserved). ErrorNotImplemented - and the native runtime's own pre-send refusals - raise AbCipLocalRefusalException: BadNotSupported, no health, LastSuccessfulRead NOT advanced, no backoff record, not reachable for the probe or Test Connect. M1: the bit RMW's parent read/write status words follow the health contract (refusal: per-tag Bad + Warning; comms: evict the parent + Degraded). M2: device Unsupported read refusals return Refused (advance LastSuccessfulRead); device write refusals call RecordWriteAnswered (not the pre-send native type gate). M3: a good write clears a Degraded a write comms fault recorded (a read-side Degraded is still left for the read path). M4: a superseded init throws AbCipInitializationSupersededException (not an OCE); ShutdownAsync stamps Unknown only if no newer init published meanwhile. M5: the sync-Dispose timing assert is 2 s against a 4 s probe read; the unencodable-array test asserts health. Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipDriver.cs | 126 ++++++++++++++---- .../AbCipDriverProbe.cs | 3 +- .../AbCipInitializationSupersededException.cs | 11 ++ .../AbCipLocalRefusalException.cs | 15 +++ .../AbCipRefusals.cs | 4 +- .../LibplctagStatusTranslation.cs | 28 +++- .../NativeAbCipTagRuntime.cs | 43 +++++- .../AbCipArrayWriteTests.cs | 1 + .../AbCipBitInWordWidthTests.cs | 34 +++++ .../AbCipDeviceRefusalTests.cs | 70 ++++++++++ .../AbCipLifecycleConcurrencyTests.cs | 37 ++++- .../AbCipLocalRefusalTests.cs | 7 +- .../AbCipStatusMapperTests.cs | 2 +- .../AbCipWriteHealthTests.cs | 18 +++ .../LibplctagStatusTranslationTests.cs | 24 +++- .../NativeAbCipTagRuntimeTests.cs | 24 ++++ 16 files changed, 395 insertions(+), 52 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipLocalRefusalException.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 8e19daed..10b7a813 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -515,7 +515,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, lock (_lifecycleLock) { if (generation != _lifecycleGeneration) - throw new OperationCanceledException( + throw new AbCipInitializationSupersededException( $"AbCip driver {_driverInstanceId} initialization was superseded by a shutdown or a newer initialization."); _options = options; @@ -562,7 +562,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Never-published states hold no probe loop and no runtime yet; dispose for symmetry. if (builtDevices is not null) foreach (var state in builtDevices.Values) state.Retire(); - if (ex is OperationCanceledException) + if (ex is AbCipInitializationSupersededException) { _logger.LogInformation("AbCip driver {DriverInstanceId} initialization abandoned: {Reason}", _driverInstanceId, ex.Message); @@ -652,9 +652,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, public async Task ShutdownAsync(CancellationToken cancellationToken) { await _poll.DisposeAsync().ConfigureAwait(false); - await ReleaseDeviceStatesAsync().ConfigureAwait(false); + var generation = await ReleaseDeviceStatesAsync().ConfigureAwait(false); _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry - UpdateHealth(h => new DriverHealth(DriverState.Unknown, h.LastSuccessfulRead, null)); + lock (_lifecycleLock) + { + // M4: a newer InitializeAsync that published while this shutdown was still joining its probe loops owns the + // health now — stamping Unknown over its Healthy would misreport a live driver. + if (generation == _lifecycleGeneration) + UpdateHealth(h => new DriverHealth(DriverState.Unknown, h.LastSuccessfulRead, null)); + } } // ---- ISubscribable (polling overlay via shared engine) ---- @@ -764,10 +770,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, probeRuntime = null; } } - catch (AbCipTagConfigurationException ace) + catch (Exception ace) when (ace is AbCipTagConfigurationException or AbCipLocalRefusalException) { - // The probe tag's own name cannot become a request — it never reached the device, so it is no evidence - // either way: no backoff signal, no host transition, one Warning. + // The probe tag's own name cannot become a request (or the library refused it locally) — it never + // reached the device, so it is no evidence either way: no backoff signal, no host transition, one Warning. try { probeRuntime?.Dispose(); } catch { } probeRuntime = null; state.ProbeInitialized = false; @@ -1145,13 +1151,21 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, { throw; } + catch (AbCipLocalRefusalException local) + { + // #737 I2: refused by the library / driver itself — no I/O, so no proof the device answered: BadNotSupported, + // keep the handle, no health signal, LastSuccessfulRead untouched. + results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now); + WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, local.Message); + return ReadOutcome.None; + } catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported) { - // A deterministic local refusal — not a comms fault: BadNotSupported, keep the handle, no - // health signal. + // The DEVICE refused (CIP 0x08, or a decode refusal of data it returned) — not a comms fault: + // BadNotSupported, keep the handle, no state change; it answered, so LastSuccessfulRead advances (M2). results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now); WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ate.Message); - return ReadOutcome.None; + return ReadOutcome.Refused; } catch (AbCipTagConfigurationException ace) { @@ -1169,7 +1183,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, reference, def.DeviceHostAddress, ate.Message); return ReadOutcome.Refused; } - catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate.Status)) + catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate)) { // H1/L4: the controller answered "no" (renamed tag, Arr[999], read-protected …) — a per-tag config // error, not a comms fault. Keep the handle (no evict → no lease / Forward-Open churn), no Degraded, @@ -1284,10 +1298,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, { throw; } + catch (AbCipLocalRefusalException local) + { + StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported); + WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, local.Message); + return ReadOutcome.None; + } catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported) { StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported); - return ReadOutcome.None; + WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, ate.Message); + return ReadOutcome.Refused; } catch (AbCipTagConfigurationException ace) { @@ -1485,14 +1506,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, { // Final review I4: deterministic — keep the handle, leave health, warn once per tag. results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch); + RecordWriteAnswered(now); WarnTypeMismatchOnce(def.Name, "AbCip write to {Tag} on device {Device} refused: the controller's type does not match the authored {DataType} (logged once per tag)", w.FullReference, def.DeviceHostAddress, def.DataType); } else if (AbCipRefusals.IsDeviceRefusal(status)) { - // The controller refused this write (read-only, out of bounds …): keep the handle, no health. + // The controller refused this write (read-only, out of bounds …): keep the handle, no state change; + // it answered, so LastSuccessfulRead advances (M2). results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status)); + RecordWriteAnswered(now); WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ((AbStatus)status).ToString()); } else if (status != 0) @@ -1550,14 +1574,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, "AbCip write to {Tag} on device {Device}: the value is out of range for {DataType}: {Reason} (logged once per tag)", w.FullReference, def.DeviceHostAddress, def.DataType, oe.Message); } + catch (AbCipLocalRefusalException local) + { + // Refused locally (e.g. the native runtime cannot type the write; libplctag NotImplemented) — nothing + // sent: BadNotSupported, keep the handle, leave health alone. + results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported); + WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, local.Message); + } catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported) { - // A deterministic local refusal (e.g. the native runtime cannot type the write) — not a comms - // fault: BadNotSupported, keep the handle, leave health alone. + // The device refused the write as unsupported — BadNotSupported, keep the handle, no state change; + // it answered, so LastSuccessfulRead advances (M2). results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported); - _logger.LogWarning(ate, - "AbCip write refused (not supported) for tag {Tag} on device {Device}", - w.FullReference, def.DeviceHostAddress); + RecordWriteAnswered(now); + WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ate.Message); } catch (AbCipTagConfigurationException ace) { @@ -1570,15 +1600,18 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, // Final review I4: the controller's type disagrees with the authored one (native type gate, or // CIP 0xFF/0x2107 from the controller) — deterministic: BadTypeMismatch, keep the handle, leave // health alone, warn once per tag. Retrying cannot succeed until the config is corrected. + // No LastSuccessfulRead advance: the native type gate throws this BEFORE sending. results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch); WarnTypeMismatchOnce(def.Name, "AbCip write to {Tag} on device {Device} refused: {Reason} (logged once per tag)", w.FullReference, def.DeviceHostAddress, ate.Message); } - catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate.Status)) + catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate)) { - // H1: the controller answered "no" to this write — keep the handle, leave health alone, warn once. + // H1: the controller answered "no" to this write — keep the handle, no state change, warn once; it + // answered, so LastSuccessfulRead advances (M2). results[i] = new WriteResult(AbCipStatusMapper.MapStatus(ate.Status)); + RecordWriteAnswered(now); WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ate.Message); } catch (Exception) when (device.Retired) @@ -1607,14 +1640,29 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// A good write proves the device answered: advance LastSuccessfulRead but leave the STATE alone. Writing /// Healthy here (H19) overwrote a Degraded the poll loop had just recorded for other tags. /// + /// + /// M3: a Degraded that a WRITE comms fault recorded (still the current health — same error, no read has + /// replaced it since) is cleared back to Healthy: the device just accepted a write. A Degraded the read + /// path recorded is left for the read path to clear. + /// private void RecordWriteAnswered(DateTime now) => - UpdateHealth(h => h with { LastSuccessfulRead = now }); + UpdateHealth(h => h.State == DriverState.Degraded && h.LastError is { } err + && ReferenceEquals(err, Volatile.Read(ref _writeFaultError)) + ? new DriverHealth(DriverState.Healthy, now, null) + : h with { LastSuccessfulRead = now }); + + // The LastError string the most recent write-side comms fault wrote (reference identity marks "write-owned"). + private string? _writeFaultError; /// A write-side comms fault: Degraded (never downgrading Faulted), LastSuccessfulRead preserved. - private void RecordWriteCommsFault(string error) => + private void RecordWriteCommsFault(string error) + { + var owned = new string(error.AsSpan()); // a distinct instance, so reference identity cannot alias a read error + Volatile.Write(ref _writeFaultError, owned); UpdateHealth(h => h.State == DriverState.Faulted ? h - : new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, error)); + : new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, owned)); + } /// /// Read-modify-write one bit within an integer parent (SINT / INT / DINT / LINT — Logix allows .n on @@ -1636,7 +1684,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false); await parentRuntime.ReadAsync(ct).ConfigureAwait(false); var readStatus = parentRuntime.GetStatus(); - if (readStatus != 0) return AbCipStatusMapper.MapStatus(readStatus); + if (readStatus != 0) return BitWriteStatusWord(device, parentName, readStatus, "read"); // Unsigned at the narrow widths so a set top bit (SINT bit 7, INT bit 15) encodes without overflowing the // signed type; DINT keeps its signed int arithmetic (1 << 31 is int.MinValue, which a DINT encodes as is). @@ -1685,7 +1733,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, var writeStatus = parentRuntime.GetStatus(); return writeStatus == 0 ? AbCipStatusMapper.Good - : AbCipStatusMapper.MapStatus(writeStatus); + : BitWriteStatusWord(device, parentName, writeStatus, "write"); } finally { @@ -1693,6 +1741,28 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, } } + /// + /// M1: a non-zero status word on the bit write's parent read / write follows the health contract — a device + /// refusal is per-tag Bad with one Warning and no state change (it answered: LastSuccessfulRead advances); any + /// other status is a comms fault: evict the PARENT runtime and write Degraded (LastSuccessfulRead preserved). + /// + private uint BitWriteStatusWord(DeviceState device, string parentName, int status, string phase) + { + var mapped = AbCipStatusMapper.MapStatus(status); + if (AbCipRefusals.IsDeviceRefusal(status)) + { + RecordWriteAnswered(DateTime.UtcNow); + WarnRefusalOnce(parentName, $"bit-write parent {phase}", parentName, device.Options.HostAddress, ((AbStatus)status).ToString()); + return mapped; + } + EvictParentRuntime(device, parentName); + RecordWriteCommsFault($"transport status {status} on the bit-write parent {phase} of {parentName}"); + _logger.LogWarning( + "AbCip bit-write parent {Phase} of {Parent} on device {Device} returned transport status {Status}; evicting the parent runtime", + phase, parentName, device.Options.HostAddress, status); + return mapped; + } + /// /// Get or lazily create a parent-DINT runtime for a parent tag path, cached per-device /// so repeated bit writes against the same DINT share one handle. @@ -1802,10 +1872,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, if (!device.Runtimes.TryAdd(def.Name, runtime)) runtime.Dispose(); throw; } - catch (AbCipTagConfigurationException) + catch (Exception ex) when (ex is AbCipTagConfigurationException or AbCipLocalRefusalException) { - // The tag's own name cannot become a request — no I/O happened, so it says nothing - // about the device: leave the backoff window exactly as it was. + // The tag's own name cannot become a request, or the library refused the tag locally — no I/O happened, + // so it says nothing about the device: leave the backoff window exactly as it was. runtime.Dispose(); throw; } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs index 52bb0f46..b927d758 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs @@ -206,7 +206,8 @@ public sealed class AbCipDriverProbe : IDriverProbe internal static bool IsReachableException(AbTransportException ex) { ArgumentNullException.ThrowIfNull(ex); - return IsReachableStatus(ex.Status); + // A local refusal (the library / driver refused without any I/O) proves nothing about the controller. + return ex is not AbCipLocalRefusalException && IsReachableStatus(ex.Status); } private static (AbCipHostAddress? parsed, AbCipDeviceOptions? device) ExtractTarget(AbCipDriverOptions opts) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs new file mode 100644 index 00000000..eeb1afef --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs @@ -0,0 +1,11 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// Thrown by when a shutdown, dispose or newer initialization retired the +/// driver's tables while this initialization was building, so it published nothing. Deliberately NOT an +/// : nobody cancelled this call, and a caller that treats cancellation as +/// "my own token fired" must not mistake a superseded attempt for one. The superseding operation owns the driver's +/// state; the caller (normally the runtime, which has already abandoned this attempt) can ignore the result. +/// +/// The detail. +public sealed class AbCipInitializationSupersededException(string message) : InvalidOperationException(message); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipLocalRefusalException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipLocalRefusalException.cs new file mode 100644 index 00000000..baa350e1 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipLocalRefusalException.cs @@ -0,0 +1,15 @@ +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; + +/// +/// A refusal raised LOCALLY — by the driver's own runtime or by libplctag (ErrorNotImplemented) — before or +/// without any reply from the controller. It carries (so it maps to +/// BadNotSupported) but, unlike a device refusal, proves nothing about the device: it keeps the runtime and +/// writes no health, does NOT advance LastSuccessfulRead, records nothing in the connect backoff, and is not +/// "reachable" for the probe or Test Connect (review 07 / Gitea #737 I2). +/// +/// The refusal detail. +/// The underlying exception, if any. +public sealed class AbCipLocalRefusalException(string message, Exception? inner = null) + : AbTransportException(AbStatus.Unsupported, message, inner); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs index fa82e31b..334568a1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs @@ -36,5 +36,7 @@ internal static class AbCipRefusals /// True when is an carrying a device refusal. /// The exception. /// for a deterministic device refusal. - public static bool IsDeviceRefusal(Exception ex) => ex is AbTransportException ate && IsDeviceRefusal(ate.Status); + /// An is never a device refusal: nothing reached the device. + public static bool IsDeviceRefusal(Exception ex) => + ex is AbTransportException ate and not AbCipLocalRefusalException && IsDeviceRefusal(ate.Status); } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs index 37614e2b..002e8c21 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs @@ -14,11 +14,17 @@ internal static class LibplctagStatusTranslation Status.Ok => AbStatus.Ok, Status.Pending => AbStatus.Pending, Status.ErrorTimeout => AbStatus.Timeout, - Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound, + // #737 I2: only ErrorNotFound is the controller saying "no such tag". ErrorNoMatch / ErrorBadDevice describe a + // session or device-level failure (no proof the controller answered this tag), so they are comms-class: with + // the widened refusal classification, folding them into NotFound made a broken session read as "reachable". + Status.ErrorNotFound => AbStatus.NotFound, + Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.BadConnection, Status.ErrorNotAllowed => AbStatus.NotAllowed, Status.ErrorOutOfBounds => AbStatus.OutOfBounds, Status.ErrorTooLarge => AbStatus.TooLarge, Status.ErrorTooSmall => AbStatus.TooSmall, + // ErrorNotImplemented is the LIBRARY refusing (no I/O); the exception paths raise it as an + // AbCipLocalRefusalException so it never counts as "the device answered" (see FromException). Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported, Status.ErrorPartial => AbStatus.Partial, Status.ErrorAbort => AbStatus.Abort, @@ -70,9 +76,21 @@ internal static class LibplctagStatusTranslation public static async Task Wrap(Func op) { try { await op().ConfigureAwait(false); } - catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); } + catch (LibPlcTagException ex) { throw FromException(ex); } } + /// + /// The transport exception for a libplctag failure: ErrorNotImplemented becomes an + /// (a library refusal, no I/O); everything else an + /// carrying the translated status. + /// + /// The libplctag exception. + /// The exception to throw. + public static AbTransportException FromException(LibPlcTagException ex) => + Enum.TryParse((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && s == Status.ErrorNotImplemented + ? new AbCipLocalRefusalException($"libplctag does not implement this operation: {ex.Message}", ex) + : new AbTransportException(ToAbStatus(ex), ex.Message ?? string.Empty, ex); + /// Runs , rethrowing any as . /// The result type. /// The libplctag operation. @@ -80,7 +98,7 @@ internal static class LibplctagStatusTranslation public static async Task Wrap(Func> op) { try { return await op().ConfigureAwait(false); } - catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); } + catch (LibPlcTagException ex) { throw FromException(ex); } } /// Runs a synchronous libplctag call, rethrowing as . @@ -90,7 +108,7 @@ internal static class LibplctagStatusTranslation public static T WrapSync(Func op) { try { return op(); } - catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); } + catch (LibPlcTagException ex) { throw FromException(ex); } } /// Runs a synchronous libplctag call, rethrowing as . @@ -98,6 +116,6 @@ internal static class LibplctagStatusTranslation public static void WrapSync(Action op) { try { op(); } - catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); } + catch (LibPlcTagException ex) { throw FromException(ex); } } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs index a77179f9..33ce02d8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs @@ -42,9 +42,14 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime private byte[]? _path; // U11: a scalar BOOL authored over a BOOL-array element "Bits[n]" — the bit (n % 32) of the DWORD the request - // addresses (n / 32). Null for every other tag. + // addresses (n / 32). Null for every other tag, and cleared (with _path restored to _elementPath) the moment the + // controller shows its BOOL array is element-addressed (#737 I1: a 0xC1 reply, or a refusal, to the DWORD request). private int? _boolElementBit; + // The ORIGINAL element-addressed path ("Bits[n]") while a DWORD-addressed _path is still unconfirmed; null once + // the layout is settled either way. + private byte[]? _elementPath; + private ConnectionLease? _lease; private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's private LogixTagClient? _client; @@ -124,6 +129,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime if (split.Bit is null && !_p.IsArray && _p.AuthoredType == AbCipDataType.Bool && TrySplitBoolArrayElement(name, out var dwordName, out var bit)) { + _elementPath = CipPath.EncodeSymbolic(name); name = dwordName; _boolElementBit = bit; } @@ -148,7 +154,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime { await ReadAsync(cancellationToken).ConfigureAwait(false); } - catch (AbTransportException ex) when (AbCipRefusals.IsDeviceRefusal(ex.Status)) + catch (AbTransportException ex) when (AbCipRefusals.IsDeviceRefusal(ex)) { // The controller answered. _status already holds the refusal. } @@ -162,11 +168,25 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime try { var (type, handle, data) = await client.ReadAsync(path, _elements, cancellationToken, ExpectedElementBytes(_p.AuthoredType)).ConfigureAwait(false); + if (_elementPath is not null) + { + // First answer to the DWORD-addressed BOOL-array request settles the layout: 0xD3 confirms it; anything + // else means this family addresses BOOL-array ELEMENTS (e.g. a 0xC1 per-element array) — decoding or + // writing element n / 32 there would hit the wrong element, so go back to "Bits[n]" and read again. + if (type != LogixTypeCode.BoolArray) { FallBackToElementPath(); await ReadAsync(cancellationToken).ConfigureAwait(false); return; } + _elementPath = null; + } _data = data; _typeCode = type; _structHandle = handle; _status = IsTypeMismatch(type) ? AbStatus.TypeMismatch : AbStatus.Ok; } + catch (AbTransportException ex) when (_elementPath is not null && AbCipRefusals.IsDeviceRefusal(ex)) + { + // The DWORD index was refused (e.g. out of bounds on an element-addressed array): retry as "Bits[n]". + FallBackToElementPath(); + await ReadAsync(cancellationToken).ConfigureAwait(false); + } catch (AbTransportException ex) { // libplctag reports a failed operation by exception AND leaves the status readable. @@ -175,6 +195,14 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime } } + /// Abandons the DWORD addressing of a BOOL-array element: the original "Bits[n]" path, no bit extraction. + private void FallBackToElementPath() + { + _path = _elementPath; + _elementPath = null; + _boolElementBit = null; + } + /// /// Upper bound on one element's wire bytes for the scheduler's reply budget; 0 when the /// authored type is unknown or a structure, which sends the read alone rather than guess. @@ -207,6 +235,13 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime try { + // An unsettled BOOL-array element layout is settled by a read first, so a write never goes to the + // DWORD-addressed element of an element-addressed array (#737 I1). + if (_elementPath is not null) + { + await ReadAsync(cancellationToken).ConfigureAwait(false); + path = Path(); + } if (_pendingString is { } text) { // A STRING writes as the type the controller reported — a Logix STRING is a structure @@ -215,7 +250,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime if (_typeCode is null || (_typeCode == LogixTypeCode.Struct && _structHandle is null)) await ReadAsync(cancellationToken).ConfigureAwait(false); if (_typeCode == LogixTypeCode.Struct && _structHandle is null) - throw new AbTransportException(AbStatus.Unsupported, + throw new AbCipLocalRefusalException( $"STRING write to '{_p.TagName}': the controller returned no structure handle."); var payload = EncodeStringPayload(text); await client.WriteAsync(path, _typeCode!.Value, _structHandle, 1, payload, cancellationToken) @@ -224,7 +259,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime else if (_writeBuffer is { } buffer) { var type = _typeCode ?? AtomicCode(_p.AuthoredType) - ?? throw new AbTransportException(AbStatus.Unsupported, + ?? throw new AbCipLocalRefusalException( $"Write to '{_p.TagName}': type unknown — no prior read and no authored atomic type."); // The read rule decides first: a same-width mismatch (REAL under a DINT-authored tag) // would otherwise send the int bit pattern as a REAL and report Good. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs index ea908d12..5ebd5908 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs @@ -123,6 +123,7 @@ public sealed class AbCipArrayWriteTests r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported); factory.Tags.ShouldNotContainKey("Arr"); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); // M5: a refusal writes no health } /// The controller holding a different shape than authored (size ≠ width × count) is BadTypeMismatch, nothing sent. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBitInWordWidthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBitInWordWidthTests.cs index 11f7b736..dce0c47d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBitInWordWidthTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBitInWordWidthTests.cs @@ -124,4 +124,38 @@ public sealed class AbCipBitInWordWidthTests await drv.DisposeAsync(); } } + + /// + /// M1: a parent read that fails with a COMMS status evicts the parent runtime and degrades (LastSuccessfulRead + /// preserved); one the controller REFUSES is per-tag Bad, keeps the runtime and writes no health. + /// + [Fact] + public async Task A_failed_parent_read_follows_the_health_contract() + { + var (drv, factory) = await BuildAsync(4, 0, "Motor.Flags.3"); + factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.BadConnection }; + var before = drv.GetHealth(); + + var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadCommunicationError); + factory.Tags["Motor.Flags"].Disposed.ShouldBeTrue(); + factory.Tags["Motor.Flags"].WriteCount.ShouldBe(0); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + drv.GetHealth().LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead); + } + + /// M1 positive control: a refused parent read keeps the runtime, no health write. + [Fact] + public async Task A_refused_parent_read_keeps_the_runtime_and_health() + { + var (drv, factory) = await BuildAsync(4, 0, "Motor.Flags.3"); + factory.Customise = p => new FakeAbCipTag(p) { Status = (int)AbStatus.NotAllowed }; + + var r = await drv.WriteAsync([new WriteRequest("Bit", true)], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotWritable); + factory.Tags["Motor.Flags"].Disposed.ShouldBeFalse(); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDeviceRefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDeviceRefusalTests.cs index 6803d3ab..e4e23d7c 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDeviceRefusalTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDeviceRefusalTests.cs @@ -225,4 +225,74 @@ public sealed class AbCipDeviceRefusalTests { public IAbCipTagRuntime Create(AbCipTagCreateParams createParams) => create(createParams); } + + /// + /// #737 I2: a LOCAL refusal (libplctag ErrorNotImplemented — no I/O) is per-tag BadNotSupported, keeps health + /// state, and does NOT advance LastSuccessfulRead (nothing proves the device answered). Positive control: the + /// device's own Unsupported refusal does advance it (M2). + /// + [Fact] + public async Task A_local_refusal_does_not_advance_LastSuccessfulRead_but_a_device_one_does() + { + var (drv, _) = Build(p => p.TagName == "Arr[999]" + ? new LocalRefusingTag(p) + : new RefusingTag(p) { ReadThrows = AbStatus.Unsupported }); + await drv.InitializeAsync("{}", CancellationToken.None); + var before = drv.GetHealth().LastSuccessfulRead!.Value; + await Task.Delay(20); + + (await drv.ReadAsync(["Bad"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported); + drv.GetHealth().LastSuccessfulRead.ShouldBe(before); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + + (await drv.ReadAsync(["Good"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported); + drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before); + } + + /// #737 I2: a local refusal at create opens no backoff — a sibling cold create still connects. + [Fact] + public async Task A_local_refusal_at_create_does_not_throttle_or_count_as_answered() + { + var (drv, _) = Build(p => p.TagName == "Arr[999]" + ? new FakeAbCipTag(p) { ThrowOnInitialize = true, Exception = new AbCipLocalRefusalException("ErrorNotImplemented") } + : new FakeAbCipTag(p) { Value = 3 }); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNotSupported); + r[1].StatusCode.ShouldBe(AbCipStatusMapper.Good); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// M2: a write the device refuses proves it answered — LastSuccessfulRead advances. + [Fact] + public async Task A_refused_write_advances_LastSuccessfulRead() + { + var factory = new FakeAbCipTagFactory { Customise = p => new RefusingTag(p) { WriteThrows = AbStatus.NotAllowed } }; + var drv = new AbCipDriver(new AbCipDriverOptions + { + Devices = [new AbCipDeviceOptions(Device)], + RawTags = AbCipRawTags.From(new AbCipTagDefinition("Ro", Device, "ReadOnlyTag", AbCipDataType.DInt)), + Probe = new AbCipProbeOptions { Enabled = false }, + }, "abcip-refusal", factory); + await drv.InitializeAsync("{}", CancellationToken.None); + var before = drv.GetHealth().LastSuccessfulRead!.Value; + await Task.Delay(20); + + (await drv.WriteAsync([new WriteRequest("Ro", 5)], CancellationToken.None))[0].StatusCode + .ShouldBe(AbCipStatusMapper.BadNotWritable); + + drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + private sealed class LocalRefusingTag(AbCipTagCreateParams p) : FakeAbCipTag(p) + { + public override async Task ReadAsync(CancellationToken cancellationToken) + { + await base.ReadAsync(cancellationToken); + throw new AbCipLocalRefusalException("ErrorNotImplemented"); + } + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs index 2b009a7f..6811afcc 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs @@ -56,9 +56,9 @@ public sealed class AbCipLifecycleConcurrencyTests return (drv, factory); } - private static async Task WaitUntilAsync(Func condition, string what) + private static async Task WaitUntilAsync(Func condition, string what, int seconds = 5) { - var deadline = DateTime.UtcNow.AddSeconds(5); + var deadline = DateTime.UtcNow.AddSeconds(seconds); while (!condition()) { if (DateTime.UtcNow > deadline) throw new TimeoutException(what); @@ -106,7 +106,8 @@ public sealed class AbCipLifecycleConcurrencyTests var init = drv.InitializeAsync("{}", CancellationToken.None); await Task.Delay(50); await drv.ShutdownAsync(CancellationToken.None); - await Should.ThrowAsync(() => init); + var superseded = await Should.ThrowAsync(() => init); + superseded.ShouldNotBeAssignableTo(); // M4: distinct from a caller cancellation drv.GetDeviceState(Host).ShouldBeNull(); drv.GetHostStatuses().ShouldBeEmpty(); @@ -199,18 +200,40 @@ public sealed class AbCipLifecycleConcurrencyTests [Fact] public async Task Sync_Dispose_does_not_block_on_the_teardown_but_still_completes_it() { - var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(500)); + var (drv, factory) = Build(probeReadDelay: TimeSpan.FromSeconds(4)); await drv.InitializeAsync("{}", CancellationToken.None); await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started"); var loop = drv.GetDeviceState(Host)!.ProbeTask!; - await Task.Delay(20); // the probe is inside its slow, cancellation-deaf read + await Task.Delay(50); // the probe is inside its slow, cancellation-deaf read var sw = System.Diagnostics.Stopwatch.StartNew(); ((IDisposable)drv).Dispose(); sw.Stop(); - sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(250)); // did not wait out the 500 ms probe read - await WaitUntilAsync(() => loop.IsCompleted && factory.Tags["ProbeTag"].Disposed, "teardown never completed"); + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2)); // did not wait out the 4 s probe read (CI-tolerant margin) + await WaitUntilAsync(() => loop.IsCompleted && factory.Tags["ProbeTag"].Disposed, "teardown never completed", seconds: 15); drv.GetDeviceState(Host).ShouldBeNull(); } + + /// + /// M4: a ShutdownAsync overtaken by a NEWER InitializeAsync (its probe join is slow) must not stamp Unknown over + /// the newer init's Healthy when it finally finishes. + /// + [Fact] + public async Task A_slow_shutdown_does_not_overwrite_a_newer_inits_health() + { + var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(400)); + await drv.InitializeAsync("{}", CancellationToken.None); + await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started"); + await Task.Delay(30); // inside the slow probe read + + var shutdown = drv.ShutdownAsync(CancellationToken.None); + await Task.Delay(30); + await drv.InitializeAsync("{}", CancellationToken.None); // newer: publishes while the shutdown still joins + await shutdown; + + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + drv.GetDeviceState(Host).ShouldNotBeNull(); + await drv.DisposeAsync(); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLocalRefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLocalRefusalTests.cs index f51bfe18..cb99093e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLocalRefusalTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLocalRefusalTests.cs @@ -7,7 +7,8 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests; /// /// Task 22 review r1: a runtime that refuses an operation with (the native -/// transport does this before the wire, e.g. a write it cannot type) reaches the client as BadNotSupported — +/// transport does this before the wire, e.g. a write it cannot type — an +/// since #737 I2) reaches the client as BadNotSupported — /// NOT as a comms fault. The handle is kept (one create, never disposed) and driver health is untouched. /// [Trait("Category", "Unit")] @@ -19,12 +20,12 @@ public sealed class AbCipLocalRefusalTests { public override Task ReadAsync(CancellationToken cancellationToken) => refuseRead - ? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused read")) + ? Task.FromException(new AbCipLocalRefusalException("refused read")) : base.ReadAsync(cancellationToken); public override Task WriteAsync(CancellationToken cancellationToken) => refuseWrite - ? Task.FromException(new AbTransportException(AbStatus.Unsupported, "refused write")) + ? Task.FromException(new AbCipLocalRefusalException("refused write")) : base.WriteAsync(cancellationToken); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs index 7c3c63df..778f2c42 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipStatusMapperTests.cs @@ -77,7 +77,7 @@ public sealed class AbCipStatusMapperTests Status.Ok => AbCipStatusMapper.Good, Status.Pending => AbCipStatusMapper.GoodMoreData, Status.ErrorTimeout => AbCipStatusMapper.BadTimeout, - Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbCipStatusMapper.BadNodeIdUnknown, + Status.ErrorNotFound => AbCipStatusMapper.BadNodeIdUnknown, // #737 I2: NoMatch/BadDevice are comms Status.ErrorNotAllowed => AbCipStatusMapper.BadNotWritable, Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbCipStatusMapper.BadOutOfRange, Status.ErrorUnsupported or Status.ErrorNotImplemented => AbCipStatusMapper.BadNotSupported, diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs index 4821a43a..9a1458c4 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs @@ -110,4 +110,22 @@ public sealed class AbCipWriteHealthTests after.LastSuccessfulRead.ShouldBe(before.LastSuccessfulRead); factory.Tags["D"].Disposed.ShouldBeTrue(); } + + /// M3: a good write clears a Degraded that a WRITE comms fault recorded. + [Fact] + public async Task A_good_write_clears_a_write_side_Degraded() + { + var fail = true; + var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p)); + (await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None))[0].StatusCode + .ShouldBe(AbCipStatusMapper.BadCommunicationError); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + + fail = false; // the evicted runtime is re-created healthy + (await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None))[0].StatusCode + .ShouldBe(AbCipStatusMapper.Good); + + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + drv.GetHealth().LastError.ShouldBeNull(); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs index fba8f7ea..9e1fd14d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/LibplctagStatusTranslationTests.cs @@ -16,8 +16,8 @@ public sealed class LibplctagStatusTranslationTests [InlineData(Status.Pending, AbStatus.Pending)] [InlineData(Status.ErrorTimeout, AbStatus.Timeout)] [InlineData(Status.ErrorNotFound, AbStatus.NotFound)] - [InlineData(Status.ErrorNoMatch, AbStatus.NotFound)] - [InlineData(Status.ErrorBadDevice, AbStatus.NotFound)] + [InlineData(Status.ErrorNoMatch, AbStatus.BadConnection)] // #737 I2: not proof the controller answered + [InlineData(Status.ErrorBadDevice, AbStatus.BadConnection)] [InlineData(Status.ErrorNotAllowed, AbStatus.NotAllowed)] [InlineData(Status.ErrorOutOfBounds, AbStatus.OutOfBounds)] [InlineData(Status.ErrorTooLarge, AbStatus.TooLarge)] @@ -148,4 +148,24 @@ public sealed class LibplctagStatusTranslationTests LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException(Status.ErrorBadParam))); thrown.Status.ShouldBe(AbStatus.BadConnection); } + + /// + /// #737 I2: libplctag's ErrorNotImplemented is the LIBRARY refusing (no I/O): a local refusal, not a device + /// refusal — it must not count as "the controller answered". + /// + [Fact] + public async Task NotImplemented_is_a_local_refusal_not_a_device_refusal() + { + var ex = await Should.ThrowAsync(() => + LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException("ErrorNotImplemented"))); + ex.ShouldBeOfType(); + ex.Status.ShouldBe(AbStatus.Unsupported); + AbCipRefusals.IsDeviceRefusal(ex).ShouldBeFalse(); + AbCipDriverProbe.IsReachableException(ex).ShouldBeFalse(); + + // Positive control: the device's own Unsupported refusal still is one. + var device = await Should.ThrowAsync(() => + LibplctagStatusTranslation.Wrap(() => throw new LibPlcTagException("ErrorUnsupported"))); + AbCipRefusals.IsDeviceRefusal(device).ShouldBeTrue(); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs index 9d013613..1d514c91 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs @@ -135,6 +135,30 @@ public sealed class NativeAbCipTagRuntimeTests ch.Sent[0].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "28" + dword.ToString("X2") + "0100" + "00000000")); } + /// + /// #737 I1: a family whose BOOL array is element-addressed (the controller answers the DWORD-index request with a + /// plain BOOL, 0xC1) falls back to the ORIGINAL element path Bits[n] — it must neither decode nor write + /// element n / 32. Positive control: the 0xD3 theory above still decodes bit n % 32. + /// + [Fact] + public async Task A_per_element_BOOL_array_falls_back_to_the_element_path_for_read_and_write() + { + var ch = new FakeCipChannel + { + Respond = req => Task.FromResult(Convert.FromHexString(req[0] == 0x4D ? "CD000000" : "D2000000C10001")), + }; + using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool)); + await rt.InitializeAsync(CancellationToken.None); + await rt.ReadAsync(CancellationToken.None); + + rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true); + ch.Sent[^1].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "2825" + "0100" + "00000000")); // Bits[37] + + rt.EncodeValue(AbCipDataType.Bool, null, false); + await rt.WriteAsync(CancellationToken.None); + ch.Sent[^1].ShouldBe(Convert.FromHexString("4D04" + "910442697473" + "2825" + "C100" + "0100" + "00")); // Bits[37] + } + /// Positive control: a BOOL answered as a plain BOOL (0xC1) still decodes its byte. [Fact] public async Task A_BOOL_element_answered_as_BOOL_decodes_the_byte() From bc985f8857a292a669dd4c34984972683e90d383 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:53:46 -0400 Subject: [PATCH 43/67] test(ablegacy): array-write gate takes a base address and restores the words Makes the N-file array-write case usable as the real-controller hardware gate: AB_LEGACY_ARRAY_WRITE_BASE names the first of six consecutive words (default N7:1), each word is read first and written back in a finally, and the neighbours are verified per word with coalescing off. Live-run green on ab_server slc500/micrologix/plc5 x libplctag/native (39/39), and with a moved base (N7:3). Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyArrayWriteIntegrationTests.cs | 69 +++++++++++++------ 1 file changed, 47 insertions(+), 22 deletions(-) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs index 0b479e73..9a098667 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs @@ -6,16 +6,21 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; /// -/// Review 07 U10: an N-file array write (N7:2, 4 elements) round-trips every element against -/// ab_server on both transports, and leaves the words either side untouched. Simulator-only (gated by -/// ); the real-controller proof is a hardware userGate. +/// Review 07 U10: an N-file array write (4 elements) round-trips every element on both transports and leaves the +/// words either side untouched. Gated by . Doubles as the real-controller +/// hardware gate: AB_LEGACY_ARRAY_WRITE_BASE (default N7:1) names the first of the SIX consecutive +/// words it uses (base = "before" sentinel, base+1..base+4 = the array, base+5 = "after" sentinel); every one is +/// read first and written back in a finally, so a run restores them byte-exact. /// [Collection(AbLegacyServerCollection.Name)] [Trait("Category", "Integration")] [Trait("Simulator", "ab_server-PCCC")] public sealed class AbLegacyArrayWriteIntegrationTests(AbLegacyServerFixture sim) { - /// Seeds the neighbours, writes an int[] to a 4-element Int array, reads it all back. + /// The env var naming the first word of the six the test may overwrite (and restores). + public const string BaseVar = "AB_LEGACY_ARRAY_WRITE_BASE"; + + /// Seeds the neighbours, writes an int[] to a 4-element Int array, reads it all back, restores. /// The simulator profile. /// The transport stack the driver runs on. [AbLegacyTheory] @@ -25,20 +30,29 @@ public sealed class AbLegacyArrayWriteIntegrationTests(AbLegacyServerFixture sim if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip); IntegrationWriteGate.SkipUnlessWritesAllowed(); + var baseText = Environment.GetEnvironmentVariable(BaseVar) is { Length: > 0 } b ? b : "N7:1"; + var baseAddr = AbLegacyAddress.TryParse(baseText, profile.Family); + if (baseAddr is not { FileLetter: "N", FileNumber: int file, BitIndex: null, SubElement: null } || baseAddr.IsIndirect) + throw new InvalidOperationException($"{BaseVar}='{baseText}' must be a plain N-file word such as N7:200."); + string At(int offset) => $"N{file}:{baseAddr.WordNumber + offset}"; + var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}"; + var words = Enumerable.Range(0, 6) + .Select(i => new AbLegacyTagDefinition($"W{i}", deviceUri, At(i), AbLegacyDataType.Int)) + .ToArray(); await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)], RawTags = AbLegacyRawTags.Entries( - new AbLegacyTagDefinition("Before", deviceUri, "N7:1", AbLegacyDataType.Int), - new AbLegacyTagDefinition("Arr", deviceUri, "N7:2", AbLegacyDataType.Int, ArrayLength: 4), - new AbLegacyTagDefinition("After", deviceUri, "N7:6", AbLegacyDataType.Int)), + [.. words, new AbLegacyTagDefinition("Arr", deviceUri, At(1), AbLegacyDataType.Int, ArrayLength: 4)]), Timeout = TimeSpan.FromSeconds(5), Probe = new AbLegacyProbeOptions { Enabled = false }, Transport = transport, + CoalesceReads = false, // read each word on its own, independent of the array path under test }, driverInstanceId: $"ablegacy-arraywrite-{profile.Family}-{transport}"); var ct = TestContext.Current.CancellationToken; await drv.InitializeAsync("{}", ct); + string[] wordRefs = [.. words.Select(w => w.Name)]; if (profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native) { @@ -48,23 +62,34 @@ public sealed class AbLegacyArrayWriteIntegrationTests(AbLegacyServerFixture sim return; } - (await drv.WriteAsync( - [new WriteRequest("Before", (short)111), new WriteRequest("After", (short)666)], ct)) - .ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good); + var original = await drv.ReadAsync(wordRefs, ct); + original.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good, "the six words must exist before anything is written"); + try + { + (await drv.WriteAsync([new WriteRequest("W0", (short)111), new WriteRequest("W5", (short)666)], ct)) + .ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good); - // ScadaBridge shape: a CLR int[] to an Int16 array node. - (await drv.WriteAsync([new WriteRequest("Arr", new[] { 10, -20, 30, 32767 })], ct)).Single() - .StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + // ScadaBridge shape: a CLR int[] to an Int16 array node. + (await drv.WriteAsync([new WriteRequest("Arr", new[] { 10, -20, 30, 32767 })], ct)).Single() + .StatusCode.ShouldBe(AbLegacyStatusMapper.Good); - var back = await drv.ReadAsync(["Before", "Arr", "After"], ct); - back.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); - back[1].Value.ShouldBe(new short[] { 10, -20, 30, 32767 }); - Convert.ToInt32(back[0].Value).ShouldBe(111); // neighbours untouched - Convert.ToInt32(back[2].Value).ShouldBe(666); + var arr = (await drv.ReadAsync(["Arr"], ct)).Single(); + arr.StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + arr.Value.ShouldBe(new short[] { 10, -20, 30, 32767 }); + var back = await drv.ReadAsync(wordRefs, ct); + back.Select(s => Convert.ToInt32(s.Value)).ShouldBe(new[] { 111, 10, -20, 30, 32767, 666 }); // per word, neighbours untouched - // A length mismatch is refused before the wire and changes nothing. - (await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], ct)).Single() - .StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange); - (await drv.ReadAsync(["Arr"], ct)).Single().Value.ShouldBe(new short[] { 10, -20, 30, 32767 }); + // A length mismatch is refused before the wire and changes nothing. + (await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], ct)).Single() + .StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange); + (await drv.ReadAsync(["Arr"], ct)).Single().Value.ShouldBe(new short[] { 10, -20, 30, 32767 }); + } + finally + { + // Restore all six words exactly as found (CancellationToken.None: restore even if the run was cancelled). + var restore = words.Select((w, i) => new WriteRequest(w.Name, Convert.ToInt16(original[i].Value))).ToArray(); + (await drv.WriteAsync(restore, CancellationToken.None)) + .ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good, "restoring the original words must succeed"); + } } } From 87a7414df7b8446e38bb30662f80c1a0b4ff1609 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:04:19 -0400 Subject: [PATCH 44/67] fix(s7): refuse unservable tag shapes per tag at every gate (R07:H3, R07:U4) The init-time tag guards THREW: one mis-authored tag (DB1.DBZ0, DB1.DBW0 typed Float64, T5 typed Int32, a writable Bool array) faulted the whole driver and the actor retried the same init every 10 s forever, darkening every tag on the PLC. Neither the editor nor the deploy gate checked any of it. - S7AddressParser moves to S7.Contracts (same namespace) and a new S7TagShapeRules there is the single authority on servable shapes. - S7TagDefinitionFactory.FromTagConfig applies it, so the AdminUI editor, the deploy gate (Inspect, with the specific reason) and the driver init refuse the same tags. Init now skips a refused tag (ISkippedTagReporter) instead of throwing; the throwing RejectUnsupportedTagConfigs is gone. - New rules on top of the old guards: a narrow type at the wrong access width (every read failed) and a Bool array not starting at bit 0 (U4: DB1.DBX0.3 silently read bit 0 of bytes 0..n). - AdminUI test fixtures that used addresses the driver could never serve (DB1.DBS0, a browse "SomeAddress", Int32/Float32 on DBW) now use real ones. Refs #742 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- docs/drivers/S7.md | 17 +- .../S7AddressParser.cs | 0 .../S7TagDefinitionFactory.cs | 38 +++- .../S7TagShapeRules.cs | 149 ++++++++++++++ .../ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 192 ++---------------- .../Uns/TagEditors/S7TagConfigModel.cs | 5 +- .../S7_1500/S7_1500TagShapeSkipTests.cs | 57 ++++++ .../S7DriverCodeReviewFixTests.cs | 40 ++-- .../S7DriverCodeReviewFixTests2.cs | 20 +- .../S7DriverReadWriteTests.cs | 21 +- .../S7DriverScaffoldTests.cs | 114 ++--------- .../S7TagShapeGateTests.cs | 66 ++++++ .../S7TagSkipAssert.cs | 60 ++++++ .../S7ValueCodecTests.cs | 13 +- .../ScriptedS7Plc.cs | 142 +++++++++++++ .../Uns/CsvMergeEvidenceTests.cs | 2 +- .../Uns/RawTagConfigGateTests.cs | 6 +- .../Uns/S7TagConfigModelTests.cs | 6 +- .../Uns/TagConfigNonEnumParityTests.cs | 18 +- 19 files changed, 598 insertions(+), 368 deletions(-) rename src/Drivers/{ZB.MOM.WW.OtOpcUa.Driver.S7 => ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts}/S7AddressParser.cs (100%) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagShapeRules.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500TagShapeSkipTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TagShapeGateTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TagSkipAssert.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ScriptedS7Plc.cs diff --git a/docs/drivers/S7.md b/docs/drivers/S7.md index 02d27885..18e07821 100644 --- a/docs/drivers/S7.md +++ b/docs/drivers/S7.md @@ -103,9 +103,18 @@ Addresses use Siemens TIA-Portal / STEP 7 Classic syntax, parsed by | Timer | `T0` / `T15` | Timer area (read-only — see Wide types section) | | Counter | `C0` / `C10` | Counter area (read-only — see Wide types section) | -Parsing is strict and runs once at `InitializeAsync` so a config typo fails fast -at load instead of surfacing as `BadInternalError` on every read. Bit offsets -must be 0-7, byte offsets non-negative, DB numbers >= 1. +Parsing is strict. Bit offsets must be 0-7, byte offsets non-negative, DB numbers >= 1. + +**Tag shape rules are judged per tag, at every gate** (`S7TagShapeRules`, called by +`S7TagDefinitionFactory.FromTagConfig`): the AdminUI editor refuses the tag, the deploy gate +(`Inspect`) warns, and the driver's init **skips** it (reported through `ISkippedTagReporter`; it reads +`BadNodeIdUnknown`) while every other tag keeps working. Refused: an address that does not parse, a +wide/structured type that is an array or is not byte-addressed (`DBB`/`MB`/`IB`/`QB`), a narrow type at the +wrong access width (`Int32` on `DBW0`, `Bool` on `DBB0`...), a Timer not typed `Float64` or a Counter not typed +`Int32`, a writable or array Timer/Counter, a writable `Bool` array, and a `Bool` array that does not start +at bit 0 (a Bool array reads bit 0 of each byte, so `DB1.DBX0.3` used to silently return bit 0 of bytes +0..n). These used to be init guards that THREW, so one typo faulted every tag on the PLC and the actor +retried the same init every 10 s forever (R07:H3). ## Data types @@ -359,7 +368,7 @@ normal dev (the S7 sim is on the shared Docker host but currently offline). **Array writes (S7-016)** — a writable `Byte` / `Int16` / `UInt16` / `Int32` / `UInt32` / `Float32` array is written as ONE contiguous `WriteBytesAsync` of `count x element-bytes` (the inverse of the read decode), and the value must be an array of exactly `arrayLength` elements (else `BadTypeMismatch`, no -health impact). **Writable `Bool` arrays are refused at init** (`NotSupportedException`): the array +health impact). **Writable `Bool` arrays are refused per tag** (see the tag shape rules above): the array read is byte-granular, so a matching write would overwrite the 7 neighbouring bits of every byte it touches, and a safe bit-packed write needs read-modify-write. Mark Bool arrays `writable: false`. A `null` array element is rejected (`BadTypeMismatch`, nothing written) rather than coerced to 0. An array write larger than one PDU is split by S7.Net into several writes and is diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7AddressParser.cs similarity index 100% rename from src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7AddressParser.cs rename to src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7AddressParser.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs index a10c2c7d..94457fa5 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagDefinitionFactory.cs @@ -48,8 +48,23 @@ public static class S7TagDefinitionFactory /// The mapped definition when this returns . /// when is a valid S7 address object. public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def) + => FromTagConfig(tagConfig, rawPath, out def, out _); + + /// + /// , also reporting WHY a structurally + /// valid blob was refused by (an unparseable address, or a shape the + /// driver cannot serve). stays for a JSON-level + /// refusal (missing keys, bad enum), which explains on its own. + /// + /// The authored equipment-tag TagConfig JSON. + /// The tag's RawPath — becomes the definition's identity (Name). + /// The mapped definition when this returns . + /// The shape/address rejection reason, when that is why it refused. + /// when the driver can serve the tag. + public static bool FromTagConfig(string tagConfig, string rawPath, out S7TagDefinition def, out string? shapeRejection) { def = null!; + shapeRejection = null; if (string.IsNullOrWhiteSpace(tagConfig)) return false; try { @@ -78,7 +93,7 @@ public static class S7TagDefinitionFactory // "writable" defaults to true when absent (today's value); node-level authz still governs // writes. Honouring the key makes read-only equipment tags authorable. var writable = TagConfigJson.ReadWritable(root); - def = new S7TagDefinition( + var mapped = new S7TagDefinition( Name: rawPath, Address: address, DataType: dataType, @@ -86,6 +101,15 @@ public static class S7TagDefinitionFactory StringLength: stringLength == 0 ? MaxStringLength : stringLength, WriteIdempotent: false, ArrayCount: arrayCount); + // The address must parse and the shape must be one the driver can serve. These used to be + // init-time guards that THREW and faulted every tag on the PLC; refusing here skips only + // this tag, and the editor + deploy gate (which ask this method) refuse it too. + if (S7TagShapeRules.Validate(mapped, out _) is { } reason) + { + shapeRejection = reason; + return false; + } + def = mapped; return true; } catch (JsonException) { return false; } @@ -162,7 +186,9 @@ public static class S7TagDefinitionFactory /// reject — a structurally unparseable or non-object document, an absent / non-string / /// blank address, an absent dataType (see ), a /// present-but-invalid dataType (the runtime enum read is STRICT, so a - /// typo rejects the tag), or an out-of-range stringLength on a String tag. Such a tag + /// typo rejects the tag), an out-of-range stringLength on a String tag, or a shape + /// refuses (an unparseable address, a wrong access width, a mis-typed + /// Timer/Counter, an unsupported array). Such a tag /// is skipped by the driver and reads BadNodeIdUnknown forever. Empty when the mapper accepts /// the blob, when it is blank, or when it is not an equipment-tag object. Never throws. /// @@ -184,7 +210,13 @@ public static class S7TagDefinitionFactory // The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the // mapper — the driver reads the stored blob, not a trimmed copy of it. - if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings; + if (FromTagConfig(reference, InspectionRawPath, out _, out var shapeRejection)) return warnings; + + if (shapeRejection is not null) + { + warnings.Add(shapeRejection + " The driver will skip this tag and it will read BadNodeIdUnknown."); + return warnings; + } try { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagShapeRules.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagShapeRules.cs new file mode 100644 index 00000000..17a9bf48 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagShapeRules.cs @@ -0,0 +1,149 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.S7; + +/// +/// The single authority on which S7 tag shapes the driver can serve. Consulted by +/// , so the AdminUI editor, the deploy gate +/// () and the driver's own init all refuse the same +/// tags with the same words. +/// +/// +/// +/// These rules used to be init-time guards in S7Driver that THREW. One mis-authored tag +/// (DB1.DBZ0, DB1.DBW0 typed Float64, T5 typed Int32) faulted the +/// whole driver, and the actor retried that same init every 10 s forever, darkening every tag on +/// the PLC. The editor and the deploy gate checked none of it, and a CSV import or API blob +/// bypasses the editor entirely. A rejected tag is now skipped on its own (it reads +/// BadNodeIdUnknown) and the deploy gate warns about it. +/// +/// +/// Every rule rejects a tag that could never be served correctly. None of them can newly break +/// a tag that used to work: each one either faulted init, or failed (or silently mis-decoded) +/// every read of that tag. +/// +/// +public static class S7TagShapeRules +{ + /// + /// S7 data types addressed by START BYTE (the B suffix) rather than by an access-width + /// suffix. The driver decodes them from a contiguous byte buffer, so a wide tag authored against a + /// non-byte suffix (DBW/DBD/...) would mis-frame its value. + /// + private static readonly HashSet ByteAnchoredDataTypes = + [ + S7DataType.Int64, + S7DataType.UInt64, + S7DataType.Float64, + S7DataType.String, + S7DataType.DateTime, + S7DataType.WString, + S7DataType.Char, + S7DataType.WChar, + S7DataType.Dtl, + S7DataType.Date, + S7DataType.Tod, + S7DataType.Time, + S7DataType.S5Time, + ]; + + /// True when is a wide/structured type read from a byte-anchored block. + /// The declared data type. + /// for the byte-anchored types. + public static bool IsByteAnchored(S7DataType dataType) => ByteAnchoredDataTypes.Contains(dataType); + + /// + /// Judges one tag definition. Returns when the driver can serve it, or + /// the operator-facing reason it cannot. + /// + /// The mapped tag definition. + /// The parsed address when this returns . + /// The rejection reason, or when the tag is servable. + public static string? Validate(S7TagDefinition def, out S7ParsedAddress parsed) + { + ArgumentNullException.ThrowIfNull(def); + parsed = default; + + S7ParsedAddress addr; + try + { + addr = S7AddressParser.Parse(def.Address); + } + catch (FormatException ex) + { + return $"S7 address '{def.Address}' does not parse: {ex.Message}"; + } + + var isWide = IsByteAnchored(def.DataType); + var isArray = def.ArrayCount is >= 1; + + // Wide-type arrays have no decoder. + if (isWide && isArray) + return $"S7 {def.DataType} arrays are not supported. Use a scalar {def.DataType} tag, or an " + + "Int16/UInt16/Int32/UInt32/Float32/Byte/Bool element type for an array."; + + // Timer / Counter type constraints: a timer decodes to Float64 seconds, a counter to an Int32 count. + if (addr.Area is S7Area.Timer && def.DataType != S7DataType.Float64) + return $"S7 Timer address '{def.Address}' is typed {def.DataType}. Timer tags must be " + + "DataType=Float64 (decoded to seconds, read-only)."; + if (addr.Area is S7Area.Counter && def.DataType != S7DataType.Int32) + return $"S7 Counter address '{def.Address}' is typed {def.DataType}. Counter tags must be " + + "DataType=Int32 (decoded to a count, read-only)."; + if (addr.Area is S7Area.Timer or S7Area.Counter) + { + if (isArray) + return $"S7 Timer/Counter address '{def.Address}' cannot be an array."; + if (def.Writable) + return $"S7 Timer/Counter address '{def.Address}' is declared writable. Timer/Counter tags " + + "are read-only; set Writable=false."; + parsed = addr; + return null; + } + + // Wide/structured scalars on DB/M/I/Q must be byte-anchored (DBB/MB/IB/QB). + if (isWide) + { + if (addr.Size != S7Size.Byte) + return $"S7 {def.DataType} at '{def.Address}' is not byte-addressed. Wide/structured types " + + "must be byte-addressed (DBB/MB/IB/QB), e.g. 'DB1.DBB0', 'MB4'."; + parsed = addr; + return null; + } + + // Narrow types: the declared type must match the address's access width, or every read fails + // (or decodes garbage) with a type mismatch. + var required = def.DataType switch + { + S7DataType.Bool => S7Size.Bit, + S7DataType.Byte => S7Size.Byte, + S7DataType.Int16 or S7DataType.UInt16 => S7Size.Word, + _ => S7Size.DWord, // Int32 / UInt32 / Float32 + }; + if (addr.Size != required) + return $"S7 {def.DataType} at '{def.Address}' has the wrong access width ({addr.Size}). " + + $"A {def.DataType} tag needs a {SuffixHint(required)} address."; + + if (isArray && def.DataType == S7DataType.Bool) + { + // Element i of a Bool array is bit 0 of byte (start + i): the array is byte-granular. A + // non-zero bit offset used to be silently ignored (DB1.DBX0.3 returned bit 0 of bytes 0..n). + if (addr.BitOffset != 0) + return $"S7 Bool array at '{def.Address}' starts at bit {addr.BitOffset}. A Bool array reads " + + "one byte per element (bit 0 of each byte), so it must start at bit 0 (e.g. 'DB1.DBX0.0')."; + // A byte-granular write would overwrite the 7 neighbouring bits of every byte it touches. + if (def.Writable) + return $"S7 Bool array at '{def.Address}' is declared writable. Bool array writes are not " + + "supported (a byte-granular write would clobber neighbouring bits); set Writable=false " + + "or write the bits as individual scalar Bool tags."; + } + + parsed = addr; + return null; + } + + private static string SuffixHint(S7Size size) => size switch + { + S7Size.Bit => "bit (DBX/M/I/Q n.b)", + S7Size.Byte => "byte (DBB/MB/IB/QB)", + S7Size.Word => "word (DBW/MW/IW/QW)", + _ => "double-word (DBD/MD/ID/QD)", + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index 8659565a..347a0b19 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -239,41 +239,36 @@ public sealed class S7Driver if (HasConfigBody(driverConfigJson)) _options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); - // v3: build the RawPath → definition table from the authored raw tags BEFORE the config - // guards run (they now iterate the mapped table). Each entry's TagConfig blob is mapped by - // the pure factory; the entry's WriteIdempotent flag is threaded onto the def (it lives on - // the RawTagEntry, not inside the blob). A mapper miss is skipped (logged), never thrown — - // one bad tag must not fail the whole driver init. + // v3: build the RawPath → definition table from the authored raw tags. Each entry's TagConfig + // blob is mapped by the pure factory, which also applies S7TagShapeRules (the address must + // parse; wide arrays, mis-typed Timer/Counter, wrong access widths, writable Timer/Counter or + // Bool arrays and offset Bool arrays are refused). A refused tag is SKIPPED (logged, reported + // through ISkippedTagReporter, reads BadNodeIdUnknown), never thrown: these used to be init + // guards that faulted every tag on the PLC and made the actor retry the same init forever. + // The entry's WriteIdempotent flag is threaded onto the def (it lives on the RawTagEntry). _tagsByRawPath.Clear(); _parsedByRawPath.Clear(); _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry _skippedTags.BeginRebuild(_options.RawTags.Count); foreach (var entry in _options.RawTags) { - if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def)) + if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def, out var shapeRejection) + && S7AddressParser.TryParse(def.Address, out var parsedAddress)) + { _tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent }; + _parsedByRawPath[entry.RawPath] = parsedAddress; + } else { - _skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition"); + var reason = shapeRejection ?? "TagConfig did not map to a definition"; + _skippedTags.Skip(entry.RawPath, reason); _logger.LogWarning( - "S7 tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}", - _driverInstanceId, entry.RawPath); + "S7 tag skipped: {Reason} Driver={DriverInstanceId} RawPath={RawPath}", + reason, _driverInstanceId, entry.RawPath); } } _skippedTags.Commit(); - // Phase 4d combined config guard. Timer/Counter addresses and the wide/structured - // types (Int64/UInt64/Float64/String/DateTime) are now SUPPORTED, but only in - // specific shapes — this rejects the genuinely-unsupported configurations - // (wide-type arrays, non-byte-addressed wide scalars, wrong Timer/Counter DataType) - // so a config typo fails fast at init instead of as a misleading per-read fault. - RejectUnsupportedTagConfigs(); - - // Legacy data-type seam. The UnimplementedDataTypes set is now - // empty (Phase 4d unblocked all five wide types), so this rejects nothing — kept as - // the single grep target / future seam should a type ever need re-gating at init. - RejectUnsupportedTagDataTypes(); - // Build the connection through the factory (the same seam EnsureConnectedAsync's reopen // uses) so the read/write timeouts are baked in identically on connect and reconnect. var plc = _plcFactory.Create(_options); @@ -306,15 +301,6 @@ public sealed class S7Driver _connectBackoff.RecordSuccess(); Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize); - // Parse every tag's address once at init so config typos fail fast here instead - // of surfacing as BadInternalError on every Read against the bad tag. The parser - // also rejects bit-offset > 7, DB 0, unknown area letters, etc. Keyed by RawPath - // (== def.Name) so Read/Write can pick up the pre-parsed address. - foreach (var t in _tagsByRawPath.Values) - { - _parsedByRawPath[t.Name] = S7AddressParser.Parse(t.Address); // throws FormatException - } - _initialized = true; Health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); _logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize} Tags={TagCount}", @@ -437,13 +423,6 @@ public sealed class S7Driver return trimmed is not "{}" and not "[]"; } - /// - /// S7 data types addressed by START BYTE (the B suffix) rather than by an - /// access-width suffix. Phase 4d decodes these from a contiguous byte buffer - /// (DB{n}.DBB{offset} / MB / IB / QB{offset}), so a wide tag - /// authored against a non-byte suffix (DBW/DBD/…) is a config error — see - /// . - /// /// /// PDU size the CPU negotiated on the most recent successful connect, in bytes; 0 before the first /// connect and after shutdown. Snapshotted at connect so it stays readable while the handle is @@ -474,143 +453,6 @@ public sealed class S7Driver : new Dictionary(); } - private static readonly HashSet ByteAnchoredDataTypes = new() - { - S7DataType.Int64, - S7DataType.UInt64, - S7DataType.Float64, - S7DataType.String, - S7DataType.DateTime, - S7DataType.WString, - S7DataType.Char, - S7DataType.WChar, - S7DataType.Dtl, - S7DataType.Date, - S7DataType.Tod, - S7DataType.Time, - S7DataType.S5Time, - }; - - /// - /// Combined init-time config guard (Phase 4d, supersedes the old Timer/Counter-only - /// RejectUnsupportedTagAddresses). Timer/Counter tags and the wide/structured - /// types (Int64/UInt64/Float64/String/DateTime) are now supported — but only in - /// specific shapes. This guard fails fast on the genuinely-unsupported configurations so - /// a config typo throws here at init rather than as a misleading per-read fault: - /// - /// (a) a wide/structured type declared as an array — wide-type arrays are out of scope this phase. - /// (b) a wide/structured type on a DB/M/I/Q area whose address isn't byte-anchored (DBB/MB/IB/QB). - /// (c) a Timer tag not typed Float64, or a Counter tag not typed Int32. - /// - /// Addresses that don't parse are left to the existing - /// pass in so the parse error isn't double-handled. - /// - private void RejectUnsupportedTagConfigs() - { - foreach (var t in _tagsByRawPath.Values) - { - var isWide = ByteAnchoredDataTypes.Contains(t.DataType); - - // (a) wide-type arrays — out of scope this phase. - if (isWide && t.ArrayCount is >= 1) - { - throw new NotSupportedException( - $"S7 tag '{t.Name}' is a {t.DataType} array, which the S7 driver does not yet " + - "support. Use a scalar wide tag, or an Int16/UInt16/Int32/UInt32/Float32/Byte/Bool " + - "element type for arrays."); - } - - // Parse the address; if it doesn't parse, leave it for the InitializeAsync - // S7AddressParser.Parse pass to throw the FormatException (no double-handling). - if (!S7AddressParser.TryParse(t.Address, out var parsed)) - continue; - - // (b) wide/structured scalar on DB/M/I/Q must be byte-anchored (Size == Byte). The - // codec decodes from a byte buffer at the start byte; a non-byte suffix (DBW/DBD/…) - // would mis-frame the value. - if (isWide - && parsed.Area is S7Area.DataBlock or S7Area.Memory or S7Area.Input or S7Area.Output - && parsed.Size != S7Size.Byte) - { - throw new NotSupportedException( - $"S7 tag '{t.Name}' is a {t.DataType} at '{t.Address}', which is not byte-addressed. " + - "Wide/structured types must be byte-addressed (DBB/MB/IB/QB) — e.g. 'DB1.DBB0', 'MB4'."); - } - - // (c) Timer/Counter type constraints — Timer decodes to a Float64 (seconds), Counter - // to an Int32 (count); both are read-only. - if (parsed.Area is S7Area.Timer && t.DataType != S7DataType.Float64) - { - throw new NotSupportedException( - $"S7 tag '{t.Name}' is a Timer address ('{t.Address}') but is typed {t.DataType}. " + - "Timer tags must be DataType=Float64 (decoded to seconds, read-only)."); - } - if (parsed.Area is S7Area.Counter && t.DataType != S7DataType.Int32) - { - throw new NotSupportedException( - $"S7 tag '{t.Name}' is a Counter address ('{t.Address}') but is typed {t.DataType}. " + - "Counter tags must be DataType=Int32 (decoded to count, read-only)."); - } - - // (d) Timer/Counter declared Writable — writes are read-only this phase; without this - // a node is discovered as Operate-writable but every write returns BadNotSupported. - if (parsed.Area is S7Area.Timer or S7Area.Counter && t.Writable) - { - throw new NotSupportedException( - $"S7 tag '{t.Name}' is a Timer/Counter ('{t.Address}') declared Writable — " + - "Timer/Counter are read-only this phase; set Writable=false."); - } - - // (e) Bool array declared Writable — element writes are byte-granular (one byte per - // element, mirroring the read), so a write would overwrite the 7 neighbouring bits of - // every byte it touches; a safe bit-packed write needs read-modify-write and is not - // implemented. Every other supported element type (Byte/Int16/UInt16/Int32/UInt32/ - // Float32) writes as ONE contiguous block (EncodeArrayBlock). Wide-type arrays - // are already rejected above (guard-a). - if (t.ArrayCount is >= 1 && t.Writable && t.DataType == S7DataType.Bool) - { - throw new NotSupportedException( - $"S7 tag '{t.Name}' is a Bool array (ArrayCount={t.ArrayCount}) declared Writable — " + - "Bool array writes are not supported (a byte-granular write would clobber the neighbouring " + - "bits). Set Writable=false, or write the bits as individual scalar Bool tags."); - } - } - } - - /// - /// Rejects tags configured with an still on the - /// list — types / - /// throw for, which - /// would otherwise create live OPC UA nodes via that return - /// BadNotSupported on every access. - /// The set is now empty (Phase 4d unblocked all five wide types), so this rejects - /// nothing — kept as the seam / grep target should a type ever need re-gating at init. - /// - private void RejectUnsupportedTagDataTypes() - { - foreach (var t in _tagsByRawPath.Values) - { - if (UnimplementedDataTypes.Contains(t.DataType)) - { - throw new NotSupportedException( - $"S7 tag '{t.Name}' uses data type '{t.DataType}' which is not yet " + - "supported by the S7 driver — Read/Write would return BadNotSupported. " + - "Remove the tag or use Bool/Byte/Int16/UInt16/Int32/UInt32/Float32 until " + - $"{t.DataType} is wired through."); - } - } - } - - /// - /// S7DataType members that the read/write helpers throw NotSupportedException for. - /// Now empty — Phase 4d (S7 wide types + Timer/Counter) wired Int64, UInt64, - /// Float64, String, and DateTime through the byte-buffer codec, and Timer/Counter read - /// through it on AREA. Kept here (rather than reflecting over - /// ) as the single grep target / future seam should a - /// data type ever need re-gating at init. - /// - private static readonly HashSet UnimplementedDataTypes = new(); - // ---- IReadable ---- // The batch is served in two passes under ONE gate acquisition. First S7ReadSpanPlanner groups @@ -1378,7 +1220,7 @@ public sealed class S7Driver /// true if the tag routes through the byte-buffer codec. internal static bool IsBufferType(S7TagDefinition tag, S7ParsedAddress addr) => addr.Area is S7Area.Timer or S7Area.Counter - || ByteAnchoredDataTypes.Contains(tag.DataType); + || S7TagShapeRules.IsByteAnchored(tag.DataType); /// /// Reads a wide/structured scalar as ONE contiguous byte block via S7.Net's diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs index 49b4e199..723cd369 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs @@ -112,8 +112,9 @@ public sealed class S7TagConfigModel + string.Join(", ", Enum.GetNames()) + "."; } - // Array shape is judged against the driver's INIT guards, which THROW (failing the whole - // driver's init, not just this tag) for a wide-type array and for a writable Bool array. The + // Array shape: the friendlier editor wording for two of the S7TagShapeRules refusals (a wide-type + // array, a writable Bool array). The reader below refuses them too (and the rest of the shape + // rules), so this is wording, not a second authority. The // isArray / arrayLength keys themselves belong to the TagModal's generic array controls // (TagArrayConfig) and are intentionally not fields of this model, so read them off the key bag. if (TagConfigJson.GetBool(_bag, "isArray")) diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500TagShapeSkipTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500TagShapeSkipTests.cs new file mode 100644 index 00000000..17bc9a61 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500TagShapeSkipTests.cs @@ -0,0 +1,57 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500; + +/// +/// Live gate for R07:H3 — one mis-authored tag must not darken the PLC. Before the fix each of +/// these shapes threw at init, so the driver Faulted and served nothing (and the actor retried the +/// same init every 10 s forever). Now each is skipped on its own and the good tags keep reading +/// their seeded values over real S7comm. +/// +[Collection(Snap7ServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Device", "S7_1500")] +public sealed class S7_1500TagShapeSkipTests(Snap7ServerFixture sim) +{ + /// Unservable tags are skipped per tag; the seeded neighbours still read Good. + [Fact] + public async Task Mis_authored_tags_are_skipped_and_the_rest_of_the_PLC_still_reads() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + var ct = TestContext.Current.CancellationToken; + + var good = S7_1500Profile.BuildOptions(sim.Host, sim.Port); + var bad = new[] + { + new S7TagDefinition("Bad/Typo", "DB1.DBZ0", S7DataType.Int16), + new S7TagDefinition("Bad/WideOnWord", "DB1.DBW0", S7DataType.Float64), + new S7TagDefinition("Bad/TimerAsInt", "T5", S7DataType.Int32, Writable: false), + new S7TagDefinition("Bad/OffsetBoolArray", "DB1.DBX50.3", S7DataType.Bool, Writable: false, ArrayCount: 8), + }; + var options = new S7DriverOptions + { + Host = good.Host, Port = good.Port, CpuType = good.CpuType, Rack = good.Rack, Slot = good.Slot, + Timeout = good.Timeout, Probe = good.Probe, + RawTags = [.. good.RawTags, .. S7RawTags.Entries(bad)], + }; + + await using var drv = new S7Driver(options, driverInstanceId: "s7-shape-skip-live"); + await drv.InitializeAsync("{}", ct); + + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + drv.GetSkippedTags().ShouldNotBeNull().Sample.Select(s => s.RawPath) + .ShouldBe(bad.Select(b => b.Name), ignoreOrder: true); + + var r = await drv.ReadAsync( + [S7_1500Profile.ProbeTag, S7_1500Profile.SmokeI16Tag, S7_1500Profile.SmokeBoolTag, bad[0].Name], ct); + r[0].StatusCode.ShouldBe(0u); + Convert.ToInt32(r[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue); + r[1].StatusCode.ShouldBe(0u); + Convert.ToInt32(r[1].Value).ShouldBe((int)S7_1500Profile.SmokeI16SeedValue); + r[2].StatusCode.ShouldBe(0u); + Convert.ToBoolean(r[2].Value).ShouldBeTrue(); + r[3].StatusCode.ShouldBe(0x80340000u, "a skipped tag reads BadNodeIdUnknown"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests.cs index 9c1c3a8a..6f0f12b8 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests.cs @@ -25,26 +25,10 @@ public sealed class S7DriverCodeReviewFixTests [InlineData("C10")] public async Task Initialize_rejects_timer_or_counter_tag_with_NotSupportedException(string address) { - // A Timer/Counter address parses cleanly but the read path has no decode case for it, - // so it must fail fast at init rather than throw a misleading type-mismatch on every - // read. The host is reserved-for-documentation so the TCP connect can never succeed — - // the unsupported-address guard runs before the connect, so the NotSupportedException - // is what surfaces. - var opts = new S7DriverOptions - { - Host = "192.0.2.1", - Timeout = TimeSpan.FromMilliseconds(250), - RawTags = S7RawTags.Entries(new S7TagDefinition("Quirk", address, S7DataType.Int16)), - }; - using var drv = new S7Driver(opts, "s7-tc"); - - var ex = await Should.ThrowAsync(async () => - await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); - ex.Message.ShouldContain(address); - - var health = drv.GetHealth(); - health.State.ShouldBe(DriverState.Faulted, "an unsupported-address config error must Fault the driver"); - health.LastError.ShouldNotBeNull(); + // A Timer/Counter address typed anything but Float64 (Timer) / Int32 (Counter) cannot be decoded. + // It is refused PER TAG at every gate; it used to throw at init and fault every tag on the PLC. + await S7TagSkipAssert.RefusedPerTagAsync( + new S7TagDefinition("Quirk", address, S7DataType.Int16, Writable: false), address); } /// Verifies that initialize accepts DB and MIQ addresses without triggering the unsupported guard. @@ -102,19 +86,21 @@ public sealed class S7DriverCodeReviewFixTests [Fact] public async Task Initialize_rejects_a_timer_tag_supplied_only_through_driverConfigJson() { - // The Timer/Counter guard must run against the *re-parsed* config, not just the - // constructor options — proves Driver.S7-001 and Driver.S7-011 compose correctly. + // The shape gate must run against the *re-parsed* config, not just the constructor options — + // proves the per-tag skip and Driver.S7-011 compose correctly. var ctorOpts = new S7DriverOptions { Host = "192.0.2.1", Timeout = TimeSpan.FromMilliseconds(250) }; - using var drv = new S7Driver(ctorOpts, "s7-cfg-tc"); + using var drv = new S7Driver(ctorOpts, "s7-cfg-tc", new ScriptedS7PlcFactory()); const string json = """ - { "Host": "192.0.2.1", "TimeoutMs": 250, + { "Host": "192.0.2.1", "TimeoutMs": 250, "Probe": { "Enabled": false }, "RawTags": [ { "RawPath": "TimerTag", "TagConfig": "{\"address\":\"T5\",\"dataType\":\"Int16\"}", "WriteIdempotent": false } ] } """; - var ex = await Should.ThrowAsync(async () => - await drv.InitializeAsync(json, TestContext.Current.CancellationToken)); - ex.Message.ShouldContain("T5"); + await drv.InitializeAsync(json, TestContext.Current.CancellationToken); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + var skipped = drv.GetSkippedTags().ShouldNotBeNull().Sample.ShouldHaveSingleItem(); + skipped.RawPath.ShouldBe("TimerTag"); + skipped.Reason.ShouldContain("T5"); } /// Verifies that reinitialize applies a changed driverConfigJson. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs index 75731de5..59bb0922 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverCodeReviewFixTests2.cs @@ -158,23 +158,9 @@ public sealed class S7DriverCodeReviewFixTests2 [InlineData(S7DataType.DateTime)] public async Task Initialize_rejects_wide_type_at_non_byte_address_with_NotSupportedException(S7DataType dt) { - var opts = new S7DriverOptions - { - Host = "192.0.2.1", - Timeout = TimeSpan.FromMilliseconds(250), - // DB1.DBD0 is a DWord (non-byte) address — guard-(b) must fault on the address shape - // for a wide type. (At a byte address like DB1.DBB0 the 8-byte numerics round-trip; - // see S7ScalarBlockTests.) - RawTags = S7RawTags.Entries(new S7TagDefinition("X", "DB1.DBD0", dt)), - }; - using var drv = new S7Driver(opts, $"s7-wide-nonbyte-{dt}"); - - var ex = await Should.ThrowAsync(async () => - await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); - ex.Message.ShouldContain(dt.ToString()); - - var health = drv.GetHealth(); - health.State.ShouldBe(DriverState.Faulted); + // DB1.DBD0 is a DWord (non-byte) address — a wide type there would mis-frame its value. (At a + // byte address like DB1.DBB0 the 8-byte numerics round-trip; see S7ScalarBlockTests.) + await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("X", "DB1.DBD0", dt), dt.ToString(), "byte-addressed"); } /// Verifies that Initialize accepts implemented data types. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReadWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReadWriteTests.cs index 9f59d91c..d5bca500 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReadWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverReadWriteTests.cs @@ -17,22 +17,11 @@ public sealed class S7DriverReadWriteTests [Fact] public async Task Initialize_rejects_invalid_tag_address_and_fails_fast() { - // Bad address at init time must throw; the alternative (deferring the parse to the - // first read) would surface the config bug as BadInternalError on every subsequent - // Read which is impossible for an operator to diagnose from the OPC UA client. - var opts = new S7DriverOptions - { - Host = "192.0.2.1", // reserved — will never complete TCP handshake - Timeout = TimeSpan.FromMilliseconds(250), - RawTags = S7RawTags.Entries(new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16)), - }; - using var drv = new S7Driver(opts, "s7-bad-tag"); - - // Either the TCP connect fails first (Exception) or the parser fails (FormatException) - // — both are acceptable since both are init-time fail-fast. What matters is that we - // don't return a "healthy" driver with a latent bad tag. - await Should.ThrowAsync(async () => - await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); + // A bad address is refused PER TAG at every gate (mapper/editor, deploy gate, driver init): + // it reads BadNodeIdUnknown while its neighbours keep working. It used to throw at init and + // fault every tag on the PLC, with the actor retrying the same init every 10 s forever. + await S7TagSkipAssert.RefusedPerTagAsync( + new S7TagDefinition("BadTag", "NOT-AN-S7-ADDRESS", S7DataType.Int16), "does not parse"); } /// Verifies that ReadAsync without initialize throws InvalidOperationException. diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverScaffoldTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverScaffoldTests.cs index 6c1c6561..a8e4e52e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverScaffoldTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DriverScaffoldTests.cs @@ -101,80 +101,32 @@ public sealed class S7DriverScaffoldTests [Fact] public async Task Initialize_rejects_wide_type_array_with_clear_message() { - var opts = new S7DriverOptions - { - Host = "192.0.2.1", - Timeout = TimeSpan.FromMilliseconds(250), - RawTags = S7RawTags.Entries(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4)), - }; - using var drv = new S7Driver(opts, "s7-wide-array"); - - var ex = await Should.ThrowAsync(async () => - await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); - ex.Message.ShouldContain("Batch64"); - ex.Message.ShouldContain("array", Case.Insensitive); - - drv.GetHealth().State.ShouldBe(DriverState.Faulted); + // Refused PER TAG at every gate (it used to throw at init and fault the whole driver). + await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Batch64", "DB1.DBB0", S7DataType.Int64, ArrayCount: 4), "Int64", "array"); } /// Verifies that a wide scalar with a non-byte address (Float64 at DB1.DBW0) is rejected with a byte-address message. [Fact] public async Task Initialize_rejects_wide_scalar_with_non_byte_address() { - var opts = new S7DriverOptions - { - Host = "192.0.2.1", - Timeout = TimeSpan.FromMilliseconds(250), - RawTags = S7RawTags.Entries(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64)), - }; - using var drv = new S7Driver(opts, "s7-wide-nonbyte"); - - var ex = await Should.ThrowAsync(async () => - await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); - ex.Message.ShouldContain("WideWord"); - ex.Message.ShouldContain("byte", Case.Insensitive); - - drv.GetHealth().State.ShouldBe(DriverState.Faulted); + // Refused PER TAG at every gate (it used to throw at init and fault the whole driver). + await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("WideWord", "DB1.DBW0", S7DataType.Float64), "Float64", "byte-addressed"); } /// Verifies that a Timer tag with the wrong DataType (Int32, not Float64) is rejected. [Fact] public async Task Initialize_rejects_Timer_tag_with_wrong_data_type() { - var opts = new S7DriverOptions - { - Host = "192.0.2.1", - Timeout = TimeSpan.FromMilliseconds(250), - RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Int32)), - }; - using var drv = new S7Driver(opts, "s7-timer-bad"); - - var ex = await Should.ThrowAsync(async () => - await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); - ex.Message.ShouldContain("Timer5"); - ex.Message.ShouldContain("Float64"); - - drv.GetHealth().State.ShouldBe(DriverState.Faulted); + // Refused PER TAG at every gate (it used to throw at init and fault the whole driver). + await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Timer5", "T5", S7DataType.Int32, Writable: false), "T5", "Float64"); } /// Verifies that a Counter tag with the wrong DataType (Float32, not Int32) is rejected. [Fact] public async Task Initialize_rejects_Counter_tag_with_wrong_data_type() { - var opts = new S7DriverOptions - { - Host = "192.0.2.1", - Timeout = TimeSpan.FromMilliseconds(250), - RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Float32)), - }; - using var drv = new S7Driver(opts, "s7-counter-bad"); - - var ex = await Should.ThrowAsync(async () => - await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); - ex.Message.ShouldContain("Counter3"); - ex.Message.ShouldContain("Int32"); - - drv.GetHealth().State.ShouldBe(DriverState.Faulted); + // Refused PER TAG at every gate (it used to throw at init and fault the whole driver). + await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Counter3", "C3", S7DataType.Float32, Writable: false), "C3", "Int32"); } // ── Phase 4d bundle-review fix — Writable Timer/Counter guard ─────────────────────── @@ -188,42 +140,16 @@ public sealed class S7DriverScaffoldTests [Fact] public async Task Initialize_rejects_Timer_tag_declared_Writable() { - var opts = new S7DriverOptions - { - Host = "192.0.2.1", - Timeout = TimeSpan.FromMilliseconds(250), - RawTags = S7RawTags.Entries(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true)), - }; - using var drv = new S7Driver(opts, "s7-timer-writable"); - - var ex = await Should.ThrowAsync(async () => - await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); - ex.Message.ShouldContain("Timer5"); - ex.Message.ShouldContain("Writable", Case.Insensitive); - ex.Message.ShouldContain("read-only", Case.Insensitive); - - drv.GetHealth().State.ShouldBe(DriverState.Faulted); + // Refused PER TAG at every gate (it used to throw at init and fault the whole driver). + await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Timer5", "T5", S7DataType.Float64, Writable: true), "T5", "writable", "read-only"); } /// Verifies that a Counter tag declared Writable=true is rejected at init with a Writable message. [Fact] public async Task Initialize_rejects_Counter_tag_declared_Writable() { - var opts = new S7DriverOptions - { - Host = "192.0.2.1", - Timeout = TimeSpan.FromMilliseconds(250), - RawTags = S7RawTags.Entries(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true)), - }; - using var drv = new S7Driver(opts, "s7-counter-writable"); - - var ex = await Should.ThrowAsync(async () => - await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); - ex.Message.ShouldContain("Counter3"); - ex.Message.ShouldContain("Writable", Case.Insensitive); - ex.Message.ShouldContain("read-only", Case.Insensitive); - - drv.GetHealth().State.ShouldBe(DriverState.Faulted); + // Refused PER TAG at every gate (it used to throw at init and fault the whole driver). + await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Counter3", "C3", S7DataType.Int32, Writable: true), "C3", "writable", "read-only"); } // ── Driver.S7-015 / S7-016 — writable Bool array must fail fast at init ───────────── @@ -236,20 +162,8 @@ public sealed class S7DriverScaffoldTests [Fact] public async Task Initialize_rejects_writable_bool_array_tag_with_NotSupportedException() { - var opts = new S7DriverOptions - { - Host = "192.0.2.1", - Timeout = TimeSpan.FromMilliseconds(250), - RawTags = S7RawTags.Entries(new S7TagDefinition("ArrTag", "DB1.DBX0.0", S7DataType.Bool, Writable: true, ArrayCount: 4)), - }; - using var drv = new S7Driver(opts, "s7-writable-bool-arr"); - - var ex = await Should.ThrowAsync(async () => - await drv.InitializeAsync("{}", TestContext.Current.CancellationToken)); - ex.Message.ShouldContain("ArrTag"); - ex.Message.ShouldContain("Bool array", Case.Insensitive); - - drv.GetHealth().State.ShouldBe(DriverState.Faulted); + // Refused PER TAG at every gate (it used to throw at init and fault the whole driver). + await S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("ArrTag", "DB1.DBX0.0", S7DataType.Bool, Writable: true, ArrayCount: 4), "Bool array", "writable"); } /// Verifies that writable non-bool arrays pass the init guard — the failure diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TagShapeGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TagShapeGateTests.cs new file mode 100644 index 00000000..3bc47dca --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TagShapeGateTests.cs @@ -0,0 +1,66 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// R07:H3 / R07:U4 — the tag shapes the driver cannot serve are refused per tag at the mapper, +/// the deploy gate and the driver's init (see ), instead of faulting +/// the whole driver at init or being silently mis-read. +/// +[Trait("Category", "Unit")] +public sealed class S7TagShapeGateTests +{ + /// A typo'd area/size letter is refused per tag (it used to throw FormatException at init). + [Fact] + public Task Unparseable_address_is_refused_per_tag() + => S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Typo", "DB1.DBZ0", S7DataType.Int16), "DB1.DBZ0", "does not parse"); + + /// + /// R07:U4 — a Bool array reads bit 0 of each byte, so a non-zero start bit was silently ignored + /// (DB1.DBX0.3 returned bit 0 of bytes 0..7). It is refused at authoring now. + /// + [Fact] + public Task Bool_array_starting_at_a_nonzero_bit_is_refused() + => S7TagSkipAssert.RefusedPerTagAsync( + new S7TagDefinition("OffsetBits", "DB1.DBX0.3", S7DataType.Bool, Writable: false, ArrayCount: 8), + "Bool array", "bit 3"); + + /// A narrow type at the wrong access width fails every read with a type mismatch; refused up front. + /// The address. + /// The declared type, which does not match the address's access width. + [Theory] + [InlineData("DB1.DBW0", S7DataType.Int32)] + [InlineData("DB1.DBD0", S7DataType.Int16)] + [InlineData("DB1.DBB0", S7DataType.Bool)] + [InlineData("M0.0", S7DataType.Byte)] + [InlineData("MW0", S7DataType.Float32)] + public Task Narrow_type_at_the_wrong_access_width_is_refused(string address, S7DataType dt) + => S7TagSkipAssert.RefusedPerTagAsync(new S7TagDefinition("Mismatch", address, dt), "access width"); + + /// Positive control: the shapes the driver serves are all still accepted by the mapper and the gate. + /// The address. + /// The declared type. + /// The array count, or 0 for a scalar. + /// Whether the tag is writable. + [Theory] + [InlineData("DB1.DBX0.0", S7DataType.Bool, 8, false)] + [InlineData("DB1.DBX50.3", S7DataType.Bool, 0, true)] + [InlineData("DB1.DBB0", S7DataType.Byte, 4, true)] + [InlineData("DB1.DBW0", S7DataType.Int16, 0, true)] + [InlineData("MW2", S7DataType.UInt16, 3, true)] + [InlineData("DB1.DBD0", S7DataType.Float32, 0, true)] + [InlineData("QD4", S7DataType.Int32, 2, true)] + [InlineData("DB1.DBB0", S7DataType.Float64, 0, true)] + [InlineData("DB1.DBB0", S7DataType.String, 0, true)] + [InlineData("T5", S7DataType.Float64, 0, false)] + [InlineData("C3", S7DataType.Int32, 0, false)] + public void Servable_shapes_are_accepted(string address, S7DataType dt, int arrayCount, bool writable) + { + var def = new S7TagDefinition("Ok", address, dt, Writable: writable, ArrayCount: arrayCount == 0 ? null : arrayCount); + var blob = S7TagDefinitionFactory.ToTagConfig(def); + S7TagDefinitionFactory.FromTagConfig(blob, "Ok", out var mapped, out var reason).ShouldBeTrue(reason); + mapped.Address.ShouldBe(address); + S7TagDefinitionFactory.Inspect(blob).ShouldBeEmpty(); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TagSkipAssert.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TagSkipAssert.cs new file mode 100644 index 00000000..47f96de4 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TagSkipAssert.cs @@ -0,0 +1,60 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// Shared assertion for an S7 tag shape the driver cannot serve. It must be refused PER TAG at +/// every gate, never fault the whole driver: the mapper (and so the AdminUI editor) refuses it with +/// the reason, the deploy gate () warns, and a driver +/// carrying it next to a good tag initializes Healthy, reports the bad one through +/// , and still serves the good one (the positive control). +/// +internal static class S7TagSkipAssert +{ + private const string GoodName = "Good"; + + /// Asserts is refused per tag at the mapper, the deploy gate and the driver. + /// The unservable tag. + /// Fragments the operator-facing rejection reason must contain. + /// A task that completes when every gate has been checked. + public static async Task RefusedPerTagAsync(S7TagDefinition bad, params string[] reasonFragments) + { + var ct = TestContext.Current.CancellationToken; + var blob = S7TagDefinitionFactory.ToTagConfig(bad); + + // Mapper (and therefore the AdminUI editor, which asks it) — refused, with the reason. + S7TagDefinitionFactory.FromTagConfig(blob, bad.Name, out _, out var reason).ShouldBeFalse(); + reason.ShouldNotBeNull(); + foreach (var f in reasonFragments) reason.ShouldContain(f, Case.Insensitive); + + // Deploy gate — warns with the same reason. + var warnings = S7TagDefinitionFactory.Inspect(blob); + warnings.ShouldHaveSingleItem().ShouldContain(reason); + + // Driver — initializes Healthy with the bad tag skipped and the good tag served. + var opts = new S7DriverOptions + { + Host = "192.0.2.1", + Timeout = TimeSpan.FromMilliseconds(250), + Probe = new S7ProbeOptions { Enabled = false }, + RawTags = S7RawTags.Entries(bad, new S7TagDefinition(GoodName, "DB1.DBW0", S7DataType.UInt16, Writable: false)), + }; + using var drv = new S7Driver(opts, "s7-skip-" + bad.Name, new ScriptedS7PlcFactory()); + await drv.InitializeAsync("{}", ct); + + drv.GetHealth().State.ShouldBe(DriverState.Healthy, "one unservable tag must not fault the driver"); + var skipped = drv.GetSkippedTags().ShouldNotBeNull(); + skipped.Sample.ShouldHaveSingleItem().RawPath.ShouldBe(bad.Name); + skipped.Sample[0].Reason.ShouldBe(reason); + + var builder = new RecordingAddressSpaceBuilder(); + await drv.DiscoverAsync(builder, ct); + builder.Variables.Select(v => v.Name).ShouldBe([GoodName]); + + var results = await drv.ReadAsync([bad.Name, GoodName], ct); + results[0].StatusCode.ShouldBe(0x80340000u, "the skipped tag reads BadNodeIdUnknown"); + results[1].StatusCode.ShouldBe(0u, "the good neighbour is still served"); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ValueCodecTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ValueCodecTests.cs index 506d5d67..95bba0b3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ValueCodecTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ValueCodecTests.cs @@ -318,16 +318,9 @@ public sealed class S7ValueCodecTests [Fact] public async Task New_types_declared_as_arrays_or_off_byte_addresses_fail_init() { - S7DriverOptions With(S7TagDefinition t) => new() - { - Host = "192.0.2.1", - Probe = new S7ProbeOptions { Enabled = false }, - RawTags = S7RawTags.Entries(t), - }; - using var arr = new S7Driver(With(Tag(S7DataType.Dtl) with { ArrayCount = 2 }), "a", new ConnectingFakeS7PlcFactory()); - await Should.ThrowAsync(() => arr.InitializeAsync("{}", TestContext.Current.CancellationToken)); - using var word = new S7Driver(With(Tag(S7DataType.Tod, address: "DB1.DBW0")), "b", new ConnectingFakeS7PlcFactory()); - await Should.ThrowAsync(() => word.InitializeAsync("{}", TestContext.Current.CancellationToken)); + // Refused PER TAG at every gate (they used to throw at init and fault the whole driver). + await S7TagSkipAssert.RefusedPerTagAsync(Tag(S7DataType.Dtl) with { Name = "DtlArr", ArrayCount = 2 }, "Dtl", "array"); + await S7TagSkipAssert.RefusedPerTagAsync(Tag(S7DataType.Tod, address: "DB1.DBW0") with { Name = "TodWord" }, "Tod", "byte-addressed"); } // ---- TagConfig ---- diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ScriptedS7Plc.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ScriptedS7Plc.cs new file mode 100644 index 00000000..25d74961 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/ScriptedS7Plc.cs @@ -0,0 +1,142 @@ +using S7DataItem = global::S7.Net.Types.DataItem; +using S7NetDataType = global::S7.Net.DataType; +using VarType = global::S7.Net.VarType; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// A scriptable : every connection it creates delegates its wire +/// operations to the factory's handlers, so a test scripts the device once and observes every +/// connection (including reopened ones). Counts wire operations so a test can prove what did +/// NOT go to the wire without timing anything. +/// +internal sealed class ScriptedS7PlcFactory : IS7PlcFactory +{ + private int _wireOps; + + /// Every connection created, in order. + public List Created { get; } = []; + + /// Wire operations issued (reads, multi-var reads, writes; not opens or status probes). + public int WireOps => Volatile.Read(ref _wireOps); + + /// Hook run inside every ; the default opens immediately. + public Func Open { get; set; } = (_, _) => Task.CompletedTask; + + /// Per-address string read; the default returns (ushort)42. + public Func> Read { get; set; } = (_, _) => Task.FromResult((ushort)42); + + /// Block read; the default returns count zero bytes. + public Func> ReadBytes { get; set; } + = (_, _, _, count, _) => Task.FromResult(new byte[count]); + + /// Multi-var read; the default answers every item with a zero of its var type. + public Func, CancellationToken, Task>> ReadMulti { get; set; } + = (items, _) => + { + foreach (var i in items) + i.Value = i.VarType switch + { + VarType.Bit => false, + VarType.Byte => (byte)0, + VarType.Word => (ushort)0, + _ => (uint)0, + }; + return Task.FromResult>(items); + }; + + /// Per-address string write; the default succeeds. + public Func Write { get; set; } = (_, _, _) => Task.CompletedTask; + + /// Block write; the default succeeds. + public Func WriteBytes { get; set; } + = (_, _, _, _, _) => Task.CompletedTask; + + /// CPU-status probe; the default answers. + public Func ReadStatus { get; set; } = _ => Task.CompletedTask; + + /// + public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) + { + lock (Created) + { + var plc = new ScriptedS7Plc(this, Created.Count); + Created.Add(plc); + return plc; + } + } + + /// Counts one wire operation. + internal void CountWireOp() => Interlocked.Increment(ref _wireOps); +} + +/// One connection created by . +internal sealed class ScriptedS7Plc(ScriptedS7PlcFactory factory, int index) : IS7Plc +{ + /// Zero-based creation index. + public int Index { get; } = index; + + /// + public bool IsConnected { get; private set; } + + /// True once disposed. + public bool Disposed { get; private set; } + + /// + public int MaxPduSize => 240; + + /// + public async Task OpenAsync(CancellationToken cancellationToken) + { + await factory.Open(Index, cancellationToken).ConfigureAwait(false); + IsConnected = true; + } + + /// + public Task ReadAsync(string address, CancellationToken cancellationToken) + { + factory.CountWireOp(); + return factory.Read(address, cancellationToken); + } + + /// + public Task ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken) + { + factory.CountWireOp(); + return factory.ReadBytes(area, db, startByteAdr, count, cancellationToken); + } + + /// + public Task> ReadMultipleVarsAsync(List items, CancellationToken cancellationToken) + { + factory.CountWireOp(); + return factory.ReadMulti(items, cancellationToken); + } + + /// + public Task WriteAsync(string address, object value, CancellationToken cancellationToken) + { + factory.CountWireOp(); + return factory.Write(address, value, cancellationToken); + } + + /// + public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) + { + factory.CountWireOp(); + return factory.WriteBytes(area, db, startByteAdr, value, cancellationToken); + } + + /// + public Task ReadStatusAsync(CancellationToken cancellationToken) => factory.ReadStatus(cancellationToken); + + /// + public void Close() => IsConnected = false; + + /// + public void Dispose() + { + Disposed = true; + IsConnected = false; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvMergeEvidenceTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvMergeEvidenceTests.cs index 337c6a34..112f1287 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvMergeEvidenceTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/CsvMergeEvidenceTests.cs @@ -142,7 +142,7 @@ public sealed class CsvMergeEvidenceTests var row = SingleRow( DriverTypeNames.S7, "Name,DataType,Address,S7DataType,TagConfigJson", - "t1,Int16,DB1.DBW0,Int32,\"{\"\"pollHint\"\":\"\"fast\"\"}\""); + "t1,Int16,DB1.DBD0,Int32,\"{\"\"pollHint\"\":\"\"fast\"\"}\""); var blob = Blob(row); blob["dataType"]!.GetValue().ShouldBe("Int32"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagConfigGateTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagConfigGateTests.cs index 65930cf0..17703c6b 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagConfigGateTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/RawTagConfigGateTests.cs @@ -346,7 +346,11 @@ public sealed class RawTagConfigGateTests public void A_browse_committed_config_passes_the_gate(string driverType) { var tagConfig = RawBrowseCommitMapper.BuildTagConfig( - driverType, driverType == DriverTypeNames.AbLegacy ? "N7:0" : "SomeAddress", BrowseFieldsFor(driverType)); + driverType, + // Drivers whose address grammar is validated by their reader need a real address: AbLegacy's + // file grammar, and S7's (S7TagShapeRules — an unparseable or wrong-width address is refused). + driverType == DriverTypeNames.AbLegacy ? "N7:0" : driverType == DriverTypeNames.S7 ? "DB1.DBW0" : "SomeAddress", + BrowseFieldsFor(driverType)); TagConfigValidator.Validate(driverType, tagConfig).ShouldBeNull( $"browse-commit for {driverType} must not author a config the import refuses"); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/S7TagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/S7TagConfigModelTests.cs index dbd8e61b..12cb121c 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/S7TagConfigModelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/S7TagConfigModelTests.cs @@ -98,13 +98,13 @@ public sealed class S7TagConfigModelTests /// driver serves. (An absent key is deliberately not asserted: S7 alone hard-requires the key, an /// asymmetry that predates this change and that its own Inspect does not report either.) [Theory] - [InlineData("""{"address":"DB1.DBW0","dataType":"float32"}""")] - [InlineData("""{"address":"DB1.DBW0","dataType":"FLOAT32"}""")] + [InlineData("""{"address":"DB1.DBD0","dataType":"float32"}""")] + [InlineData("""{"address":"DB1.DBD0","dataType":"FLOAT32"}""")] [InlineData("""{"address":"DB1.DBW0","dataType":7}""")] public void Validate_accepts_every_dataType_the_driver_accepts(string json) => S7TagConfigModel.FromJson(json).Validate().ShouldBeNull(); - // ---- S7-016: array shape vs the driver's init guards (which fail the WHOLE driver init) ---- + // ---- S7-016: array shape vs S7TagShapeRules (refused per tag at every gate) ---- [Theory] [InlineData("Int64")] diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigNonEnumParityTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigNonEnumParityTests.cs index 019498e4..d71a00e0 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigNonEnumParityTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/TagConfigNonEnumParityTests.cs @@ -160,9 +160,9 @@ public sealed class TagConfigNonEnumParityTests /// S7's remaining non-enum refusal: a String tag whose stringLength falls outside /// 0–254. The reader REJECTS it (it does not clamp), so the tag would never resolve. [Theory] - [InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":255}""")] - [InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":300}""")] - [InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":-1}""")] + [InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":255}""")] + [InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":300}""")] + [InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":-1}""")] public void S7_refuses_a_string_length_its_reader_refuses(string blob) { RuntimeAccepts(DriverTypeNames.S7, blob).ShouldBeFalse( @@ -174,9 +174,9 @@ public sealed class TagConfigNonEnumParityTests /// stops this becoming an over-rejection — an out-of-range stringLength on a tag that is not a /// String at all, which the reader ignores entirely. [Theory] - [InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":10}""")] - [InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":254}""")] - [InlineData("""{"address":"DB1.DBS0","dataType":"String","stringLength":0}""")] + [InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":10}""")] + [InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":254}""")] + [InlineData("""{"address":"DB1.DBB0","dataType":"String","stringLength":0}""")] [InlineData("""{"address":"DB1.DBW0","dataType":"Int16","stringLength":300}""")] public void S7_accepts_a_string_length_its_reader_accepts(string blob) { @@ -360,8 +360,8 @@ public sealed class TagConfigNonEnumParityTests (DriverTypeNames.S7, """{"address":"DB1.DBW0","dataType":"Int16"}"""), (DriverTypeNames.S7, """{"address":"DB1.DBW0"}"""), (DriverTypeNames.S7, """{"address":" ","dataType":"Int16"}"""), - (DriverTypeNames.S7, """{"address":"DB1.DBS0","dataType":"String","stringLength":300}"""), - (DriverTypeNames.S7, """{"address":"DB1.DBS0","dataType":"String","stringLength":24}"""), + (DriverTypeNames.S7, """{"address":"DB1.DBB0","dataType":"String","stringLength":300}"""), + (DriverTypeNames.S7, """{"address":"DB1.DBB0","dataType":"String","stringLength":24}"""), (DriverTypeNames.AbCip, "{}"), (DriverTypeNames.AbCip, """{"tagPath":"Program:Main.Speed"}"""), @@ -576,7 +576,7 @@ public sealed class TagConfigNonEnumParityTests data.Add(DriverTypeNames.Modbus, new ModbusTagConfigModel { Address = 1 }.ToJson()); data.Add(DriverTypeNames.S7, new S7TagConfigModel { Address = "DB1.DBW0" }.ToJson()); data.Add(DriverTypeNames.S7, - new S7TagConfigModel { Address = "DB1.DBS0", DataType = S7DataType.String, StringLength = 24 }.ToJson()); + new S7TagConfigModel { Address = "DB1.DBB0", DataType = S7DataType.String, StringLength = 24 }.ToJson()); data.Add(DriverTypeNames.AbCip, new AbCipTagConfigModel { TagPath = "Program:Main.Speed" }.ToJson()); data.Add(DriverTypeNames.AbLegacy, new AbLegacyTagConfigModel { Address = "N7:0" }.ToJson()); data.Add(DriverTypeNames.TwinCAT, new TwinCATTagConfigModel { SymbolPath = "MAIN.bStart" }.ToJson()); From f37cb2a4a38ac4e7be102d7680c257c58ea16923 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:05:00 -0400 Subject: [PATCH 45/67] fix(abcip): only a reply type settles the BOOL-array layout (review r2) N1: a refusal of the DWORD-addressed request permanently settled the layout as per-element, so a tag deployed before the online edit that creates its BOOL array later read DWORD n and published byte 0 as Good. The native runtime now keeps an explicit layout (Unsettled / Packed / PerElement) that ONLY a reply type changes: 0xD3 settles packed (Bits[n/32], bit n%32), 0xC1 settles per-element (Bits[n]). Refusals and comms faults leave it unsettled, so the next read asks again. A reply whose type disagrees with the addressing it was asked under is discarded and re-read once under the shown layout (it would be some other element), also when an online edit re-types a settled array. A scalar BOOL answered 0xD3 with no element bit, or not settled packed, is refused BadTypeMismatch instead of decoding byte 0. m1: the write-owned Degraded marker is set together with the CAS that installs it, under one lock, so concurrent failing writes cannot leave it naming a different instance (a good write then clears it); a refusal-only read batch still keeps it write-owned. m2/m3/m5: documented (breaker accounting of a superseded init; libplctag throw-only NotImplemented assumption; the TypeMismatch write status word is libplctag-only). m4: test for a write on an unsettled layout (settles by a read first). Refs #737 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbCipDriver.cs | 44 ++++--- .../AbCipInitializationSupersededException.cs | 7 ++ .../LibplctagStatusTranslation.cs | 4 + .../NativeAbCipTagRuntime.cs | 109 +++++++++++++----- .../AbCipWriteHealthTests.cs | 44 +++++++ .../NativeAbCipTagRuntimeTests.cs | 102 ++++++++++++++++ 6 files changed, 266 insertions(+), 44 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 10b7a813..6be5b374 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -149,14 +149,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// Atomically replaces with applied to its current value. /// Computes the new health from the current one. - private void UpdateHealth(Func update) + /// The health instance this call installed. + private DriverHealth UpdateHealth(Func update) { while (true) { var current = _health; var next = update(current); #pragma warning disable CS0420 // a volatile field passed by ref to Interlocked keeps its full-fence semantics - if (ReferenceEquals(Interlocked.CompareExchange(ref _health, next, current), current)) return; + if (ReferenceEquals(Interlocked.CompareExchange(ref _health, next, current), current)) return next; #pragma warning restore CS0420 } } @@ -1505,6 +1506,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, else if (status == (int)AbStatus.TypeMismatch) { // Final review I4: deterministic — keep the handle, leave health, warn once per tag. + // Only libplctag reaches this status-word branch, where it is a real controller answer: the native + // runtime's pre-send type gate THROWS (caught below, no LastSuccessfulRead advance) and never + // returns here with the status merely set (m5). results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch); RecordWriteAnswered(now); WarnTypeMismatchOnce(def.Name, @@ -1645,23 +1649,35 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, /// replaced it since) is cleared back to Healthy: the device just accepted a write. A Degraded the read /// path recorded is left for the read path to clear. /// - private void RecordWriteAnswered(DateTime now) => - UpdateHealth(h => h.State == DriverState.Degraded && h.LastError is { } err - && ReferenceEquals(err, Volatile.Read(ref _writeFaultError)) - ? new DriverHealth(DriverState.Healthy, now, null) - : h with { LastSuccessfulRead = now }); + private void RecordWriteAnswered(DateTime now) + { + lock (_writeHealthLock) + { + UpdateHealth(h => h.State == DriverState.Degraded && h.LastError is { } err && ReferenceEquals(err, _writeOwnedError) + ? new DriverHealth(DriverState.Healthy, now, null) + : h with { LastSuccessfulRead = now }); + } + } - // The LastError string the most recent write-side comms fault wrote (reference identity marks "write-owned"). - private string? _writeFaultError; + // The exact LastError string INSTANCE the most recent write-side comms fault installed (a fresh copy, so reference + // identity cannot alias a read-side error). A health still carrying it is write-owned: a refusal-only read batch + // keeps it (`with` copies the reference), while any read / poll fault installs a new string and takes ownership. + // Set only under _writeHealthLock TOGETHER with the CAS that installs it, so two concurrent failing writes can never + // leave the marker naming a different instance than the installed health (#737 m1). + private string? _writeOwnedError; + private readonly object _writeHealthLock = new(); /// A write-side comms fault: Degraded (never downgrading Faulted), LastSuccessfulRead preserved. private void RecordWriteCommsFault(string error) { - var owned = new string(error.AsSpan()); // a distinct instance, so reference identity cannot alias a read error - Volatile.Write(ref _writeFaultError, owned); - UpdateHealth(h => h.State == DriverState.Faulted - ? h - : new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, owned)); + var owned = new string(error.AsSpan()); + lock (_writeHealthLock) + { + var installed = UpdateHealth(h => h.State == DriverState.Faulted + ? h + : new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, owned)); + if (ReferenceEquals(installed.LastError, owned)) _writeOwnedError = owned; + } } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs index eeb1afef..ffa3c0b1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs @@ -7,5 +7,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// "my own token fired" must not mistake a superseded attempt for one. The superseding operation owns the driver's /// state; the caller (normally the runtime, which has already abandoned this attempt) can ignore the result. /// +/// +/// Resilience accounting (#737 m2): not being an OCE, it passes the Initialize pipeline's breaker +/// ShouldHandle (DriverResiliencePipelineBuilder) and, if its generation still matched, the actor's +/// RecordFault. That is practically unreachable — DriverInstanceActor runs initialisation single-flight +/// and drops generation-mismatched results — and a superseded attempt is at worst one counted failure. Recognising +/// it there is a runtime-side (Lane A) change if it is ever wanted. +/// /// The detail. public sealed class AbCipInitializationSupersededException(string message) : InvalidOperationException(message); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs index 002e8c21..7be295a6 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs @@ -25,6 +25,10 @@ internal static class LibplctagStatusTranslation Status.ErrorTooSmall => AbStatus.TooSmall, // ErrorNotImplemented is the LIBRARY refusing (no I/O); the exception paths raise it as an // AbCipLocalRefusalException so it never counts as "the device answered" (see FromException). + // ASSUMPTION (#737 m3): libplctag reports a failed operation by THROWING, so a NotImplemented reaches the driver + // only through FromException. AbStatus has no "local" member (it is Driver.Ab.Transport's, Lane K), so a + // NotImplemented observed only as a STATUS WORD (GetStatus after a non-throwing op) would still classify as a + // device Unsupported refusal; that combination is not produced by libplctag.NET 1.5.2. Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported, Status.ErrorPartial => AbStatus.Partial, Status.ErrorAbort => AbStatus.Abort, diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs index 33ce02d8..a666239c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs @@ -41,15 +41,34 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime private int? _implicitBit; private byte[]? _path; - // U11: a scalar BOOL authored over a BOOL-array element "Bits[n]" — the bit (n % 32) of the DWORD the request - // addresses (n / 32). Null for every other tag, and cleared (with _path restored to _elementPath) the moment the - // controller shows its BOOL array is element-addressed (#737 I1: a 0xC1 reply, or a refusal, to the DWORD request). + // U11: a scalar BOOL authored over a BOOL-array element "Bits[n]" — the bit (n % 32) of DWORD n / 32 when the + // array is packed. Null for every other tag. private int? _boolElementBit; - // The ORIGINAL element-addressed path ("Bits[n]") while a DWORD-addressed _path is still unconfirmed; null once - // the layout is settled either way. + // Both addressings of such a tag: "Bits[n / 32]" (packed: a Logix BOOL array is stored as 0xD3 DWORDs) and the + // original "Bits[n]" (per-element: a family that answers 0xC1). _path is whichever _boolLayout selects. + private byte[]? _dwordPath; private byte[]? _elementPath; + /// How a BOOL-array element tag's array is laid out, as the controller's REPLY TYPE last showed it. + private enum BoolLayout + { + /// No reply type seen yet (or only refusals / comms faults): request DWORD-addressed, keep asking. + Unsettled, + + /// The controller answered 0xD3: address DWORD n / 32, decode bit n % 32. + Packed, + + /// The controller answered 0xC1: address element n, decode its byte. + PerElement, + } + + // #737 fix round 2 (N1): ONLY a reply type settles (or re-settles) the layout — 0xD3 ⇒ Packed, 0xC1 ⇒ PerElement. + // A refusal or comms fault says nothing about the layout and leaves it as it was, so a tag deployed before the + // online edit that creates its array starts reading correctly once the array exists. A reply whose type disagrees + // with the addressing it was asked under is DISCARDED (never decoded) and re-read under the right addressing. + private BoolLayout _boolLayout; + private ConnectionLease? _lease; private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's private LogixTagClient? _client; @@ -130,10 +149,15 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime && TrySplitBoolArrayElement(name, out var dwordName, out var bit)) { _elementPath = CipPath.EncodeSymbolic(name); - name = dwordName; + _dwordPath = CipPath.EncodeSymbolic(dwordName); _boolElementBit = bit; + _boolLayout = BoolLayout.Unsettled; + path = _dwordPath; + } + else + { + path = CipPath.EncodeSymbolic(name); } - path = CipPath.EncodeSymbolic(name); } catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException) { @@ -161,32 +185,43 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime } /// - public async Task ReadAsync(CancellationToken cancellationToken) + public Task ReadAsync(CancellationToken cancellationToken) => ReadCoreAsync(cancellationToken, mayRelayout: true); + + private async Task ReadCoreAsync(CancellationToken cancellationToken, bool mayRelayout) { var client = Client(); var path = Path(); try { var (type, handle, data) = await client.ReadAsync(path, _elements, cancellationToken, ExpectedElementBytes(_p.AuthoredType)).ConfigureAwait(false); - if (_elementPath is not null) + if (_boolElementBit is not null) { - // First answer to the DWORD-addressed BOOL-array request settles the layout: 0xD3 confirms it; anything - // else means this family addresses BOOL-array ELEMENTS (e.g. a 0xC1 per-element array) — decoding or - // writing element n / 32 there would hit the wrong element, so go back to "Bits[n]" and read again. - if (type != LogixTypeCode.BoolArray) { FallBackToElementPath(); await ReadAsync(cancellationToken).ConfigureAwait(false); return; } - _elementPath = null; + BoolLayout? shown = type == LogixTypeCode.BoolArray ? BoolLayout.Packed + : type == LogixTypeCode.Bool ? BoolLayout.PerElement + : null; // any other type (a mistyped tag): no layout evidence; the type check below reports it + if (shown is { } layout) + { + var askedPacked = ReferenceEquals(path, _dwordPath); + SetBoolLayout(layout); + if ((layout == BoolLayout.Packed) != askedPacked) + { + // The reply is for the OTHER addressing (element n / 32 of a per-element array, or DWORD n of a + // packed one) — not this element. Discard it and ask again under the layout it just showed. + if (mayRelayout) + { + await ReadCoreAsync(cancellationToken, mayRelayout: false).ConfigureAwait(false); + return; + } + throw new AbTransportException(AbStatus.TypeMismatch, + $"'{_p.TagName}': the controller's BOOL-array layout changed between two reads; retrying next read."); + } + } } _data = data; _typeCode = type; _structHandle = handle; _status = IsTypeMismatch(type) ? AbStatus.TypeMismatch : AbStatus.Ok; } - catch (AbTransportException ex) when (_elementPath is not null && AbCipRefusals.IsDeviceRefusal(ex)) - { - // The DWORD index was refused (e.g. out of bounds on an element-addressed array): retry as "Bits[n]". - FallBackToElementPath(); - await ReadAsync(cancellationToken).ConfigureAwait(false); - } catch (AbTransportException ex) { // libplctag reports a failed operation by exception AND leaves the status readable. @@ -195,12 +230,11 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime } } - /// Abandons the DWORD addressing of a BOOL-array element: the original "Bits[n]" path, no bit extraction. - private void FallBackToElementPath() + /// Settles a BOOL-array element tag's layout and selects the matching request path. + private void SetBoolLayout(BoolLayout layout) { - _path = _elementPath; - _elementPath = null; - _boolElementBit = null; + _boolLayout = layout; + _path = layout == BoolLayout.PerElement ? _elementPath : _dwordPath; } /// @@ -236,8 +270,9 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime try { // An unsettled BOOL-array element layout is settled by a read first, so a write never goes to the - // DWORD-addressed element of an element-addressed array (#737 I1). - if (_elementPath is not null) + // DWORD-addressed element of an element-addressed array (#737 I1). A packed layout's write is refused below + // (0xD3 DWORD — it would clobber 31 neighbours); a per-element one writes "Bits[n]" as a BOOL. + if (_boolElementBit is not null && _boolLayout == BoolLayout.Unsettled) { await ReadAsync(cancellationToken).ConfigureAwait(false); path = Path(); @@ -323,9 +358,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime return type switch { - AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) - : offset == 0 && _boolElementBit is int element && _typeCode == LogixTypeCode.BoolArray ? GetBit(element) - : Span(offset, 1)[0] != 0, + AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : DecodeScalarBool(offset), AbCipDataType.SInt => (int)(sbyte)Span(offset, 1)[0], AbCipDataType.USInt => (int)Span(offset, 1)[0], AbCipDataType.Int => (int)BinaryPrimitives.ReadInt16LittleEndian(Span(offset, 2)), @@ -471,6 +504,22 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime catch (Exception) { /* ForTest only; Dispose must not throw */ } } + /// + /// A scalar BOOL. A packed (0xD3) reply is decoded ONLY as the element's own bit of the DWORD the packed layout + /// addressed; any other 0xD3 reply (no element bit, or not settled packed) is refused rather than decoded as + /// "byte 0 != 0" — that value would be some other bit, published as Good (#737 N1). + /// + private bool DecodeScalarBool(int offset) + { + if (offset == 0 && _typeCode == LogixTypeCode.BoolArray) + { + if (_boolElementBit is int element && _boolLayout == BoolLayout.Packed) return GetBit(element); + throw new AbTransportException(AbStatus.TypeMismatch, + $"'{_p.TagName}' is a scalar BOOL but the controller answered a packed BOOL-array DWORD (0xD3) with no element bit to select."); + } + return Span(offset, 1)[0] != 0; + } + /// /// Splits a BOOL-array element reference Name[n] (one non-negative subscript, last segment) into the /// DWORD it lives in (Name[n / 32]) and its bit within that DWORD (n % 32). A Logix BOOL array diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs index 9a1458c4..db68d1f5 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs @@ -128,4 +128,48 @@ public sealed class AbCipWriteHealthTests drv.GetHealth().State.ShouldBe(DriverState.Healthy); drv.GetHealth().LastError.ShouldBeNull(); } + + /// + /// m1: many concurrent failing writes leave the write-owned marker naming the installed Degraded, so one good + /// write still clears it. + /// + [Fact] + public async Task Concurrent_failing_writes_then_a_good_write_clear_the_write_side_Degraded() + { + var fail = true; + var (drv, _) = await BuildAsync(p => fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p)); + for (var round = 0; round < 20; round++) + { + fail = true; + await Task.WhenAll(Enumerable.Range(0, 8).Select(_ => Task.Run(() => + drv.WriteAsync([new WriteRequest("D", 1), new WriteRequest("I", 2)], CancellationToken.None)))); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + + fail = false; + (await drv.WriteAsync([new WriteRequest("D", 3)], CancellationToken.None))[0].StatusCode + .ShouldBe(AbCipStatusMapper.Good); + drv.GetHealth().State.ShouldBe(DriverState.Healthy, $"round {round}"); + } + } + + /// + /// M3: a refusal-only read batch in between (no read comms fault happened) keeps the Degraded write-owned, so a + /// good write still clears it. + /// + [Fact] + public async Task A_refusal_only_read_between_keeps_the_Degraded_write_owned() + { + var fail = true; + var (drv, _) = await BuildAsync(p => p.TagName == "Broken" + ? new FakeAbCipTag(p) { Status = (int)ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.AbStatus.NotFound } + : fail ? new ThrowingWriteTag(p) : new RealEncodingTag(p)); + await drv.WriteAsync([new WriteRequest("D", 5)], CancellationToken.None); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + (await drv.ReadAsync(["Broken"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.BadNodeIdUnknown); + + fail = false; + await drv.WriteAsync([new WriteRequest("D", 6)], CancellationToken.None); + + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs index 1d514c91..028d6e27 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/NativeAbCipTagRuntimeTests.cs @@ -159,6 +159,108 @@ public sealed class NativeAbCipTagRuntimeTests ch.Sent[^1].ShouldBe(Convert.FromHexString("4D04" + "910442697473" + "2825" + "C100" + "0100" + "00")); // Bits[37] } + /// A channel whose reply can be switched mid-test (read replies; writes always succeed). + private static FakeCipChannel Switchable(Func readReply) => new() + { + Respond = req => Task.FromResult(Convert.FromHexString(req[0] == 0x4D ? "CD000000" : readReply())), + }; + + private const string Dword1 = "5204" + "910442697473" + "2801" + "0100" + "00000000"; // Bits[1] (DWORD 37/32) + private const string Element37 = "5204" + "910442697473" + "2825" + "0100" + "00000000"; // Bits[37] (element) + + /// + /// #737 fix round 2 (N1): a REFUSAL of the DWORD request says nothing about the layout — it must not settle it. + /// Tag deployed before the online edit that creates Bits : BOOL[2048]: the init read is refused NotFound; + /// once the array exists the controller answers 0xD3, and the read must address DWORD n/32 and decode bit n%32 + /// (the round-1 code had permanently fallen back to Bits[37] = DWORD 37 and decoded byte 0 as Good). + /// + /// The refusal reply the DWORD request first gets. + [Theory] + [InlineData("D2000400")] // NotFound + [InlineData("D2000F00")] // NotAllowed + [InlineData("D2000A00")] // OutOfBounds + public async Task A_refusal_leaves_the_BOOL_layout_unsettled_and_a_later_DWORD_reply_decodes_the_right_bit(string refusal) + { + var reply = refusal; + var ch = Switchable(() => reply); + using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool)); + await rt.InitializeAsync(CancellationToken.None); // refused: completes with the refusal status + rt.GetStatus().ShouldNotBe((int)AbStatus.Ok); + ch.Sent[^1].ShouldBe(Convert.FromHexString(Dword1)); // never fell back to the element path + + reply = "D2000000D300" + "20000000"; // bit 5 of DWORD 1 = element 37 + await rt.ReadAsync(CancellationToken.None); + + ch.Sent[^1].ShouldBe(Convert.FromHexString(Dword1)); + rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true); + } + + /// + /// N1 positive control: a refusal, then a 0xC1 reply, settles PER-ELEMENT — the n/32 element that came back is + /// discarded and Bits[n] is re-read and decoded. + /// + [Fact] + public async Task A_refusal_then_a_per_element_reply_settles_the_element_path() + { + var reply = "D2000A00"; + var ch = Switchable(() => reply); + using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool)); + await rt.InitializeAsync(CancellationToken.None); + + reply = "D2000000C10001"; + await rt.ReadAsync(CancellationToken.None); + + ch.Sent[^2].ShouldBe(Convert.FromHexString(Dword1)); // asked DWORD-addressed, got a plain BOOL… + ch.Sent[^1].ShouldBe(Convert.FromHexString(Element37)); // …so element 37 is re-read and decoded + rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(true); + } + + /// + /// N1: a layout that changes after it was settled (an online edit re-types the array) re-settles from the reply + /// type — it never decodes a DWORD reply to the ELEMENT request (DWORD n, byte 0) as the element's value. + /// + [Fact] + public async Task A_settled_per_element_layout_resettles_when_the_reply_becomes_a_DWORD() + { + var reply = "D2000000C10000"; + var ch = Switchable(() => reply); + using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[36]", AbCipDataType.Bool)); + await rt.InitializeAsync(CancellationToken.None); // settles per-element + + reply = "D2000000D300" + "01000000"; // byte 0 non-zero, bit 4 (element 36) clear + await rt.ReadAsync(CancellationToken.None); + + ch.Sent[^1].ShouldBe(Convert.FromHexString("5204" + "910442697473" + "2801" + "0100" + "00000000")); + rt.DecodeValue(AbCipDataType.Bool, null).ShouldBe(false); // bit 4 of DWORD 1, not "byte 0 != 0" + } + + /// N1 minimum guard: a scalar BOOL with no element bit answered as a packed DWORD is refused, not byte 0. + [Fact] + public async Task A_scalar_BOOL_answered_as_a_packed_DWORD_without_an_element_bit_is_refused() + { + using var rt = await ReadAsync(Replying("D300", "01000000"), Params("Flag", AbCipDataType.Bool)); + (Should.Throw(() => rt.DecodeValue(AbCipDataType.Bool, null))).Status.ShouldBe(AbStatus.TypeMismatch); + } + + /// + /// m4: a runtime whose init read failed on the wire (layout still unsettled) settles the layout by a read BEFORE + /// it writes, so the write goes to the right element of a per-element array. + /// + [Fact] + public async Task A_write_on_an_unsettled_layout_reads_first_and_writes_the_element() + { + var reply = "D2000100"; // 0x01: not a refusal — init throws, layout unsettled + var ch = Switchable(() => reply); + using var rt = NativeAbCipTagRuntime.ForTest(ch, Params("Bits[37]", AbCipDataType.Bool)); + await Should.ThrowAsync(() => rt.InitializeAsync(CancellationToken.None)); + + reply = "D2000000C10000"; + rt.EncodeValue(AbCipDataType.Bool, null, true); + await rt.WriteAsync(CancellationToken.None); + + ch.Sent[^1].ShouldBe(Convert.FromHexString("4D04" + "910442697473" + "2825" + "C100" + "0100" + "01")); // Bits[37] + } + /// Positive control: a BOOL answered as a plain BOOL (0xC1) still decodes its byte. [Fact] public async Task A_BOOL_element_answered_as_BOOL_decodes_the_byte() From acdf6d83a31026bd4cf75389554951f8f8ef9cbc Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:06:23 -0400 Subject: [PATCH 46/67] fix(runtime): drop value bursts from driver children the host already stopped Review I-1. Context.Stop is asynchronous, so a removed child kept publishing through the rest of the apply handler and its own stop. Those bursts were forwarded after SourceRemoved and re-retained the removed driver's last Good in the mux, where nothing would ever clear it while a replay subscriber held the ref (a removed driver never publishes again, and its PostStop Bad fan resolves no refs once the index is rebuilt without it). StopChild and HandleRestartDriver now retire the child (_retiredChildren + Context.Watch) before stopping it. ForwardBatchToMux drops a burst whose Sender is retired, and Terminated (handled in Steady, Applying and Stale) removes the entry. Akka delivers Terminated after the watched child's last message, so the set is bounded and a live incarnation is never filtered. ConnectivityChanged from a retired child is deliberately not filtered, because the respawn path relies on its Bad fan. Tests (review m-3 folded in): a stale burst spoofed as the removed child, held alive in PostStop by a new RecordingSubscribableDriver.ShutdownGate, no longer re-retains (red with the guard disabled). New cases cover a config-change respawn and an operator Restart, each with the new driver's value replaying as the positive control. The arrange step now waits for the real subscription set and fishes for both drivers' values at once (two sequential fishes could discard the other burst). Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../Drivers/DriverHostActor.cs | 47 ++++- .../DriverHostActorSourceRemovedTests.cs | 199 +++++++++++++----- .../Drivers/StubDrivers.cs | 9 +- 3 files changed, 203 insertions(+), 52 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index 31f13546..b751214f 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -205,6 +205,17 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS /// Node-wide script run-guard options for the scripted-alarm engine; null ⇒ compiled defaults. private readonly ScriptingOptions? _scripting; + + /// + /// Driver children this host has stopped (, an operator Restart) and is watching + /// until they terminate. A value burst from one of them is DROPPED in : + /// Context.Stop is asynchronous, so the old child keeps publishing for the rest of the apply + /// handler and its own stop, and forwarding those bursts after would + /// re-retain a removed driver's last Good in the dependency mux indefinitely. Akka delivers + /// after the last message the watched child sent here, so removing the entry + /// there bounds the set without ever dropping a live incarnation's values. + /// + private readonly HashSet _retiredChildren = new(); private readonly IActorRef? _scriptedAlarmHostOverride; private readonly ILoggingAdapter _log = Context.GetLogger(); @@ -1516,6 +1527,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS Receive(HandleApplyWedged); Receive(HandleGetDiagnostics); Receive(ForwardBatchToMux); + Receive(OnRetiredChildTerminated); Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); Receive(HandleCommsRecoveryAudit); @@ -1578,6 +1590,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS Receive(HandleApplyWedged); Receive(HandleGetDiagnostics); Receive(ForwardBatchToMux); + Receive(OnRetiredChildTerminated); Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); Receive(HandleCommsRecoveryAudit); @@ -1622,6 +1635,16 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS /// private void ForwardBatchToMux(DriverInstanceActor.AttributeValueBatchPublished msg) { + // A burst from an incarnation this host already stopped: forwarding it would undo the SourceRemoved + // eviction (see _retiredChildren). Its ConnectivityChanged is NOT filtered — the respawn path relies on + // that Bad fan to cover the gap. + if (_retiredChildren.Contains(Sender)) + { + _log.Debug("DriverHost {Node}: dropped a {Count}-item value burst from a stopped incarnation of {Id}", + _localNode, msg.Items.Count, msg.DriverInstanceId); + return; + } + // One envelope per burst; the mux filters per item (it registered a batch receive). _dependencyMux?.Tell(msg); @@ -2517,6 +2540,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS Receive(m => _log.Debug("DriverHost {Node}: dropping a {Count}-item value burst from {Id} while Stale (no address space)", _localNode, m.Items.Count, m.DriverInstanceId)); + Receive(OnRetiredChildTerminated); Receive(m => _log.Debug("DriverHost {Node}: dropping a native alarm transition ({Cond}) from {Id} while Stale", _localNode, m.Args.ConditionId, m.DriverInstanceId)); @@ -3832,6 +3856,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS { if (!_children.TryGetValue(driverInstanceId, out var entry)) return; StopDependencyAdapter(driverInstanceId); + RetireChild(entry.Actor); Context.Stop(entry.Actor); _children.Remove(driverInstanceId); EvictRetainedValues(driverInstanceId); @@ -3863,15 +3888,28 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS /// values travel, so the eviction is ordered ahead of them. /// /// - /// Residual: a burst the old child enqueued on this host just before it stopped can still be forwarded - /// after the eviction and re-retain a value. The window is one mailbox drain; a comms-loss fan or the - /// next real publish overwrites it, and the mux's grace sweep bounds it once no replay subscriber holds - /// the ref. + /// The stopped child keeps publishing until it actually terminates, and for a REMOVED driver nothing + /// would ever overwrite a value re-retained after this eviction (it never publishes again, and its + /// PostStop Bad fan resolves no refs once the apply has rebuilt the index without it). So the caller + /// also s it, and drops every burst from a + /// retired child. /// /// The driver instance that ended. private void EvictRetainedValues(string driverInstanceId) => _dependencyMux?.Tell(new DependencyMuxActor.SourceRemoved(driverInstanceId)); + /// Marks a child this host is about to stop as retired and watches it until it terminates. + /// The child being stopped. + private void RetireChild(IActorRef child) + { + _retiredChildren.Add(child); + Context.Watch(child); + } + + /// A retired child has terminated; nothing more can arrive from it. + /// The termination notice. + private void OnRetiredChildTerminated(Terminated msg) => _retiredChildren.Remove(msg.ActorRef); + private static string ActorNameFor(string driverInstanceId, long generation) { // Akka actor names cannot contain '/', ':', or whitespace. Mangle defensively. The monotonic @@ -3917,6 +3955,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS // Stop the existing child actor — DriverInstanceActor.PostStop calls ShutdownAsync. StopDependencyAdapter(msg.DriverInstanceId); + RetireChild(entry.Actor); Context.Stop(entry.Actor); _children.Remove(msg.DriverInstanceId); // An operator Restart builds a NEW driver object, so the old one's retained values are evicted like a diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorSourceRemovedTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorSourceRemovedTests.cs index a7a49f7a..0e7e48ac 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorSourceRemovedTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorSourceRemovedTests.cs @@ -1,9 +1,13 @@ using Akka.Actor; using Akka.TestKit; +using Microsoft.EntityFrameworkCore; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Commons.Engines; +using ZB.MOM.WW.OtOpcUa.Commons.Messages.Admin; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration; using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; @@ -14,8 +18,10 @@ namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; /// The dependency mux retains a ref's last value for replay-opting subscribers and evicts a driver's values on /// . Before this, nothing in production sent that message, so a /// driver removed by a deploy kept its last Good in the mux and replayed it to the next replay-opting -/// subscriber as if it were current. DriverHostActor.StopChild now sends it. A supervision restart -/// re-uses the same driver and must NOT send it. All through a REAL mux and real driver children. +/// subscriber as if it were current. The host now sends it wherever a driver OBJECT ends (deploy removal, +/// config-change respawn, operator Restart), never on a supervision restart, and drops value bursts from the +/// child incarnations it has already stopped so a late burst cannot re-retain what was evicted. All through a +/// REAL mux and real driver children. /// public sealed class DriverHostActorSourceRemovedTests : RuntimeActorTestBase { @@ -32,88 +38,187 @@ public sealed class DriverHostActorSourceRemovedTests : RuntimeActorTestBase ("DI-2", "Modbus2", "dev2", "flow", "Double", false), ]; + private IActorRef _host = ActorRefs.Nobody; + private IActorRef _mux = ActorRefs.Nobody; + private TestProbe _coordinator = null!; + private TestProbe _holder = null!; + private RecordingSubscribableDriverFactory _factory = null!; + private IDbContextFactory _db = null!; + /// Spawns a host over a real mux with two enabled drivers, waits for both children to subscribe, /// registers a replay-opting holder (so the mux retains both refs) and publishes one value per driver. - private (IActorRef Host, TestProbe Coordinator, RecordingSubscribableDriverFactory Factory) ArrangeTwoDriversWithRetainedValues( - Akka.TestKit.TestProbe holder) + private void ArrangeTwoDriversWithRetainedValues() { - var db = NewInMemoryDbFactory(); - _db = db; - var revA = V3ArtifactSeeder.SeedV3Deployment(db, RevA, BothDrivers, NoRefs, enabled: true); - var factory = new RecordingSubscribableDriverFactory("Modbus"); - var mux = Sys.ActorOf(DependencyMuxActor.Props()); - var coordinator = CreateTestProbe(); - var host = Sys.ActorOf(DriverHostActor.Props( - db, TestNode, coordinator.Ref, - driverFactory: factory, + _db = NewInMemoryDbFactory(); + var revA = V3ArtifactSeeder.SeedV3Deployment(_db, RevA, BothDrivers, NoRefs, enabled: true); + _factory = new RecordingSubscribableDriverFactory("Modbus"); + _mux = Sys.ActorOf(DependencyMuxActor.Props()); + _coordinator = CreateTestProbe(); + _holder = CreateTestProbe(); + _host = Sys.ActorOf(DriverHostActor.Props( + _db, TestNode, _coordinator.Ref, + driverFactory: _factory, localRoles: new HashSet { "driver" }, - dependencyMux: mux, + dependencyMux: _mux, virtualTagEvaluator: NullVirtualTagEvaluator.Instance, virtualTagHostOverride: CreateTestProbe().Ref)); - V3ArtifactSeeder.Dispatch(host, coordinator, revA, RevA); - AwaitAssert(() => factory.Created.Count.ShouldBe(2), PresenceBudget); - foreach (var d in factory.Created) AwaitAssert(() => d.SubscribeCount.ShouldBeGreaterThanOrEqualTo(1), PresenceBudget); + V3ArtifactSeeder.Dispatch(_host, _coordinator, revA, RevA); + AwaitAssert(() => _factory.Created.Count.ShouldBe(2), PresenceBudget); + // The spawn hands a child the PREVIOUS apply's set (empty on the first deploy) and the bulk push then + // re-subscribes with the real one; a value fired before that lands is lost, so wait for the real set. + AwaitSubscribed("DI-1", Path1); + AwaitSubscribed("DI-2", Path2); - mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { Path1, Path2 }, holder.Ref, ReplayRetained: true)); - Driver(factory, "DI-1").FireDataChange(Path1, 11.0); - Driver(factory, "DI-2").FireDataChange(Path2, 22.0); - var seen = new HashSet(); - while (seen.Count < 2) - foreach (var item in holder.ExpectMsg(PresenceBudget).Items) - seen.Add(item.TagId); - - _mux = mux; - return (host, coordinator, factory); + _mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { Path1, Path2 }, _holder.Ref, ReplayRetained: true)); + Driver("DI-1").FireDataChange(Path1, 11.0); + Driver("DI-2").FireDataChange(Path2, 22.0); + // One fish for both: the two drivers' bursts arrive in either order, and fishing for one at a time would + // discard the other. + var pending = new HashSet(StringComparer.Ordinal) { Path1, Path2 }; + _holder.FishForMessage(b => + { + foreach (var i in b.Items) + if ((i.TagId == Path1 && Equals(i.Value, 11.0)) || (i.TagId == Path2 && Equals(i.Value, 22.0))) + pending.Remove(i.TagId); + return pending.Count == 0; + }, PresenceBudget); } - private IActorRef _mux = ActorRefs.Nobody; - private Microsoft.EntityFrameworkCore.IDbContextFactory _db = null!; + /// Waits until the holder has been delivered for . + private void AwaitHolder(string path, object value) => + _holder.FishForMessage( + b => b.Items.Any(i => i.TagId == path && Equals(i.Value, value)), PresenceBudget); - private static RecordingSubscribableDriver Driver(RecordingSubscribableDriverFactory f, string id) => - f.Created.Last(d => d.DriverInstanceId == id); + private void AwaitSubscribed(string driverId, string path) => + AwaitAssert(() => (Driver(driverId).LastSubscribedRefs ?? []).ShouldContain(path), PresenceBudget); - /// A fresh replay-opting subscriber for both refs; returns the refs it was replayed. - private string[] ReplayedRefs() + private RecordingSubscribableDriver Driver(string id) => _factory.Created.Last(d => d.DriverInstanceId == id); + + /// A fresh replay-opting subscriber for both refs; returns what it was replayed, keyed by ref. + private Dictionary Replayed() { var probe = CreateTestProbe(); _mux.Tell(new DependencyMuxActor.RegisterInterest(new[] { Path1, Path2 }, probe.Ref, ReplayRetained: true)); return probe.ExpectMsg(PresenceBudget) - .Items.Select(i => i.TagId).OrderBy(x => x, StringComparer.Ordinal).ToArray(); + .Items.ToDictionary(i => i.TagId, StringComparer.Ordinal); + } + + /// The live child actor for (named drv-{id}-g{generation}). + private IActorRef ChildOf(string driverId) + { + var probe = CreateTestProbe(); + Sys.ActorSelection(_host.Path.ToStringWithoutAddress() + "/drv-" + driverId + "-g*").Tell(new Identify(driverId), probe.Ref); + return probe.ExpectMsg(PresenceBudget).Subject.ShouldNotBeNull(); + } + + private static void ShouldNotBeTheOldGood(Dictionary replay, string path, object oldValue) + { + if (!replay.TryGetValue(path, out var item)) return; + (Equals(item.Value, oldValue) && OpcUaStatus.IsGood(item.StatusCode)) + .ShouldBeFalse($"the stopped incarnation's last Good for {path} was replayed as current"); } [Fact] public void A_driver_removed_by_a_deploy_is_no_longer_replayed_while_a_still_present_driver_is() { - var holder = CreateTestProbe(); - var (host, coordinator, _) = ArrangeTwoDriversWithRetainedValues(holder); + ArrangeTwoDriversWithRetainedValues(); // Positive control before the removal: both values replay. - ReplayedRefs().ShouldBe(new[] { Path2, Path1 }.OrderBy(x => x, StringComparer.Ordinal).ToArray()); + Replayed().Keys.ShouldBe(new[] { Path1, Path2 }, ignoreOrder: true); // Deploy B drops DI-1 (StopChild). - var revB = V3ArtifactSeeder.SeedV3Deployment( - _db, RevB, [("DI-2", "Modbus2", "dev2", "flow", "Double", false)], NoRefs, enabled: true); - V3ArtifactSeeder.Dispatch(host, coordinator, revB, RevB); + var revB = V3ArtifactSeeder.SeedV3Deployment(_db, RevB, [("DI-2", "Modbus2", "dev2", "flow", "Double", false)], NoRefs, enabled: true); + V3ArtifactSeeder.Dispatch(_host, _coordinator, revB, RevB); AwaitAssert( - () => ReplayedRefs().ShouldBe(new[] { Path2 }, "DI-1 was removed: its last Good must not replay; DI-2's must"), + () => Replayed().Keys.ToArray().ShouldBe(new[] { Path2 }, "DI-1 was removed: its last Good must not replay; DI-2's must"), PresenceBudget); } + /// Review I-1: a burst the removed child sent after the eviction (it stops asynchronously, so it keeps + /// publishing through the rest of the apply) must be dropped by the host, not forwarded to re-retain the value. + [Fact] + public void A_late_burst_from_a_removed_child_does_not_re_retain_its_value() + { + ArrangeTwoDriversWithRetainedValues(); + var oldChild = ChildOf("DI-1"); + // Hold the removed child in its PostStop so it has NOT terminated when the stale burst lands; a terminated + // actor cannot send, and the host forgets a retired child on Terminated. + var gate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + Driver("DI-1").ShutdownGate = gate; + + var revB = V3ArtifactSeeder.SeedV3Deployment(_db, RevB, [("DI-2", "Modbus2", "dev2", "flow", "Double", false)], NoRefs, enabled: true); + V3ArtifactSeeder.Dispatch(_host, _coordinator, revB, RevB); + + // The stale burst, sent AS the stopped child. + _host.Tell(ValuePublishes.Single("DI-1", Path1, 11.0, OpcUaStatus.Good, DateTime.UtcNow), oldChild); + // Positive control / ordering fence: a live driver's value sent after it is delivered, so the stale burst + // has been processed by the host by the time we look. + Driver("DI-2").FireDataChange(Path2, 23.0); + AwaitHolder(Path2, 23.0); + + Replayed().Keys.ToArray().ShouldBe(new[] { Path2 }, "the removed child's late burst re-retained its last Good"); + gate.TrySetResult(); + } + + /// Review m-3: a config edit respawns the driver (stop + spawn); the OLD object's value is evicted, and + /// the NEW driver's value replays (positive control). + [Fact] + public void A_config_change_respawn_evicts_the_old_value_and_the_new_drivers_value_replays() + { + ArrangeTwoDriversWithRetainedValues(); + + // Adding a tag to DI-1 changes its merged DriverConfig, which respawns it. + var revB = V3ArtifactSeeder.SeedV3Deployment(_db, RevB, + [.. BothDrivers, ("DI-1", "Modbus", "dev1", "temp", "Double", false)], NoRefs, enabled: true); + V3ArtifactSeeder.Dispatch(_host, _coordinator, revB, RevB); + AwaitAssert(() => _factory.Created.Count.ShouldBe(3), PresenceBudget); + var respawned = Driver("DI-1"); + // The spawn hands the child the PREVIOUS apply's set, then the bulk push re-subscribes with the new one + // (which names the added tag); a value fired between the two can be lost, so wait for the final set. + AwaitAssert(() => (respawned.LastSubscribedRefs ?? []).ShouldContain("Plant/Modbus/dev1/temp"), PresenceBudget); + + ShouldNotBeTheOldGood(Replayed(), Path1, 11.0); + + respawned.FireDataChange(Path1, 33.0); + AwaitHolder(Path1, 33.0); + var replay = Replayed(); + replay[Path1].Value.ShouldBe(33.0, "the respawned driver's own value replays"); + replay[Path2].Value.ShouldBe(22.0, "the untouched driver is unaffected"); + } + + /// Review m-3: an operator Restart builds a new driver object; the old one's value is evicted and the + /// new one's replays. + [Fact] + public void An_operator_restart_evicts_the_old_value_and_the_new_drivers_value_replays() + { + ArrangeTwoDriversWithRetainedValues(); + + _host.Tell(new RestartDriver("c1", "DI-1", "tester", Guid.NewGuid())); + AwaitAssert(() => _factory.Created.Count.ShouldBe(3), PresenceBudget); + var restarted = Driver("DI-1"); + AwaitSubscribed("DI-1", Path1); + + ShouldNotBeTheOldGood(Replayed(), Path1, 11.0); + + restarted.FireDataChange(Path1, 44.0); + AwaitHolder(Path1, 44.0); + Replayed()[Path1].Value.ShouldBe(44.0); + } + [Fact] public void A_supervision_restart_does_not_evict_the_drivers_retained_value() { - var holder = CreateTestProbe(); - var (_, _, factory) = ArrangeTwoDriversWithRetainedValues(holder); - var di1 = Driver(factory, "DI-1"); + ArrangeTwoDriversWithRetainedValues(); + var di1 = Driver("DI-1"); // Crash DI-1's child: Akka restarts it around the SAME driver object, and the host reprovisions it. di1.FireMalformedDataChange(Path1); AwaitAssert(() => di1.InitializeCount.ShouldBe(2), PresenceBudget); - factory.Created.Count.ShouldBe(2, "a supervision restart re-uses the driver; no new one is created"); + _factory.Created.Count.ShouldBe(2, "a supervision restart re-uses the driver; no new one is created"); - ReplayedRefs().ShouldBe( - new[] { Path2, Path1 }.OrderBy(x => x, StringComparer.Ordinal).ToArray(), + var replay = Replayed(); + replay.Keys.ShouldBe(new[] { Path1, Path2 }, ignoreOrder: true, "a restart is the same driver instance: its last value stays replayable"); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs index 627a1c38..ea0cfaef 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/StubDrivers.cs @@ -279,11 +279,18 @@ internal sealed class RecordingSubscribableDriver : IDriver, ISubscribable, IAla /// public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask; + /// + /// When set, awaits it (the actor bounds the wait), which keeps a stopping child + /// ALIVE in its PostStop for as long as the test wants — the window in which a stopped child can still + /// have messages reaching its parent. + /// + public TaskCompletionSource? ShutdownGate { get; set; } + /// public Task ShutdownAsync(CancellationToken cancellationToken) { Interlocked.Increment(ref _shutdownCount); - return Task.CompletedTask; + return ShutdownGate?.Task ?? Task.CompletedTask; } /// From a8284f499e287c70c89eafc1d34a8129baffbbe1 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:08:44 -0400 Subject: [PATCH 47/67] fix(opcua): scope the deferred-write replay to nodes the apply created (Lane B fix round 2) N1: a full alarm-state snapshot also sets the condition's Quality, so it now supersedes an earlier quality-only write for the same id in the deferred buffer; a quality arriving after the state is kept and replays after it. N2 / M1: the replay is not inert on a node the apply KEPT - an SDK-applied Acknowledge/Confirm/Shelve or an optimistic client Write in the window would be rolled back (with spurious Part 9 events). The replay is now scoped to nodes the apply created: variables the applier ensured during this apply (AddressSpaceApplier.BeginRecordingEnsuredVariables) or every variable after a full rebuild; conditions in the applied composition but not the previous one (also on a rebuild, which carries a kept condition's state over). Option (b), a sink that reports whether a live write landed, needs the Commons DeferredAddressSpaceSink seam outside this lane (follow-up F7). M2: the benchmark remark now matches its Category=Benchmark trait. M3: TestHooks and the seam-carrying constructor are internal; PropsForTests produces the actor through an IIndirectActorProducer. M4: pinned replay-nothing-and-drop-the-buffer after a failed plan. M5: PreRestart answers every queued rebuild requester (degraded). Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AddressSpaceApplier.cs | 26 +- .../OpcUa/OpcUaPublishActor.cs | 162 ++++++++++-- .../NodeManagerLockTimeBenchmarkTests.cs | 3 +- .../OpcUaPublishActorDeferredWriteTests.cs | 235 +++++++++++++++++- 4 files changed, 400 insertions(+), 26 deletions(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index 9d42e409..bb0f2752 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -1153,10 +1153,34 @@ public sealed class AddressSpaceApplier /// true when the variable was ensured; false when the sink threw. private bool SafeEnsureVariable(string nodeId, string? parentNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { - try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar); return true; } + try + { + _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar); + _ensuredVariables?.Add((realm, nodeId)); + return true; + } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; } } + /// While non-null, every variable this applier ensures (realm, NodeId). See . + private HashSet<(AddressSpaceRealm, string)>? _ensuredVariables; + + /// + /// Starts recording every variable the following materialise passes ensure, until + /// . The publish actor scopes the replay of writes it deferred while a + /// plan was in flight to these (a surgical apply ensures only its delta), so a variable the apply kept is not + /// rewritten. Single-caller (the publish actor's thread), like the rest of the apply. + /// + /// The live set the recording fills. + public IReadOnlySet<(AddressSpaceRealm, string)> BeginRecordingEnsuredVariables() + { + _ensuredVariables = new HashSet<(AddressSpaceRealm, string)>(); + return _ensuredVariables; + } + + /// Stops the recording started (the returned set is kept). + public void EndRecordingEnsuredVariables() => _ensuredVariables = null; + // The tag/vtag surgical-eligibility predicates moved to AddressSpaceChangeClassifier so the // whole delta classification is one pure, table-testable unit; the applier references them there (and the // classifier's Classify names the routing). The `with`-expression whitelist technique is preserved there diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 833525a0..94f2f24d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -146,17 +146,17 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private sealed record PlanFailed(object Incarnation, RebuildAddressSpace Request, IActorRef Requester, Exception Error); /// Test seams for the off-thread plan step. Production passes none. - public sealed class TestHooks + internal sealed class TestHooks { /// Runs on the thread pool at the start of every plan step (a test blocks it to hold a plan in flight). - public Action? BeforePlan { get; init; } + internal Action? BeforePlan { get; init; } /// Replaces (a test makes it throw). internal Func? ParseAuthority { get; init; } } /// The default bound on : distinct (realm, id) keys held while a plan is in flight. - public const int DefaultDeferredWriteCapacity = 250_000; + internal const int DefaultDeferredWriteCapacity = 250_000; /// Identity of THIS actor instance (see ). private readonly object _incarnation = new(); @@ -178,6 +178,32 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// private readonly DeferredWrites _deferred; + /// + /// Review fix round 2 (N2 / M1) — WHICH deferred writes the next replay may land: only the ones whose live write + /// could not have, i.e. onto nodes the apply just CREATED. A node the apply kept took the live write already, + /// and replaying it is not inert: a client changed it in the window (an SDK-applied Acknowledge / Confirm / + /// Shelve on a condition, an optimistic Write on a variable) and the replay would roll that back and fire + /// spurious Part 9 events. Set by , consumed by . + /// + /// Variables: after a FULL rebuild every one (each was recreated, its live write landed on the + /// discarded node); otherwise exactly those the apply asked the sink to ensure (the applier records them). + /// Conditions: those in the applied composition but not the previous one — on a rebuild too, because the + /// node manager carries a kept condition's state over to its re-materialised instance. + /// + /// (Precise "did the live write land" would need the sink to report it; the production sink chain runs through + /// Commons' DeferredAddressSpaceSink, outside this lane — follow-up F7.) + /// + private ReplayScope _replayScope = ReplayScope.Nothing; + + private sealed record ReplayScope( + bool AllVariables, + IReadOnlySet<(AddressSpaceRealm, string)> Variables, + IReadOnlySet<(AddressSpaceRealm, string)> Conditions) + { + private static readonly IReadOnlySet<(AddressSpaceRealm, string)> None = new HashSet<(AddressSpaceRealm, string)>(); + public static readonly ReplayScope Nothing = new(false, None, None); + } + /// True while a rebuild's parse / plan step is running off-thread. Rebuilds are applied strictly in /// arrival order — the plan is a diff against , which only the apply advances — so a /// rebuild arriving meanwhile waits in ; value, status and alarm writes do not — they @@ -357,9 +383,19 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers int deferredWriteCapacity = DefaultDeferredWriteCapacity, NodeId? localNode = null, IDataPlaneAuthoritySink? dataPlaneAuthority = null) => - Akka.Actor.Props.Create(() => new OpcUaPublishActor( + // CreateBy, not Create(expression): Akka's expression path activates through a PUBLIC constructor, and the + // seam-carrying constructor is internal so the test seams stay off the public API (review M3). + Akka.Actor.Props.CreateBy(new TestProducer(() => new OpcUaPublishActor( sink, NullServiceLevelPublisher.Instance, false, localNode, null, applier, null, null, null, null, - false, dataPlaneAuthority, null, deferredWriteCapacity, hooks)); + false, dataPlaneAuthority, null, deferredWriteCapacity, hooks))); + + /// Produces the actor through its internal (seam-carrying) constructor, for . + private sealed class TestProducer(Func create) : IIndirectActorProducer + { + public Type ActorType => typeof(OpcUaPublishActor); + public ActorBase Produce() => create(); + public void Release(ActorBase actor) { } + } /// Initializes a new instance of the class. /// The OPC UA address space sink. @@ -383,8 +419,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// ServiceLevel DB-reachability input is retired (DbReachable treated constant-true, only /// staleness demotes). No is wired on such a node. /// The optional sink that receives the API keys and ACL grants carried by each applied artifact. - /// The bound on distinct nodes whose writes are held for replay while a rebuild plan is in flight. - /// Test seams for the off-thread plan step; null in production. public OpcUaPublishActor( IOpcUaAddressSpaceSink sink, IServiceLevelPublisher serviceLevel, @@ -398,9 +432,31 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers TimeSpan? healthTickInterval = null, bool dbLess = false, IDataPlaneAuthoritySink? dataPlaneAuthority = null, - Func? utcNow = null, - int deferredWriteCapacity = DefaultDeferredWriteCapacity, - TestHooks? hooks = null) + Func? utcNow = null) + : this(sink, serviceLevel, subscribeRedundancyTopic, localNode, dbFactory, applier, dbHealthProbe, staleWindow, + probeFreshnessWindow, healthTickInterval, dbLess, dataPlaneAuthority, utcNow, DefaultDeferredWriteCapacity, hooks: null) + { + } + + /// The full constructor: also the deferred-write bound and the test seams (internal — tests only). + /// The bound on distinct nodes whose writes are held for replay while a rebuild plan is in flight. + /// Test seams for the off-thread plan step; null in production. + internal OpcUaPublishActor( + IOpcUaAddressSpaceSink sink, + IServiceLevelPublisher serviceLevel, + bool subscribeRedundancyTopic, + NodeId? localNode, + IDbContextFactory? dbFactory, + AddressSpaceApplier? applier, + IActorRef? dbHealthProbe, + TimeSpan? staleWindow, + TimeSpan? probeFreshnessWindow, + TimeSpan? healthTickInterval, + bool dbLess, + IDataPlaneAuthoritySink? dataPlaneAuthority, + Func? utcNow, + int deferredWriteCapacity, + TestHooks? hooks) { _deferred = new DeferredWrites(Math.Max(1, deferredWriteCapacity)); _hooks = hooks; @@ -436,6 +492,24 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers Receive(_ => { /* PubSub ack */ }); } + /// + /// Review M5 — a restart discards ; answer each queued requester (degraded) rather + /// than leave it to the deploy timeout. The in-flight plan's requester is answered when its plan pipes back to + /// the new instance (). + /// + /// + protected override void PreRestart(Exception reason, object message) + { + while (_pendingRebuilds.TryDequeue(out var queued)) + { + if (!queued.Request.ReportOutcome) continue; + queued.Requester.Tell(new RebuildCompleted(queued.Request.Correlation, queued.Request.DeploymentId, true, + "the address-space publisher restarted before this rebuild was applied; it was not applied")); + } + + base.PreRestart(reason, message); + } + /// protected override void PreStart() { @@ -774,10 +848,11 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// Re-applies the deferred writes through the sink: values, then status-only annotations, then alarm /// states and alarm qualities. Each pass isolated like the live handlers; not re-metered (counted on arrival). - private void ReplayDeferredWrites() + private void ReplayDeferredWrites(ReplayScope scope) { if (_deferred.IsEmpty) return; - var (values, statuses) = _deferred.VariableWrites(); + var (values, statuses) = _deferred.VariableWrites( + key => scope.AllVariables || scope.Variables.Contains(key)); if (values.Count > 0) { try { _sink.WriteValues(values); } @@ -792,18 +867,20 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers foreach (var alarm in _deferred.Alarms) { + if (!scope.Conditions.Contains((alarm.Realm, alarm.AlarmNodeId))) continue; try { _sink.WriteAlarmCondition(alarm.AlarmNodeId, alarm.State, alarm.TimestampUtc, alarm.Realm); } catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm write threw for {Node}", alarm.AlarmNodeId); } } foreach (var quality in _deferred.AlarmQualities) { + if (!scope.Conditions.Contains((quality.Realm, quality.AlarmNodeId))) continue; try { _sink.WriteAlarmQuality(quality.AlarmNodeId, quality.StatusCode, quality.TimestampUtc, quality.Realm); } catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm-quality write threw for {Node}", quality.AlarmNodeId); } } - _log.Debug("OpcUaPublish: replayed {Values} value, {Statuses} status and {Alarms} alarm writes deferred during a rebuild plan", - values.Count, statuses.Count, _deferred.AlarmCount); + _log.Debug("OpcUaPublish: replayed {Values} value and {Statuses} status writes (of {Held} held) deferred during a rebuild plan", + values.Count, statuses.Count, _deferred.Count); } /// @@ -830,7 +907,6 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers public long Dropped { get; private set; } public int Count => _variables.Count + _alarms.Count + _qualities.Count; public bool IsEmpty => Count == 0; - public int AlarmCount => _alarms.Count + _qualities.Count; public IEnumerable Alarms => _alarms.Values; public IEnumerable AlarmQualities => _qualities.Values; @@ -863,16 +939,26 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers return true; } - public bool Alarm(AlarmStateUpdate msg) => Put(_alarms, (msg.Realm, msg.AlarmNodeId), msg); + /// A full snapshot also sets the condition's Quality (from its StatusCode), so it SUPERSEDES an + /// earlier quality-only write for the same id (review N1): replayed state-then-quality, the stale quality + /// would overwrite the snapshot's. A quality arriving AFTER the state is kept and replays after it. + public bool Alarm(AlarmStateUpdate msg) + { + var key = (msg.Realm, msg.AlarmNodeId); + if (!Put(_alarms, key, msg)) return false; + _qualities.Remove(key); + return true; + } public bool AlarmQuality(AlarmQualityUpdate msg) => Put(_qualities, (msg.Realm, msg.AlarmNodeId), msg); - public (List Values, List Statuses) VariableWrites() + public (List Values, List Statuses) VariableWrites(Func<(AddressSpaceRealm, string), bool> include) { var values = new List(); var statuses = new List(); foreach (var ((realm, id), e) in _variables) { + if (!include((realm, id))) continue; if (e.HasValue) values.Add(new ValueWrite(id, e.Value, e.Status, e.TimestampUtc, realm)); else statuses.Add(new StatusWrite(id, e.Status, e.TimestampUtc, realm)); } @@ -921,7 +1007,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers // Land what arrived while the plan was in flight on the nodes the apply just created — before the answer, // so "applied" means the deferred first values are on the address space too (review fix C1). - ReplayDeferredWrites(); + ReplayDeferredWrites(_replayScope); + _replayScope = ReplayScope.Nothing; if (msg.ReportOutcome) requester.Tell(new RebuildCompleted(msg.Correlation, msg.DeploymentId, outcome.Degraded, outcome.Reason)); @@ -931,6 +1018,44 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// sink passes, announce. Returns whether the apply was degraded, and why. private (bool Degraded, string? Reason) ApplyPlan(PlanReady ready) { + var previous = _lastApplied; + var ensured = _applier!.BeginRecordingEnsuredVariables(); + try + { + var result = ApplyPlanCore(ready, out var rebuilt); + // What this apply created (see _replayScope). A throwing pass still scopes to what it did create. + _replayScope = new ReplayScope(rebuilt, ensured, AddedConditions(previous, ready.Composition)); + return result; + } + finally + { + _applier.EndRecordingEnsuredVariables(); + } + } + + /// The condition ids in that did not materialise: + /// enabled scripted alarms (Uns, by ScriptedAlarmId) and native alarms (Raw, at their RawPath). + private static HashSet<(AddressSpaceRealm, string)> AddedConditions( + AddressSpaceComposition previous, AddressSpaceComposition next) + { + var added = ConditionIds(next); + added.ExceptWith(ConditionIds(previous)); + return added; + + static HashSet<(AddressSpaceRealm, string)> ConditionIds(AddressSpaceComposition c) + { + var ids = new HashSet<(AddressSpaceRealm, string)>(); + foreach (var a in c.EquipmentScriptedAlarms) + if (a.Enabled) ids.Add((AddressSpaceRealm.Uns, a.ScriptedAlarmId)); + foreach (var t in c.RawTags) + if (t.Alarm is not null) ids.Add((AddressSpaceRealm.Raw, t.NodeId)); + return ids; + } + } + + private (bool Degraded, string? Reason) ApplyPlanCore(PlanReady ready, out bool rebuilt) + { + rebuilt = false; var msg = ready.Request; using var span = OtOpcUaTelemetry.StartAddressSpaceRebuildSpan(); span?.SetTag("otopcua.correlation_id", msg.Correlation.ToString()); @@ -981,6 +1106,7 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers } var outcome = _applier!.Apply(plan); + rebuilt = outcome.RebuildCalled; // Sum swallowed per-node materialise failures across every pass together with Apply's own // removal-pass tally (archreview 01/S-1). A degraded apply used to vanish into per-node diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs index 865bef48..7940bd09 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs @@ -20,7 +20,8 @@ namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; /// /// The wall-clock budgets are generous for CI hardware; the before/after numbers are in the commit /// message. P3 is pinned by the bookkeeping's own visit counter, which does not depend on the machine. -/// Kept in the default run on purpose (not a Benchmark category the CI filter would have to learn to exclude): +/// Tagged Category=Benchmark, but no filter excludes that category (CI drops only E2E + LiveIntegration), +/// so it runs by default — on purpose: /// measured after the fix at 5–6 ms (P1) and 58–64 ms (P2) on a loaded dev machine against the 1 s budget — /// ≥ 15× headroom, so it does not flake on slow CI. The flip side: the pre-fix code (75–150 ms / 470–680 ms) also /// fits 1 s at these sizes, so the wall-clock legs guard against gross (order-of-magnitude) regressions only; the diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs index 7b795e3d..82bc6d8a 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs @@ -235,6 +235,196 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase sink.Exists(NewTag).ShouldBeTrue(); } + // ───────────────────── fix round 2: N1 / N2 / M1 / M4 / M5 ───────────────────── + + /// N1 — a full state snapshot carries quality, so it supersedes an EARLIER quality-only write for the + /// same condition (the engine sends a transition with the recovered worst quality and no QualityChanged). + [Fact] + public void A_state_after_a_quality_only_write_supersedes_it_on_replay() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + + actor.Tell(Rebuild(AlarmArtifact())); + actor.Tell(new OpcUaPublishActor.AlarmQualityUpdate("al-1", OpcUaStatus.BadNoCommunication, DateTime.UtcNow, AddressSpaceRealm.Uns)); + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns)); + AwaitAssert(() => sink.SkippedAlarms.Count.ShouldBe(2), PlanBudget); + gate.Set(); + ExpectMsg(PlanBudget); + + sink.AlarmQualityOf("al-1").ShouldBe(OpcUaStatus.Good, "the later snapshot's quality is what live would leave"); + sink.AlarmStateOf("al-1")!.Active.ShouldBeTrue(); + } + + /// N1 positive control — the reverse order: a quality-only write AFTER the state still lands last. + [Fact] + public void A_quality_only_write_after_a_state_still_lands_after_it_on_replay() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var actor = Spawn(sink, gate); + + actor.Tell(Rebuild(AlarmArtifact())); + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns)); + actor.Tell(new OpcUaPublishActor.AlarmQualityUpdate("al-1", OpcUaStatus.BadNoCommunication, DateTime.UtcNow, AddressSpaceRealm.Uns)); + AwaitAssert(() => sink.SkippedAlarms.Count.ShouldBe(2), PlanBudget); + gate.Set(); + ExpectMsg(PlanBudget); + + sink.AlarmQualityOf("al-1").ShouldBe(OpcUaStatus.BadNoCommunication); + } + + /// + /// N2 — a condition the plan KEEPS took the live write already; replaying it would roll back an operator's + /// Acknowledge applied in the window (the SDK mutates the node directly) and fire spurious events. Driven over + /// both apply shapes: a surgical pure-add, and a full rebuild (which carries the condition's state over). + /// + [Theory] + [InlineData(false)] + [InlineData(true)] + public void An_out_of_band_ack_on_a_kept_condition_is_not_reverted_by_the_replay(bool rebuild) + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(AlarmArtifact())); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + sink.ConditionExists("al-1").ShouldBeTrue(); + + gate.Reset(); + // A surgical pure-add (new raw tags) or a full rebuild (an equipment rename) that keeps al-1. + actor.Tell(Rebuild(rebuild ? AlarmArtifact(equipmentName: "Eq-renamed") : AlarmArtifact(rawTags: 2))); + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns)); + AwaitAssert(() => sink.AlarmWritesTo("al-1").ShouldBe(1), PlanBudget); // the live write landed + sink.AcknowledgeOutOfBand("al-1"); // an operator acks in the window + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.SawRebuild.ShouldBe(rebuild, "non-vacuity: the intended apply shape ran"); + sink.AlarmStateOf("al-1")!.Acknowledged.ShouldBeTrue("the operator's ack must survive the apply"); + sink.AlarmWritesTo("al-1").ShouldBe(1, "no replayed write (and so no extra Part 9 event) on a kept condition"); + } + + /// M1 — a variable the surgical plan keeps took the live write; replaying it would revert a client + /// Write applied in the window. Positive control in the same apply: the node the plan ADDS gets its value. + [Fact] + public void A_surgical_apply_replays_values_only_onto_the_variables_it_added() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(RawTagArtifact(1))); + ExpectMsg(PlanBudget); + const string kept = "Plant/Modbus/dev1/T0"; + + gate.Reset(); + actor.Tell(Rebuild(RawTagArtifact(3))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate( + [ + new ValueWrite(kept, 5, OpcUaStatus.Good, at, AddressSpaceRealm.Raw), + new ValueWrite(NewTag, 7, OpcUaStatus.Good, at, AddressSpaceRealm.Raw), + ])); + AwaitAssert(() => sink.ValueOf(kept)!.Value.Value.ShouldBe(5), PlanBudget); + sink.SetValueOutOfBand(kept, 9); // a client Write in the window + + gate.Set(); + ExpectMsg(PlanBudget); + + sink.SawRebuild.ShouldBeFalse("non-vacuity: a surgical apply"); + sink.ValueOf(kept)!.Value.Value.ShouldBe(9, "the kept node's client write is not reverted"); + sink.ValueOf(NewTag)!.Value.Value.ShouldBe(7, "positive control: the added node gets its deferred value"); + } + + /// M1 — a FULL rebuild recreates every variable, so every deferred value replays (the live write landed + /// on a node the rebuild discarded). + [Fact] + public void A_rebuild_replays_every_deferred_value() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(AlarmArtifact(rawTags: 1))); + ExpectMsg(PlanBudget); + const string kept = "Plant/Modbus/dev1/T0"; + sink.Exists(kept).ShouldBeTrue(); + + gate.Reset(); + // An equipment rename is a ChangedEquipment delta ⇒ full rebuild; the raw tag keeps its path. + actor.Tell(Rebuild(AlarmArtifact(equipmentName: "Eq-renamed", rawTags: 1))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate([new ValueWrite(kept, 5, OpcUaStatus.Good, at, AddressSpaceRealm.Raw)])); + AwaitAssert(() => sink.ValueOf(kept)!.Value.Value.ShouldBe(5), PlanBudget); + + gate.Set(); + ExpectMsg(PlanBudget); + + sink.SawRebuild.ShouldBeTrue("non-vacuity: a full rebuild ran"); + sink.ValueOf(kept)!.Value.Value.ShouldBe(5, "the recreated node gets the value its predecessor took"); + } + + /// M4 — a FAILED plan applies nothing, so nothing is replayed, and the buffer is dropped once the + /// pipeline is idle: a later plan that adds the node does not receive a write from the failed window. + [Fact] + public void A_failed_plan_replays_nothing_and_drops_the_buffer() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(RawTagArtifact(2))); + ExpectMsg(PlanBudget); + + gate.Reset(); + actor.Tell(Rebuild("not json"u8.ToArray())); + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate( + [new ValueWrite("Plant/Modbus/dev1/T2", 42, OpcUaStatus.Good, DateTime.UtcNow, AddressSpaceRealm.Raw)])); + AwaitAssert(() => sink.SkippedWrites.ShouldContain("Plant/Modbus/dev1/T2"), PlanBudget); + // Snapshot AFTER the live write (the sink's counter cannot tell a live write after an apply from a replay). + var replayedBefore = sink.ReplayedItems; + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeTrue(); + sink.ReplayedItems.ShouldBe(replayedBefore, "nothing was applied, so nothing is replayed"); + + actor.Tell(Rebuild(RawTagArtifact(3))); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + sink.Exists("Plant/Modbus/dev1/T2").ShouldBeTrue("positive control: the later plan adds it"); + sink.ValueOf("Plant/Modbus/dev1/T2").ShouldBeNull("the failed window's buffer was dropped"); + } + + /// M5 — a restart answers every QUEUED requester (degraded) instead of leaving it to the deploy timeout; + /// the in-flight one is answered by the incarnation check. + [Fact] + public void A_restart_answers_queued_rebuild_requesters() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(false); + var applier = new AddressSpaceApplier(sink, NullLogger.Instance); + var childProps = OpcUaPublishActor.PropsForTests(sink, applier, new OpcUaPublishActor.TestHooks { BeforePlan = () => gate.Wait() }); + var parent = Sys.ActorOf(Props.Create(() => new RestartingParent(childProps))); + parent.Tell(RestartingParent.GetChild.Instance); + var child = ExpectMsg(); + + var inFlight = Rebuild(RawTagArtifact(2)); + var queued = Rebuild(RawTagArtifact(3)); + child.Tell(inFlight); + child.Tell(queued); + child.Tell(Kill.Instance); + + var first = ExpectMsg(PlanBudget); + first.Correlation.ShouldBe(queued.Correlation, "the queued requester is answered at the restart"); + first.Degraded.ShouldBeTrue(); + + gate.Set(); + var second = ExpectMsg(PlanBudget); + second.Correlation.ShouldBe(inFlight.Correlation); + second.Degraded.ShouldBeTrue(); + } + + private static AlarmConditionSnapshot ActiveUnacked(uint quality) => + new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, AlarmShelvingKind.Unshelved, 500, "high", quality); + // ───────────────────────────── helpers ───────────────────────────── private IActorRef Spawn(ExistenceSink sink, ManualResetEventSlim gate) => @@ -264,17 +454,30 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase ScriptedAlarms = Array.Empty(), }); - private static byte[] AlarmArtifact() => + private static byte[] AlarmArtifact(string equipmentName = "Eq", int rawTags = 0) => JsonSerializer.SerializeToUtf8Bytes(new { UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } }, UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } }, Equipment = new[] { - new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = "Eq", MachineCode = "EQ-1" }, + new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = equipmentName, MachineCode = "EQ-1" }, }, - DriverInstances = Array.Empty(), - Tags = Array.Empty(), + RawFolders = rawTags == 0 + ? Array.Empty() + : new object[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + DriverInstances = rawTags == 0 + ? Array.Empty() + : new object[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } }, + Devices = rawTags == 0 + ? Array.Empty() + : new object[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, + TagGroups = Array.Empty(), + Tags = Enumerable.Range(0, rawTags).Select(i => (object)new + { + TagId = $"t-{i}", DeviceId = "dev-1", TagGroupId = (string?)null, Name = $"T{i}", + DataType = "Float", AccessLevel = 0, TagConfig = "{}", + }).ToArray(), ScriptedAlarms = new[] { new @@ -334,6 +537,22 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase private readonly Dictionary _alarmQualities = new(); public ConcurrentQueue SkippedWrites { get; } = new(); + private readonly Dictionary _alarmWrites = new(); + private volatile bool _sawRebuild; + + /// Whether any apply called (the full-rebuild shape). + public bool SawRebuild => _sawRebuild; + public int AlarmWritesTo(string id) { lock (_gate) return _alarmWrites.GetValueOrDefault(id); } + public void SetValueOutOfBand(string id, object? value) + { + lock (_gate) { var c = _values.GetValueOrDefault(id); _values[id] = (value, c.Status, c.At); } + } + + /// What an SDK-applied Part 9 Acknowledge does: mutates the condition directly, no sink write. + public void AcknowledgeOutOfBand(string id) + { + lock (_gate) _alarmStates[id] = _alarmStates[id] with { Acknowledged = true }; + } public ConcurrentQueue SkippedAlarms { get; } = new(); public int ReplayedItems; @@ -378,7 +597,10 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase lock (_gate) { if (!_conditions.Contains(alarmNodeId)) { SkippedAlarms.Enqueue(alarmNodeId); return; } + _alarmWrites[alarmNodeId] = _alarmWrites.GetValueOrDefault(alarmNodeId) + 1; + // The node manager projects the snapshot's StatusCode onto the condition's Quality. _alarmStates[alarmNodeId] = state; + _alarmQualities[alarmNodeId] = state.StatusCode; } } @@ -410,12 +632,13 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase { lock (_gate) { + // Variables are recreated empty; a condition's state is captured and carried over to its + // re-materialised instance (the node manager's rebuild carry-over). _applyClosed = false; + _sawRebuild = true; _variables.Clear(); _conditions.Clear(); _values.Clear(); - _alarmStates.Clear(); - _alarmQualities.Clear(); } } From ecf1e864641a24f89b6230ae1f72bb862ec01e97 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:10:21 -0400 Subject: [PATCH 48/67] fix(s7): lifecycle gate + generation-stamped health and publishes (R07:H5) Overlapping InitializeAsync calls (the actor's fire-and-forget init plus a retry tick) could leak an opened PLC connection and orphan a probe loop: no gate guarded the lifecycle, only _gate for I/O. - _lifecycleGate (the Modbus pattern) serialises Initialize / Reinitialize / Shutdown / Dispose; teardown waits a bounded time, then tears down anyway. - Every initialize and teardown starts a lifecycle generation. Health writes (init, read/write passes, poll errors, shutdown) and the init publish carry it and are dropped once it has moved on, so an abandoned init never publishes over a later shutdown and a shutdown's final write never clobbers a newer init. A superseded init disposes what it opened and throws. - The init publish (options, tag tables, Plc, probe loop) happens under _gate; the tag tables are built fresh and swapped by one reference write instead of being cleared in place under live poll passes. - Read/write passes re-check initialization under _gate, so a pass that waited out a teardown cannot reopen a connection on a shut-down driver. - The semaphores are no longer disposed: Dispose can race a running shutdown. Refs #742 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 439 +++++++++++++----- .../S7LifecycleGateTests.cs | 143 ++++++ 2 files changed, 467 insertions(+), 115 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7LifecycleGateTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index 347a0b19..acade74e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -59,7 +59,7 @@ public sealed class S7Driver _plcFactory = plcFactory ?? new S7PlcFactory(); _logger = logger ?? NullLogger.Instance; _resolver = new EquipmentTagRefResolver( - r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null); + r => TagsByRawPath.TryGetValue(r, out var t) ? t : null); // The polling overlay runs on the shared PollGroupEngine (structural array diff + // drain-before-dispose + capped-exponential failure backoff + caller-token-filtered OCE), // retiring the bespoke S7 poll fork. minInterval 100 ms matches the S7 scan-mailbox floor; @@ -128,13 +128,6 @@ public sealed class S7Driver /// OPC UA StatusCode used for a genuine device fault (CPU error, hardware fault). private const uint StatusBadDeviceFailure = 0x808B0000u; - // v3: the RawPath → definition table. Keyed by RawPath (the v3 tag identity, == def.Name), - // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure factory. - private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal); - // Pre-parsed S7 address per tag, keyed by RawPath (== def.Name). Populated alongside - // _tagsByRawPath at init so a config typo fails fast here rather than as a per-read fault. - private readonly Dictionary _parsedByRawPath = new(StringComparer.Ordinal); - // Resolves a read/write/subscribe fullReference (a RawPath in v3) to a tag definition by a single // authored-table hit; a miss is a miss (the driver surfaces BadNodeIdUnknown, never a parse attempt). private readonly EquipmentTagRefResolver _resolver; @@ -225,19 +218,50 @@ public sealed class S7Driver /// public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { - // InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract - // allows it, and a reconnect retry can overlap a running attempt). Stop the previous probe loop and drop the previous connection - // before they are overwritten below. Deliberately NOT ShutdownAsync: that also drops _poll and the - // subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init). - await ReleaseConnectionAsync().ConfigureAwait(false); - Health = new DriverHealth(DriverState.Initializing, null, null); + // Serialised against every other lifecycle operation: DriverInstanceActor dispatches initialize + // on a fire-and-forget Task.Run and a reconnect retry can start a second attempt while the + // first is still inside OpenAsync. Without the gate attempt A's freshly opened Plc and probe + // loop were overwritten by attempt B and never released (R07:H5) — the Modbus pattern. + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } + } + + /// The lifecycle-gated body of . + /// The driver config. + /// Caller cancellation. + /// A task that completes when the attempt has published, been superseded, or failed. + private async Task InitializeCoreAsync(string driverConfigJson, CancellationToken cancellationToken) + { + // This attempt's generation. Every health write and table/connection publish below is stamped + // with it and dropped once the generation has moved on: an attempt abandoned by its caller can + // still be running when a ShutdownAsync (which only waits a bounded time for the lifecycle + // gate) starts, and it must never publish over it. + var gen = BeginGeneration(); + + // InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract + // allows it). Stop the previous probe loop and drop the previous connection before they are + // replaced below. Deliberately NOT a full teardown: that also drops _poll and the subscriptions, + // which are owned by the shutdown path (the actor re-subscribes after every successful init). + await ReleaseConnectionAsync(gen).ConfigureAwait(false); + TryWriteHealth(gen, new DriverHealth(DriverState.Initializing, null, null)); + + // Re-parse the supplied DriverConfig JSON so a config change delivered through the IDriver + // contract is honoured. An empty / placeholder document (e.g. the "{}" some unit tests pass) + // keeps the constructor-supplied options. Local until published: a superseded attempt must + // not leave its options behind for the live connection to reopen against. + var options = _options; + IS7Plc? plc = null; try { - // Re-parse the supplied DriverConfig JSON so a config change delivered through the - // IDriver contract is honoured. An empty / placeholder document - // (e.g. the "{}" some unit tests pass) keeps the constructor-supplied options. if (HasConfigBody(driverConfigJson)) - _options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); + options = S7DriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); // v3: build the RawPath → definition table from the authored raw tags. Each entry's TagConfig // blob is mapped by the pure factory, which also applies S7TagShapeRules (the address must @@ -246,36 +270,32 @@ public sealed class S7Driver // through ISkippedTagReporter, reads BadNodeIdUnknown), never thrown: these used to be init // guards that faulted every tag on the PLC and made the actor retry the same init forever. // The entry's WriteIdempotent flag is threaded onto the def (it lives on the RawTagEntry). - _tagsByRawPath.Clear(); - _parsedByRawPath.Clear(); - _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry - _skippedTags.BeginRebuild(_options.RawTags.Count); - foreach (var entry in _options.RawTags) + // Built into fresh dictionaries and published by ONE reference swap — a retained poll pass + // reading the old table never sees it half-cleared. + var tags = new Dictionary(StringComparer.Ordinal); + var parsed = new Dictionary(StringComparer.Ordinal); + var skipped = new List<(string RawPath, string Reason)>(); + foreach (var entry in options.RawTags) { if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def, out var shapeRejection) && S7AddressParser.TryParse(def.Address, out var parsedAddress)) { - _tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent }; - _parsedByRawPath[entry.RawPath] = parsedAddress; + tags[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent }; + parsed[entry.RawPath] = parsedAddress; } else { - var reason = shapeRejection ?? "TagConfig did not map to a definition"; - _skippedTags.Skip(entry.RawPath, reason); - _logger.LogWarning( - "S7 tag skipped: {Reason} Driver={DriverInstanceId} RawPath={RawPath}", - reason, _driverInstanceId, entry.RawPath); + skipped.Add((entry.RawPath, shapeRejection ?? "TagConfig did not map to a definition")); } } - _skippedTags.Commit(); // Build the connection through the factory (the same seam EnsureConnectedAsync's reopen // uses) so the read/write timeouts are baked in identically on connect and reconnect. - var plc = _plcFactory.Create(_options); + plc = _plcFactory.Create(options); try { using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - cts.CancelAfter(_options.Timeout); + cts.CancelAfter(options.Timeout); await plc.OpenAsync(cts.Token).ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) @@ -283,88 +303,157 @@ public sealed class S7Driver // The linked CancelAfter fired — a connect TIMEOUT, not a caller cancellation. // Surface it as a TimeoutException so init-time and reconnect-time timeouts report // the same exception type (for consistency); the outer catch still faults health. - try { plc.Dispose(); } catch { } throw new TimeoutException( - $"S7 connect to {_options.Host}:{_options.Port} timed out after " + - $"{(int)_options.Timeout.TotalMilliseconds} ms."); + $"S7 connect to {options.Host}:{options.Port} timed out after " + + $"{(int)options.Timeout.TotalMilliseconds} ms."); } - catch + + // Publish everything this attempt built under _gate (no pass can be mid-flight against a + // half-published state) and only while this attempt is still the current generation. + await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + try { - // Dispose the partially-constructed connection so a failed init doesn't leak it - // before the outer catch nulls Plc (Plc isn't assigned yet, so it can't). - try { plc.Dispose(); } catch { } - throw; + if (!IsCurrentGeneration(gen)) + { + // The outer finally disposes the unpublished plc; the catch writes no health (the + // generation moved on). Thrown, not returned: a caller must never read "initialized" + // into an attempt that published nothing. + throw new InvalidOperationException( + "S7 initialize was superseded by a newer lifecycle operation before it published; " + + "its connection was discarded."); + } + + _options = options; + Volatile.Write(ref _tables, new TagTables(tags, parsed)); + _skippedTags.BeginRebuild(options.RawTags.Count); + foreach (var (rawPath, reason) in skipped) + { + _skippedTags.Skip(rawPath, reason); + _logger.LogWarning( + "S7 tag skipped: {Reason} Driver={DriverInstanceId} RawPath={RawPath}", + reason, _driverInstanceId, rawPath); + } + _skippedTags.Commit(); + + Plc = plc; + plc = null; // published — no longer this method's to dispose + _plcDead = false; + _connectBackoff.RecordSuccess(); + Volatile.Write(ref _negotiatedPduSize, Plc.MaxPduSize); + _initialized = true; + TryWriteHealth(gen, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); + + // Kick off the probe loop once the connection is up. Initial HostState stays Unknown + // until the first probe tick succeeds — avoids broadcasting a premature Running + // transition before any PDU round-trip has happened. Tracked (not fire-and-forget) so + // a teardown can await it before disposing its CTS; Task.Run gets None so the handle + // is always awaitable — the loop's own token check handles cancellation. + if (options.Probe.Enabled) + { + var probeCts = new CancellationTokenSource(); + _probeCts = probeCts; + _probeTask = Task.Run(() => ProbeLoopAsync(probeCts.Token), CancellationToken.None); + } + } + finally + { + _gate.Release(); } - Plc = plc; - _plcDead = false; - _connectBackoff.RecordSuccess(); - Volatile.Write(ref _negotiatedPduSize, plc.MaxPduSize); - - _initialized = true; - Health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); _logger.LogInformation("S7Driver connected. Driver={DriverInstanceId} Host={Host} CPU={CpuType} PduSize={PduSize} Tags={TagCount}", - _driverInstanceId, _options.Host, _options.CpuType, _negotiatedPduSize, _tagsByRawPath.Count); - - // Kick off the probe loop once the connection is up. Initial HostState stays - // Unknown until the first probe tick succeeds — avoids broadcasting a premature - // Running transition before any PDU round-trip has happened. - if (_options.Probe.Enabled) - { - _probeCts = new CancellationTokenSource(); - // Track the probe Task (not fire-and-forget) so ShutdownAsync can await it - // before disposing _gate / _probeCts. Pass None to Task.Run so - // the delegate always runs and the handle is always awaitable; the loop's own - // token check handles cancellation. - _probeTask = Task.Run(() => ProbeLoopAsync(_probeCts.Token), CancellationToken.None); - } + _driverInstanceId, options.Host, options.CpuType, _negotiatedPduSize, tags.Count); } catch (Exception ex) { - // Clean up a partially-constructed Plc so a retry from the caller doesn't leak - // the TcpClient. Dispose is best-effort and idempotent. - try { Plc?.Dispose(); } catch { } - Plc = null; - Volatile.Write(ref _negotiatedPduSize, 0); - _initialized = false; - _plcDead = false; - Health = new DriverHealth(DriverState.Faulted, null, ex.Message); - _logger.LogError(ex, "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}", _driverInstanceId, _options.Host); + // A superseded attempt reports nothing: the generation that replaced it owns health. + if (TryWriteHealth(gen, new DriverHealth(DriverState.Faulted, null, ex.Message))) + { + _options = options; + _logger.LogError(ex, "S7Driver connect failed. Driver={DriverInstanceId} Host={Host}", _driverInstanceId, options.Host); + } throw; } + finally + { + // A connection this attempt opened but did not publish (failed or superseded) is released + // here, so a retry from the caller never leaks the TcpClient. + if (plc is not null) { try { plc.Dispose(); } catch { /* best-effort */ } } + } } /// public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { - // InitializeAsync re-parses driverConfigJson, so a config change delivered here is + // Holds the lifecycle gate across BOTH halves so no other lifecycle operation slips between + // them. InitializeCoreAsync re-parses driverConfigJson, so a config change delivered here is // applied in place rather than silently discarded. - await ShutdownAsync(cancellationToken).ConfigureAwait(false); - await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); + await _lifecycleGate.WaitAsync(cancellationToken).ConfigureAwait(false); + try + { + await ShutdownCoreAsync().ConfigureAwait(false); + await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); + } + finally + { + _lifecycleGate.Release(); + } } /// public async Task ShutdownAsync(CancellationToken cancellationToken) { - // Signal cancellation to the probe + poll loops first, collect their Task handles, - // then await all of them with a bounded timeout BEFORE disposing the shared semaphore - // and CTS objects. Without the drain, a loop iteration mid-_gate would call Release() - // on (or WaitAsync against) a disposed semaphore. + // Bounded wait for the lifecycle gate: a wedged initialize (a connect that never returns, or an + // attempt its caller abandoned) must not turn shutdown into a hang. Past the bound we tear down + // anyway — the generation bump makes the still-running attempt discard instead of publish. + var gated = false; + try { gated = await _lifecycleGate.WaitAsync(LifecycleGateTimeout, cancellationToken).ConfigureAwait(false); } + catch (OperationCanceledException) { /* the caller stopped waiting for the gate — still tear down */ } + if (!gated) + _logger.LogWarning( + "S7 lifecycle gate still held at shutdown; tearing down anyway. Driver={DriverInstanceId}", + _driverInstanceId); + try + { + await ShutdownCoreAsync().ConfigureAwait(false); + } + finally + { + if (gated) _lifecycleGate.Release(); + } + } + + /// + /// The complete teardown: the poll engine (every subscription), the probe loop, the connection, + /// the sticky write fault and the per-generation memos. It is its own generation, so its final + /// health write can never clobber an initialize that started after it. + /// + /// A task that completes once everything is released. + private async Task ShutdownCoreAsync() + { + var gen = BeginGeneration(); // Drain the polling overlay first — the engine cancels each loop and awaits it before - // disposing its CTS (drain-before-dispose), so no poll iteration touches _gate - // after this returns. - await _poll.DisposeAsync().ConfigureAwait(false); - await ReleaseConnectionAsync().ConfigureAwait(false); - _writeFault = null; - _connectBackoff.RecordSuccess(); - Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null); + // disposing its CTS (drain-before-dispose), so no poll iteration touches _gate after this. + try { await _poll.DisposeAsync().ConfigureAwait(false); } + catch (Exception ex) { _logger.LogWarning(ex, "S7 poll engine drain failed. Driver={DriverInstanceId}", _driverInstanceId); } + await ReleaseConnectionAsync(gen).ConfigureAwait(false); + lock (_healthLock) + { + if (_generation != gen) return; // a newer initialize owns the state now + _writeFault = null; + _connectBackoff.RecordSuccess(); + Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null); + } } /// /// Cancels + bounded-joins the probe loop, then disposes its CTS and the PLC connection. The - /// connection part of (everything but the poll engine and health). + /// connection part of a teardown (everything but the poll engine and health). Releases only + /// while is still current — a teardown that ran past the lifecycle gate + /// must not dispose a connection a NEWER initialize already published. /// - private async Task ReleaseConnectionAsync() + /// The generation of the lifecycle operation doing the release. + /// A task that completes once the prior state is released. + private async Task ReleaseConnectionAsync(long gen) { var probeCts = _probeCts; var probeTask = _probeTask; @@ -379,11 +468,6 @@ public sealed class S7Driver catch { /* loop faults are already surfaced via health; teardown continues */ } } - // The probe loop has now observed cancellation and released _gate — safe to dispose the CTS. - probeCts?.Dispose(); - _probeCts = null; - _probeTask = null; - // Swap the connection out under _gate so a Read/Write/poll pass that already holds the gate (they // re-read Plc inside it) can never see a disposed Plc mid-swap. Bounded: a wedged holder must not hang // teardown, in which case we proceed exactly as the un-gated path always did. @@ -395,6 +479,15 @@ public sealed class S7Driver _driverInstanceId, DrainTimeout); try { + if (!IsCurrentGeneration(gen)) return; // superseded: the newer generation released (or owns) this state + + // The probe loop has now observed cancellation and released _gate — safe to dispose the CTS. + if (ReferenceEquals(_probeCts, probeCts)) + { + probeCts?.Dispose(); + _probeCts = null; + _probeTask = null; + } _initialized = false; _plcDead = false; try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ } @@ -407,6 +500,101 @@ public sealed class S7Driver } } + // ---- lifecycle generation ---- + + /// + /// Guards / / + /// / against each other (R07:H5, the + /// Modbus _lifecycleGate pattern). Distinct from , which serialises I/O. + /// Never disposed: a Dispose racing a still-running ShutdownAsync must not make it throw. + /// + private readonly SemaphoreSlim _lifecycleGate = new(1, 1); + + /// + /// Bounded wait the teardown paths take on before tearing down anyway. + /// Internal so a test can shorten it instead of waiting the production 10 s. + /// + internal TimeSpan LifecycleGateTimeout { get; set; } = TimeSpan.FromSeconds(10); + + /// Serialises generation bumps against generation-stamped health writes. + private readonly object _healthLock = new(); + + /// + /// Lifecycle generation: bumped by every initialize and teardown. Health writes and + /// table/connection publishes carry the generation they were computed under and are dropped + /// once it has moved on. Written under . + /// + private long _generation; + + /// Starts a new lifecycle generation. + /// The new generation. + private long BeginGeneration() + { + lock (_healthLock) return ++_generation; + } + + /// The current lifecycle generation. + private long CurrentGeneration => Interlocked.Read(ref _generation); + + /// True while is still the current lifecycle generation. + /// The generation to test. + /// when nothing has superseded it. + private bool IsCurrentGeneration(long gen) => CurrentGeneration == gen; + + /// Writes only while is current. + /// The generation the health was computed under. + /// The health to publish. + /// when written. + private bool TryWriteHealth(long gen, DriverHealth health) + { + lock (_healthLock) + { + if (_generation != gen) return false; + Health = health; + return true; + } + } + + /// + /// Derives and writes a new health from the current one, atomically, only while + /// is current — for the passes that preserve LastSuccessfulRead. + /// + /// The generation the update was computed under. + /// Maps the current health to the new one; returning null writes nothing. + /// when written. + private bool TryUpdateHealth(long gen, Func update) + { + lock (_healthLock) + { + if (_generation != gen) return false; + if (update(Health) is not { } next) return false; + Health = next; + return true; + } + } + + /// The published RawPath → definition and RawPath → parsed-address tables, swapped as one. + /// RawPath → definition. + /// RawPath → pre-parsed address. + private sealed record TagTables( + IReadOnlyDictionary Tags, + IReadOnlyDictionary Parsed) + { + /// The empty tables a driver starts with. + public static readonly TagTables Empty = new( + new Dictionary(StringComparer.Ordinal), + new Dictionary(StringComparer.Ordinal)); + } + + /// The live tag tables; replaced by reference at init, never mutated in place. + private TagTables _tables = TagTables.Empty; + + /// The live RawPath → definition table. + private IReadOnlyDictionary TagsByRawPath => Volatile.Read(ref _tables).Tags; + + /// The live RawPath → pre-parsed address table. + private IReadOnlyDictionary ParsedByRawPath => Volatile.Read(ref _tables).Parsed; + /// public DriverHealth GetHealth() => Health; @@ -488,8 +676,15 @@ public sealed class S7Driver var outcome = new ReadPassOutcome(); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + // The lifecycle generation this pass runs in — its health write is dropped if a teardown or + // re-initialize has moved on by the time it lands. Captured under _gate, where the init + // publish happens, and re-checked against _initialized: a pass that passed RequireInitialized + // and then waited out a teardown must not reopen a connection on a shut-down driver. + var gen = CurrentGeneration; try { + RequireInitialized(); + // (Re)open the connection under the gate. A failed (re)connect degrades the whole // batch to BadCommunicationError rather than throwing — the poll loop / next read // retries, and EnsureConnectedAsync repairs on the following call. @@ -506,7 +701,7 @@ public sealed class S7Driver { for (var i = 0; i < fullReferences.Count; i++) results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now); - Health = new DriverHealth(DriverState.Degraded, Health.LastSuccessfulRead, ex.Message); + TryUpdateHealth(gen, h => new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, ex.Message)); return results; } @@ -635,10 +830,10 @@ public sealed class S7Driver state = wf.State; error = wf.Message; } - Health = new DriverHealth( + TryUpdateHealth(gen, h => new DriverHealth( state, - outcome.AnySuccess ? now : Health.LastSuccessfulRead, - error); + outcome.AnySuccess ? now : h.LastSuccessfulRead, + error)); } _gate.Release(); @@ -1068,7 +1263,7 @@ public sealed class S7Driver /// The parsed address, valid only when this returns true. /// true when the address is known or parses. private bool TryGetParsedAddress(S7TagDefinition tag, out S7ParsedAddress addr) - => _parsedByRawPath.TryGetValue(tag.Name, out addr) || S7AddressParser.TryParse(tag.Address, out addr); + => ParsedByRawPath.TryGetValue(tag.Name, out addr) || S7AddressParser.TryParse(tag.Address, out addr); /// /// The read-failure classification ladder, shared by the per-tag path and the span path so @@ -1137,10 +1332,10 @@ public sealed class S7Driver private async Task ReadOneAsync(IS7Plc plc, S7TagDefinition tag, CancellationToken ct) { - // Every tag pre-parses its address at init (_parsedByRawPath); the on-demand + // Every tag pre-parses its address at init (ParsedByRawPath); the on-demand // S7AddressParser.Parse is a defensive fallback for a tag not in that table. Parse throws // FormatException on a bad address, which the caller's catch maps to BadCommunicationError. - var addr = _parsedByRawPath.TryGetValue(tag.Name, out var parsed) + var addr = ParsedByRawPath.TryGetValue(tag.Name, out var parsed) ? parsed : S7AddressParser.Parse(tag.Address); @@ -1691,8 +1886,11 @@ public sealed class S7Driver var outcome = new WritePassOutcome(); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + var gen = CurrentGeneration; // see ReadAsync try { + RequireInitialized(); + // (Re)open under the gate; a failed (re)connect degrades every write to // BadCommunicationError rather than throwing. IS7Plc plc; @@ -1733,7 +1931,7 @@ public sealed class S7Driver // Timer/Counter, but the mapper doesn't run that guard). // TryParse (not Parse) so a malformed address falls through to the existing // error path rather than throwing here. - if (_parsedByRawPath.TryGetValue(tag.Name, out var tcParsed) + if (ParsedByRawPath.TryGetValue(tag.Name, out var tcParsed) ? tcParsed.Area is S7Area.Timer or S7Area.Counter : (S7AddressParser.TryParse(tag.Address, out tcParsed) && tcParsed.Area is S7Area.Timer or S7Area.Counter)) { @@ -1806,7 +2004,7 @@ public sealed class S7Driver { // THE single health write of the batch (a finally, so an OCE mid-batch still flushes the // evidence gathered so far — a pass that observed nothing writes nothing). - ApplyWritePassHealth(outcome); + ApplyWritePassHealth(gen, outcome); _gate.Release(); } return results; @@ -1818,10 +2016,11 @@ public sealed class S7Driver /// successful sibling. Maintains , the sticky Faulted a read pass /// must not erase. Caller holds . /// + /// The lifecycle generation the batch ran in; a superseded batch writes nothing. /// The batch's accumulated outcomes. - private void ApplyWritePassHealth(WritePassOutcome outcome) + private void ApplyWritePassHealth(long gen, WritePassOutcome outcome) { - if (!outcome.HasObservations) return; + if (!outcome.HasObservations || !IsCurrentGeneration(gen)) return; var worst = outcome.WorstFailure; var priorWriteFault = _writeFault; @@ -1836,9 +2035,10 @@ public sealed class S7Driver // side) were holding — a clean write is the only evidence the alert has cleared. // Only a Faulted the write side owns: a read-side Faulted (e.g. access denied on another DB) is the // read pass's to clear, and Health still carries the write fault's own message iff it is ours. - if (outcome.AnySuccess && _writeFault is null && priorWriteFault is { } owned - && Health.State == DriverState.Faulted && Health.LastError == owned.Message) - Health = new DriverHealth(DriverState.Healthy, Health.LastSuccessfulRead, null); + if (outcome.AnySuccess && _writeFault is null && priorWriteFault is { } owned) + TryUpdateHealth(gen, h => h.State == DriverState.Faulted && h.LastError == owned.Message + ? new DriverHealth(DriverState.Healthy, h.LastSuccessfulRead, null) + : null); return; } @@ -1850,7 +2050,7 @@ public sealed class S7Driver state = wf.State; error = wf.Message; } - Health = new DriverHealth(state, Health.LastSuccessfulRead, error); + TryUpdateHealth(gen, h => new DriverHealth(state, h.LastSuccessfulRead, error)); } /// @@ -1881,10 +2081,10 @@ public sealed class S7Driver private async Task WriteOneAsync(IS7Plc plc, S7TagDefinition tag, object? value, CancellationToken ct) { // Parse the address the same way ReadOneAsync does: every tag pre-parses at init - // (_parsedByRawPath), with an on-demand parse as a defensive fallback. Needed here so the + // (ParsedByRawPath), with an on-demand parse as a defensive fallback. Needed here so the // wide-type write can byte-address the block (the narrow path below addresses by the raw // address string instead). - var addr = _parsedByRawPath.TryGetValue(tag.Name, out var parsed) + var addr = ParsedByRawPath.TryGetValue(tag.Name, out var parsed) ? parsed : S7AddressParser.Parse(tag.Address); @@ -2243,7 +2443,7 @@ public sealed class S7Driver { ArgumentNullException.ThrowIfNull(builder); var folder = builder.Folder("S7", "S7"); - foreach (var t in _tagsByRawPath.Values) + foreach (var t in TagsByRawPath.Values) { // A tag carrying a non-null array count (>= 1) surfaces as a 1-D OPC UA array node. // A null count stays scalar. A count of 1 IS a valid 1-element array: the foundation @@ -2308,8 +2508,9 @@ public sealed class S7Driver private void HandlePollError(Exception ex) { _logger.LogWarning(ex, "S7 poll reader failed. Driver={DriverInstanceId}", _driverInstanceId); - if (Health.State != DriverState.Faulted) - Health = new DriverHealth(DriverState.Degraded, Health.LastSuccessfulRead, ex.Message); + TryUpdateHealth(CurrentGeneration, h => h.State == DriverState.Faulted + ? null + : new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, ex.Message)); } // ---- IHostConnectivityProbe ---- @@ -2418,7 +2619,10 @@ public sealed class S7Driver if (_disposed) return; _disposed = true; SynchronousTeardown(); - _gate.Dispose(); + // _gate / _lifecycleGate are deliberately NOT disposed: a Dispose can race a still-running + // ShutdownAsync (the runtime disposes right after a bounded shutdown), which would then throw + // ObjectDisposedException mid-teardown. A SemaphoreSlim whose wait handle was never requested + // holds no unmanaged resource. } /// Asynchronously disposes the driver and releases resources. @@ -2427,9 +2631,10 @@ public sealed class S7Driver { if (_disposed) return; _disposed = true; + // Safe while a ShutdownAsync is still running: it waits (bounded) for the lifecycle gate, and + // the teardown is idempotent. The semaphores are not disposed (see Dispose). try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { /* disposal is best-effort */ } - _gate.Dispose(); } /// @@ -2439,6 +2644,10 @@ public sealed class S7Driver /// private void SynchronousTeardown() { + // Its own lifecycle generation, like ShutdownCoreAsync: an initialize still running past this + // point discards what it built instead of publishing it over the torn-down driver. + var gen = BeginGeneration(); + // Drain the polling overlay (drain-before-dispose). try { _poll.DisposeAsync().AsTask().Wait(DrainTimeout); } catch { /* timeouts/loop faults are tolerated — teardown continues */ } @@ -2463,6 +2672,6 @@ public sealed class S7Driver try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ } Plc = null; Volatile.Write(ref _negotiatedPduSize, 0); // a stale post-teardown reading would read as "online" - Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null); + TryUpdateHealth(gen, h => new DriverHealth(DriverState.Unknown, h.LastSuccessfulRead, null)); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7LifecycleGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7LifecycleGateTests.cs new file mode 100644 index 00000000..d856e307 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7LifecycleGateTests.cs @@ -0,0 +1,143 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// R07:H5 — overlapping lifecycle calls. DriverInstanceActor dispatches initialize on a +/// fire-and-forget task and a reconnect retry can start a second attempt while the first is still in +/// OpenAsync; an abandoned initialize can also still be running when the next shutdown starts. +/// Every wait here is a condition wait on the fake's own signals, never a sleep. +/// +[Trait("Category", "Unit")] +public sealed class S7LifecycleGateTests +{ + private static readonly TimeSpan Bound = TimeSpan.FromSeconds(10); + + private static S7DriverOptions Options(bool probe = false) => new() + { + Host = "192.0.2.1", + Timeout = TimeSpan.FromSeconds(30), // the fake's gated open is released by the test, never timed out + Probe = new S7ProbeOptions { Enabled = probe, Interval = TimeSpan.FromSeconds(30) }, + RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)), + }; + + /// A factory whose FIRST open blocks until released; later opens complete immediately. + private sealed class FirstOpenBlocks + { + public readonly ScriptedS7PlcFactory Factory = new(); + public readonly TaskCompletionSource FirstOpenEntered = new(TaskCreationOptions.RunContinuationsAsynchronously); + public readonly TaskCompletionSource ReleaseFirstOpen = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public FirstOpenBlocks() + { + Factory.Open = async (index, ct) => + { + if (index != 0) return; + FirstOpenEntered.TrySetResult(); + await ReleaseFirstOpen.Task.WaitAsync(ct).ConfigureAwait(false); + }; + } + } + + /// + /// A second initialize waits for the first instead of running beside it, then releases the first + /// attempt's connection: exactly one live connection remains. Before the gate, attempt B opened + /// its own connection while A was still in OpenAsync, and A's published connection (and + /// probe loop) was overwritten and never released. + /// + [Fact] + public async Task Overlapping_initializes_are_serialised_and_leave_one_live_connection() + { + var ct = TestContext.Current.CancellationToken; + var f = new FirstOpenBlocks(); + using var drv = new S7Driver(Options(), "s7-overlap", f.Factory); + + var a = drv.InitializeAsync("{}", ct); + await f.FirstOpenEntered.Task.WaitAsync(Bound, ct); + var b = drv.InitializeAsync("{}", ct); + + // Positive control first: B is genuinely pending (not finished, not faulted)... + b.IsCompleted.ShouldBeFalse(); + // ...and while A holds the gate B has not created a connection of its own. B can only reach + // its open after A completes, so this is not a race the test can lose by being slow. + f.Factory.Created.Count.ShouldBe(1, "a second initialize must not open beside the first"); + + f.ReleaseFirstOpen.SetResult(); + await a.WaitAsync(Bound, ct); + await b.WaitAsync(Bound, ct); + + f.Factory.Created.Count.ShouldBe(2); + f.Factory.Created[0].Disposed.ShouldBeTrue("B releases A's published connection before replacing it"); + f.Factory.Created[1].Disposed.ShouldBeFalse(); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + (await drv.ReadAsync(["W0"], ct))[0].StatusCode.ShouldBe(0u); + } + + /// + /// An initialize still running when a shutdown gives up waiting for it must not publish its + /// connection, its health or its probe loop over the shut-down driver; it fails and disposes what + /// it opened. Before the fix the late attempt published Healthy + a live connection after the + /// shutdown returned. + /// + [Fact] + public async Task An_initialize_superseded_by_shutdown_publishes_nothing() + { + var ct = TestContext.Current.CancellationToken; + var f = new FirstOpenBlocks(); + using var drv = new S7Driver(Options(probe: true), "s7-superseded", f.Factory) + { + LifecycleGateTimeout = TimeSpan.FromSeconds(1), + }; + + var init = drv.InitializeAsync("{}", ct); + await f.FirstOpenEntered.Task.WaitAsync(Bound, ct); + + // The shutdown cannot get the lifecycle gate (the attempt holds it), gives up after its bound + // and tears down anyway. + await drv.ShutdownAsync(ct).WaitAsync(Bound, ct); + drv.GetHealth().State.ShouldBe(DriverState.Unknown); + + // The abandoned attempt now completes its open... + f.ReleaseFirstOpen.SetResult(); + var ex = await Should.ThrowAsync(() => init.WaitAsync(Bound, ct)); + ex.Message.ShouldContain("superseded"); + + // ...and published nothing: no connection kept, health untouched, the driver still shut down. + f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue(); + drv.GetHealth().State.ShouldBe(DriverState.Unknown, "a superseded attempt must not write Healthy or Faulted"); + await Should.ThrowAsync(() => drv.ReadAsync(["W0"], ct)); + + // Positive control: the driver is not wedged — a fresh initialize publishes normally. + await drv.InitializeAsync("{}", ct).WaitAsync(Bound, ct); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + (await drv.ReadAsync(["W0"], ct))[0].StatusCode.ShouldBe(0u); + } + + /// + /// Dispose racing a shutdown that is still running is safe: neither throws, and the driver ends + /// torn down. The semaphores are no longer disposed, so the running shutdown cannot hit an + /// ObjectDisposedException. + /// + [Fact] + public async Task Dispose_while_a_shutdown_is_still_running_is_safe() + { + var ct = TestContext.Current.CancellationToken; + var f = new FirstOpenBlocks(); + var drv = new S7Driver(Options(), "s7-dispose-race", f.Factory) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) }; + + var init = drv.InitializeAsync("{}", ct); + await f.FirstOpenEntered.Task.WaitAsync(Bound, ct); + + var shutdown = drv.ShutdownAsync(ct); // waits on the gate the blocked init holds + var dispose = drv.DisposeAsync().AsTask(); + await shutdown.WaitAsync(Bound, ct); + await dispose.WaitAsync(Bound, ct); + + f.ReleaseFirstOpen.SetResult(); + await Should.ThrowAsync(() => init.WaitAsync(Bound, ct)); + f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue(); + drv.GetHealth().State.ShouldBe(DriverState.Unknown); + } +} From 8a80dcc7875a169ac5eb94ffc3e023dbe67eb0b7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:12:21 -0400 Subject: [PATCH 49/67] fix(ablegacy): a status the PLC returned is an answer, never a comms fault Review r0 I1. On the default libplctag transport a PLC refusal (non-zero PCCC STS, e.g. an element past the end of the file; or a CIP error) arrives as ERR_REMOTE_ERR / ERR_BAD_STATUS, which fell into the BadConnection default: a no-reply comms fault. A coalesced block with one such member then failed as a group every poll, and two refused tags in a row opened the device's fail-fast window against a device that was answering. - LibplctagLegacyStatusTranslation maps ERR_REMOTE_ERR / ERR_BAD_STATUS to the existing AbStatus.DeviceStateConflict (BadDeviceFailure): "reachable, refused the operation". No new AbStatus value (Driver.Ab.Transport is Lane K's). - IsNoReply (Timeout / BadConnection / Abort / Pending / unknown codes) is now the ONLY comms-fault class; IsDeviceRefusal is every other non-Ok status except Unsupported (BadReply / Partial included: a reply arrived). - A coalesced block refused with ANY returned status falls back to per-member reads in the same pass; the shape is remembered only after a repeat, and the refused block's handle is always evicted (M5). - A runtime status Unsupported is neutral (keeps the handle); the probe no longer counts it as a fault. - Refusal warnings log the status actually answered (M8). Live: the new past-end-of-file ab_server case fails on libplctag with the old translation and passes with it, on slc500/micrologix/plc5 x both transports. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriver.Coalescing.cs | 34 ++++-- .../AbLegacyDriver.cs | 69 ++++++++--- .../LibplctagLegacyStatusTranslation.cs | 8 ++ .../AbLegacyReadCoalescingIntegrationTests.cs | 40 ++++++ .../AbLegacyAnsweredStatusTests.cs | 115 ++++++++++++++++++ .../AbLegacyHostAndStatusTests.cs | 3 + .../AbLegacyReadCoalescingTests.cs | 6 +- 7 files changed, 240 insertions(+), 35 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs index c1c6fac1..401ce098 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs @@ -19,8 +19,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// The span is safe to read. A block runs from its lowest to its highest member, so every element /// it reads lies between two authored elements of the same file — inside the file whenever the authored tags /// are. A device REFUSAL of a block (an authored element past the end of the file, a PLC-5 native non-N file) -/// falls back to single reads for that block's members, so one bad tag never blackens its neighbours, and the -/// block shape is remembered so it is not retried every poll. A comms fault fails the members together — they +/// — or ANY other status the device returned, incl. libplctag's remote error — falls back to single reads for that +/// block's members in the same pass, so one bad tag never blackens its neighbours; a shape refused twice in a row +/// is remembered so it is not retried every poll. Only a NO-REPLY failure fails the members together — they /// share the connection — exactly as their single reads would have. /// Turned off with the driver-config key coalesceReads: false. /// @@ -133,10 +134,19 @@ public sealed partial class AbLegacyDriver (BlockOutcome, OpVerdict, string?) Refused(string why) { - device.RefusedBlocks.TryAdd(block.Key, 0); - _logger.LogInformation( - "AbLegacy coalesced read {Block} refused ({Reason}); its {Count} tags read singly from now on. Driver={DriverInstanceId} Device={DeviceHostAddress}", - block.StartName + "+" + block.Count, why, block.Members.Count, _driverInstanceId, device.Options.HostAddress); + // The handle is never kept for a refused shape (review 07 r0 M5): it may never be planned again. + EvictRuntime(device, block.Key); + // Every refusal falls back to per-member reads THIS pass, so each member gets its own answer and a bad + // member can never black out its neighbours. The shape is only REMEMBERED after a repeat — one refusal can + // be transient (program mode, a download in progress), two in a row are deterministic. + var refusals = device.BlockRefusals.AddOrUpdate(block.Key, 1, (_, n) => n + 1); + if (refusals >= 2 && device.RefusedBlocks.TryAdd(block.Key, 0)) + { + device.BlockRefusals.TryRemove(block.Key, out _); + _logger.LogInformation( + "AbLegacy coalesced read {Block} refused twice ({Reason}); its {Count} tags read singly from now on. Driver={DriverInstanceId} Device={DeviceHostAddress}", + block.StartName + "+" + block.Count, why, block.Members.Count, _driverInstanceId, device.Options.HostAddress); + } return (BlockOutcome.Fallback, OpVerdict.Neutral, null); } @@ -170,11 +180,10 @@ public sealed partial class AbLegacyDriver if (status != 0) { - if (IsDeviceRefusal((AbStatus)status) || (AbStatus)status == AbStatus.Unsupported) - { - EvictRuntime(device, block.Key); + // ANY status the device returned (a refusal, a remote error, an unmapped reply, Unsupported) falls back + // to per-member reads; only a no-reply status fails the block's members together (review 07 r0 I1). + if (!IsNoReply((AbStatus)status)) return Refused($"device status {(AbStatus)status}"); - } EvictRuntime(device, block.Key); FailAll(AbLegacyStatusMapper.MapStatus(status)); return (BlockOutcome.Done, OpVerdict.Fault, $"transport status {status} reading {block.StartName}+{block.Count}"); @@ -184,6 +193,7 @@ public sealed partial class AbLegacyDriver foreach (var m in block.Members) results[m.Index] = new DataValueSnapshot(DecodeMember(block, m, data), AbLegacyStatusMapper.Good, now, now); + device.BlockRefusals.TryRemove(block.Key, out _); device.FirstNonZeroStatusLogged = false; return (BlockOutcome.Done, OpVerdict.Answered, null); } @@ -193,14 +203,12 @@ public sealed partial class AbLegacyDriver FailAll(AbLegacyStatusMapper.BadCommunicationError); return (BlockOutcome.Done, OpVerdict.Throttled, tex.Message); } - catch (AbTransportException ex) when (IsLocalRefusal(ex) || IsDeviceRefusal(ex.Status)) + catch (AbTransportException ex) when (!IsNoReply(ex.Status)) { - EvictRuntime(device, block.Key); return Refused(ex.Message); } catch (NotSupportedException nse) { - EvictRuntime(device, block.Key); return Refused(nse.Message); } catch (Exception ex) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 773acc64..2f303ee8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -677,12 +677,18 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(status), null, now); if (IsDeviceRefusal((AbStatus)status)) { - // The device ANSWERED with a deterministic refusal (file/element missing, out of bounds…): per-tag - // Bad, the handle is kept (re-creating it would only re-pay the init read), warned once per tag, - // and it is evidence of reachability rather than a comms fault. - WarnRefusalOnce(device, def, "read", $"device status {(AbStatus)status}"); + // The device ANSWERED with a refusal (file/element missing, out of bounds, a remote error…): + // per-tag Bad, the handle is kept (re-creating it would only re-pay the init read), warned once + // per tag, and it is evidence of reachability rather than a comms fault. + WarnRefusalOnce(device, def, "read", $"device status {(AbStatus)status}", results[item.Index].StatusCode); return (OpVerdict.Answered, null); } + if ((AbStatus)status == AbStatus.Unsupported) + { + // Refused locally / not supported: says nothing about reachability. Keep the handle. + WarnRefusalOnce(device, def, "read", "status Unsupported", AbLegacyStatusMapper.BadNotSupported); + return (OpVerdict.Neutral, null); + } // Evict the stale handle so the next call re-creates it (mirrors AbCip). EvictRuntime(device, def.Name); if (!device.FirstNonZeroStatusLogged) @@ -711,14 +717,14 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag { // The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read). // Not a comms fault: BadNotSupported, keep the handle, leave health alone. - WarnRefusalOnce(device, def, "read", ex.Message); + WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.BadNotSupported); results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); return (OpVerdict.Neutral, null); } catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status)) { // The native runtime raises a device's refusal (e.g. STS 0x20 "file not found") as an exception. - WarnRefusalOnce(device, def, "read", ex.Message); + WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status)); results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now); return (OpVerdict.Answered, null); } @@ -726,7 +732,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag { // A deterministic decode refusal (review 07 H16) — the same answer every poll. BadNotSupported, keep // the handle (re-creating it only re-paid the init read), warn once, and NO health write. - WarnRefusalOnce(device, def, "read", nse.Message); + WarnRefusalOnce(device, def, "read", nse.Message, AbLegacyStatusMapper.BadNotSupported); results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); return (OpVerdict.Neutral, null); } @@ -740,17 +746,34 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag } /// - /// True for a status that proves the DEVICE answered and refused this one operation deterministically — the - /// file or element does not exist, the access is not allowed, the data type or size is wrong. Such a status - /// is per-tag, keeps the handle, and is not a comms fault. Everything else non-zero (timeouts, dropped or - /// refused connections, malformed or partial replies, unknown codes) is treated as a comms fault. - /// is classified separately (). + /// True for a status meaning NO REPLY arrived: a timeout, a dropped / refused connection, an aborted request, a + /// request still pending, or a code this build does not know. Only these are comms faults — they evict the + /// handle, degrade health and count toward (review 07 r0 I1: fail-fast + /// must trip only when nothing came back). /// /// The transport status. - /// for a deterministic device refusal. - internal static bool IsDeviceRefusal(AbStatus status) => status is - AbStatus.NotFound or AbStatus.NotAllowed or AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall - or AbStatus.TypeMismatch or AbStatus.DeviceStateConflict; + /// when no reply arrived. + internal static bool IsNoReply(AbStatus status) => + !Enum.IsDefined(status) || status is AbStatus.Timeout or AbStatus.BadConnection or AbStatus.Abort or AbStatus.Pending; + + /// + /// True for a non-zero status that proves the DEVICE answered and refused this one operation — the file or + /// element does not exist, the access is not allowed, a size / type is wrong, a remote error (libplctag + /// ERR_REMOTE_ERR, translated to ), or a reply the transport + /// could not map (, ). Such a status is per-tag Bad, + /// keeps the handle, and is evidence of reachability. is classified + /// separately (: it says nothing about reachability). + /// + /// + /// ⚠️ Depends on the transport's PCCC STS table (AbStatusCodes.FromPccc, Lane K): today it maps STS 0x30 + /// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to answered values, so on the + /// NATIVE transport such a dead node reads as answered and never trips the backoff or the comms-loss watchdog. + /// The remap is follow-up F8 (review 07 r0 I4); this classification needs no change once it lands. + /// + /// The transport status. + /// for a device refusal. + internal static bool IsDeviceRefusal(AbStatus status) => + status is not (AbStatus.Ok or AbStatus.Unsupported) && !IsNoReply(status); // ---- IWritable ---- @@ -1029,7 +1052,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag // The probe's verdict feeds the data path's backoff (review 07 P7): an answer resets the window so a // recovered device is read again at once; a comms fault counts toward marking it down. if (success || IsDeviceRefusal((AbStatus)status)) state.RecordAnswered(); - else state.RecordCommsFault(DateTime.UtcNow); + else if ((AbStatus)status != AbStatus.Unsupported) state.RecordCommsFault(DateTime.UtcNow); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) @@ -1456,12 +1479,14 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// A deterministic refusal (BadNotSupported) is answered the same way every time, so it is logged ONCE per /// tag and operation — a polled tag must not flood the log, and the refusal never touches driver health. /// - private void WarnRefusalOnce(DeviceState device, AbLegacyTagDefinition def, string operation, string reason) + private void WarnRefusalOnce(DeviceState device, AbLegacyTagDefinition def, string operation, string reason, + uint status = AbLegacyStatusMapper.BadNotSupported) { if (!device.RefusalLogged.TryAdd($"{operation}:{def.Name}", 0)) return; + // The status the operation is actually answered with (review 07 r0 M8) — not always BadNotSupported. _logger.LogWarning( - "AbLegacy {Operation} refused (BadNotSupported). Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Address={Address} Reason={Reason}", - operation, _driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, reason); + "AbLegacy {Operation} refused (0x{Status:X8}). Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Address={Address} Reason={Reason}", + operation, status, _driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, reason); } /// The create parameters for a tag's own runtime — shared by creation and the write-refusal check, so @@ -1718,6 +1743,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// distinct subscription shapes, released with the state). public System.Collections.Concurrent.ConcurrentDictionary RefusedBlocks { get; } = new(StringComparer.Ordinal); + /// Consecutive refusals per coalesced block shape not yet remembered in + /// (a success clears the count). + public System.Collections.Concurrent.ConcurrentDictionary BlockRefusals { get; } = new(StringComparer.Ordinal); + /// Tag operations already warned about for a deterministic refusal (once-per-tag-and-operation log). public System.Collections.Concurrent.ConcurrentDictionary RefusalLogged { get; } = new(StringComparer.Ordinal); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs index 3d762e84..281c4ebd 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs @@ -23,6 +23,14 @@ internal static class LibplctagLegacyStatusTranslation Status.ErrorPartial => AbStatus.Partial, Status.ErrorAbort => AbStatus.Abort, Status.ErrorBadReply => AbStatus.BadReply, + // The PLC (or a CIP hop) ANSWERED with an error: libplctag's PCCC layer turns a non-zero STS — a file or + // element that does not exist, a write the processor refuses — into PLCTAG_ERR_REMOTE_ERR (and a non-zero + // CIP general status into ERR_REMOTE_ERR / ERR_BAD_STATUS). AbStatus has no dedicated "remote error" value and + // Driver.Ab.Transport is not this driver's to extend (Lane K), so this maps to the existing value whose meaning + // is closest — "the device is reachable but refused the operation" — DeviceStateConflict (OPC UA + // BadDeviceFailure). It MUST NOT fall into the BadConnection default: that is a no-reply comms fault, which + // evicts handles, degrades health and counts toward marking the device down (review 07 r0 I1). + Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.DeviceStateConflict, _ => AbStatus.BadConnection, }; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs index 2558adc3..e3aa58d2 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs @@ -66,4 +66,44 @@ public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture b.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); b.Select(s => s.Value).ShouldBe(a.Select(s => s.Value)); } + + /// + /// Review 07 r0 I1, on the wire: one coalesced member past the END of the file (the fixture's N7 holds 10 + /// words) makes the PLC refuse the block with a real transport status (libplctag: ERR_REMOTE_ERR). Its + /// neighbours must still read Good on every poll, and the device must never be failed fast or degraded. + /// + /// The simulator profile. + /// The transport stack the driver runs on. + [AbLegacyTheory] + [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))] + public async Task A_member_past_the_end_of_the_file_never_blacks_out_its_neighbours( + AbLegacyServerProfile profile, AbLegacyTransport transport) + { + if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip); + var ct = TestContext.Current.CancellationToken; + var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}"; + await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(uri, profile.Family)], + RawTags = AbLegacyRawTags.Entries( + new AbLegacyTagDefinition("A", uri, "N7:7", AbLegacyDataType.Int), + new AbLegacyTagDefinition("B", uri, "N7:8", AbLegacyDataType.Int), + new AbLegacyTagDefinition("PastEnd", uri, "N7:12", AbLegacyDataType.Int)), + Timeout = TimeSpan.FromSeconds(5), + Probe = new AbLegacyProbeOptions { Enabled = false }, + Transport = transport, + }, driverInstanceId: $"ablegacy-pastend-{profile.Family}-{transport}"); + await drv.InitializeAsync("{}", ct); + + for (var poll = 0; poll < 3; poll++) + { + var r = await drv.ReadAsync(["A", "B", "PastEnd"], ct); + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour A"); + r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour B"); + r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}: the past-the-end tag"); + r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.BadCommunicationError, + $"poll {poll}: the PLC answered — not a comms fault (status 0x{r[2].StatusCode:X8})"); + } + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs new file mode 100644 index 00000000..8cbaba2a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs @@ -0,0 +1,115 @@ +using libplctag; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// Review 07 r0 I1: on the default libplctag transport a PLC's own refusal (a non-zero PCCC STS — e.g. an element +/// past the end of the file) arrives as ERR_REMOTE_ERR, which used to translate to BadConnection: a +/// no-reply comms fault. A coalesced block holding one such member then failed as a group every poll, and two +/// refused tags in a row tripped the device's fail-fast window. Statuses the device RETURNED are now answers; +/// only no-reply statuses are comms faults. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyAnsweredStatusTests +{ + private const string Host = "ab://10.0.0.5/1,0"; + + /// What the libplctag runtime's GetStatus() reports for a PLC refusal (through the real translation). + private static readonly int RemoteErr = (int)LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorRemoteErr); + + private static AbLegacyDriver NewDriver(FakeAbLegacyTagFactory factory, bool coalesce, params AbLegacyTagDefinition[] tags) => + new(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host)], + RawTags = AbLegacyRawTags.Entries(tags), + Probe = new AbLegacyProbeOptions { Enabled = false }, + CoalesceReads = coalesce, + }, "drv-i1", factory); + + [Fact] + public void Remote_error_translates_to_an_answered_status_not_a_comms_fault() + { + var s = LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorRemoteErr); + AbLegacyDriver.IsNoReply(s).ShouldBeFalse(); + AbLegacyDriver.IsDeviceRefusal(s).ShouldBeTrue(); + AbLegacyDriver.IsDeviceRefusal(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorBadStatus)).ShouldBeTrue(); + // Positive control: real no-reply statuses still are. + AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorTimeout)).ShouldBeTrue(); + AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorBadConnection)).ShouldBeTrue(); + } + + [Theory] + [InlineData(AbStatus.BadReply)] // native: an STS the transport does not map + [InlineData(AbStatus.Partial)] + [InlineData(AbStatus.NotFound)] + [InlineData(AbStatus.DeviceStateConflict)] + public void Statuses_the_device_returned_are_answers(AbStatus status) => + AbLegacyDriver.IsDeviceRefusal(status).ShouldBeTrue(); + + [Theory] + [InlineData(AbStatus.Timeout)] + [InlineData(AbStatus.BadConnection)] + [InlineData(AbStatus.Abort)] + [InlineData((AbStatus)(-99))] // a code this build does not know: conservatively no reply + public void Only_no_reply_statuses_are_comms_faults(AbStatus status) + { + AbLegacyDriver.IsNoReply(status).ShouldBeTrue(); + AbLegacyDriver.IsDeviceRefusal(status).ShouldBeFalse(); + } + + [Fact] + public async Task A_remote_error_member_never_blacks_out_its_coalesced_neighbours() + { + // N7:52 is past the end of the file: the block N7:48+5 and the single N7:52 both draw ERR_REMOTE_ERR. + var factory = new FakeAbLegacyTagFactory + { + Customise = p => p.ElementCount > 1 || p.TagName == "N7:52" + ? new FakeAbLegacyTag(p) { Status = RemoteErr } + : new FakeAbLegacyTag(p) { Value = 48 }, + }; + var drv = NewDriver(factory, coalesce: true, + new AbLegacyTagDefinition("A", Host, "N7:48", AbLegacyDataType.Int), + new AbLegacyTagDefinition("B", Host, "N7:49", AbLegacyDataType.Int), + new AbLegacyTagDefinition("C", Host, "N7:52", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + + for (var poll = 0; poll < 4; poll++) + { + var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}"); + r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}"); + r[2].StatusCode.ShouldBe(AbLegacyStatusMapper.BadDeviceFailure, $"poll {poll}"); + } + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse(); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Consecutive_refused_tags_do_not_trip_fail_fast_on_an_answering_device() + { + var factory = new FakeAbLegacyTagFactory + { + Customise = p => p.TagName == "N7:9" ? new FakeAbLegacyTag(p) { Value = 9 } : new FakeAbLegacyTag(p) { Status = RemoteErr }, + }; + var drv = NewDriver(factory, coalesce: false, + new AbLegacyTagDefinition("R1", Host, "N7:0", AbLegacyDataType.Int), + new AbLegacyTagDefinition("R2", Host, "N7:3", AbLegacyDataType.Int), + new AbLegacyTagDefinition("R3", Host, "N7:6", AbLegacyDataType.Int), + new AbLegacyTagDefinition("Good", Host, "N7:9", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + + for (var poll = 0; poll < 3; poll++) + { + var r = await drv.ReadAsync(["R1", "R2", "R3", "Good"], CancellationToken.None); + r[3].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: an answering device is never failed fast"); + } + factory.Tags["N7:9"].ReadCount.ShouldBe(3); + factory.Tags["N7:0"].InitializeCount.ShouldBe(1); // refused handles are kept, not churned + await drv.ShutdownAsync(CancellationToken.None); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs index 8338dc3b..79cd5d80 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs @@ -89,6 +89,9 @@ public sealed class AbLegacyHostAndStatusTests Status.ErrorNotAllowed => AbLegacyStatusMapper.BadNotWritable, Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbLegacyStatusMapper.BadOutOfRange, Status.ErrorUnsupported or Status.ErrorNotImplemented => AbLegacyStatusMapper.BadNotSupported, + // DELIBERATE change (review 07 r0 I1): a PLC's own refusal (PCCC STS / CIP status → ERR_REMOTE_ERR / + // ERR_BAD_STATUS) is "device answered", not a comms error. + Status.ErrorRemoteErr or Status.ErrorBadStatus => AbLegacyStatusMapper.BadDeviceFailure, _ => AbLegacyStatusMapper.BadCommunicationError, }; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs index 59fddba0..0dc8cf89 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs @@ -113,7 +113,7 @@ public sealed class AbLegacyReadCoalescingTests } [Fact] - public async Task A_refused_block_falls_back_to_single_reads_and_is_not_retried() + public async Task A_refused_block_falls_back_to_single_reads_and_is_remembered_after_a_repeat() { // N7:9 is past the end of the file: the BLOCK is refused; singly, only that tag is. var (drv, _, created) = NewDriver( @@ -133,7 +133,9 @@ public sealed class AbLegacyReadCoalescingTests r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}"); r[2].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(AbStatus.OutOfBounds)); } - created.Count(c => c.ElementCount > 1).ShouldBe(1); // the refused shape is remembered + // Refused on polls 0 and 1 (each falling back to singles in the same pass); remembered after the repeat, + // so poll 2 does not try the block again. + created.Count(c => c.ElementCount > 1).ShouldBe(2); drv.GetHealth().State.ShouldBe(DriverState.Healthy); // refusals are not comms faults await drv.ShutdownAsync(CancellationToken.None); } From 89204ac0faa3c337daba4a1de52a8cde427a0574 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:13:02 -0400 Subject: [PATCH 50/67] fix(alarms): re-apply scripted alarms after a supervision restart of their host Review I-2. Under factory Props a supervision restart of ScriptedAlarmHostActor builds an empty engine, and nothing re-sent ApplyScriptedAlarms. Until the next deploy the host had no mux interest (inputs stopped), and its ownership filter silently dropped every operator ack and shelve. The host now tells its parent ScriptedAlarmHostRestarted from PostRestart. DriverHostActor keeps the last plans it applied and re-sends ApplyScriptedAlarms when the signal comes from the current host, in Steady, Applying and Stale. This mirrors how a driver child's ReprovisionRequested is answered. The OnAlarmsLoaded comment, which assumed a re-apply that never happened, now says where it comes from. DriverHostActorScriptedAlarmRestartTests deploys one scripted alarm through a real host, crashes the alarm host, does NOT re-apply by hand, and asserts that interest is re-registered, a dependency push activates the alarm, and an operator ack reaches the engine. It is red without the signal (times out waiting for the re-registration). Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../Drivers/DriverHostActor.cs | 27 +++++ .../ScriptedAlarms/ScriptedAlarmHostActor.cs | 31 +++++- ...riverHostActorScriptedAlarmRestartTests.cs | 103 ++++++++++++++++++ 3 files changed, 158 insertions(+), 3 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorScriptedAlarmRestartTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs index b751214f..fd29c0b8 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs @@ -278,6 +278,11 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS /// from . private IActorRef? _scriptedAlarmHost; + /// The scripted-alarm plans last sent to , re-sent when it reports a + /// supervision restart (). Null until the + /// first apply. + private IReadOnlyList? _lastScriptedAlarmPlans; + private RevisionHash? _currentRevision; /// @@ -1528,6 +1533,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS Receive(HandleGetDiagnostics); Receive(ForwardBatchToMux); Receive(OnRetiredChildTerminated); + Receive(_ => HandleScriptedAlarmHostRestarted()); Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); Receive(HandleCommsRecoveryAudit); @@ -1591,6 +1597,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS Receive(HandleGetDiagnostics); Receive(ForwardBatchToMux); Receive(OnRetiredChildTerminated); + Receive(_ => HandleScriptedAlarmHostRestarted()); Receive(ForwardNativeAlarm); Receive(OnDriverConnectivityChanged); Receive(HandleCommsRecoveryAudit); @@ -2541,6 +2548,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS _log.Debug("DriverHost {Node}: dropping a {Count}-item value burst from {Id} while Stale (no address space)", _localNode, m.Items.Count, m.DriverInstanceId)); Receive(OnRetiredChildTerminated); + Receive(_ => HandleScriptedAlarmHostRestarted()); Receive(m => _log.Debug("DriverHost {Node}: dropping a native alarm transition ({Cond}) from {Id} while Stale", _localNode, m.Args.ConditionId, m.DriverInstanceId)); @@ -3692,6 +3700,7 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS // (re)loads its engine + re-registers mux interest for the union of dependency refs. Covers // both the fresh-apply and bootstrap-restore paths (both call this method); the Stale-recovery // path deliberately does not, matching driver + VirtualTag recovery. + _lastScriptedAlarmPlans = composition.EquipmentScriptedAlarms; _scriptedAlarmHost?.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(composition.EquipmentScriptedAlarms)); if (composition.EquipmentScriptedAlarms.Count > 0) { @@ -3906,6 +3915,24 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS Context.Watch(child); } + /// + /// The scripted-alarm host was restarted by supervision and has an empty engine: re-send the last plans so + /// its inputs and operator commands work again without waiting for the next deploy. Only the CURRENT host's + /// signal is honoured. + /// + private void HandleScriptedAlarmHostRestarted() + { + if (_scriptedAlarmHost is null || !Sender.Equals(_scriptedAlarmHost)) return; + if (_lastScriptedAlarmPlans is null) + { + _log.Debug("DriverHost {Node}: ScriptedAlarm host restarted before any apply; nothing to re-send", _localNode); + return; + } + _log.Warning("DriverHost {Node}: ScriptedAlarm host was restarted by supervision; re-applying {Count} scripted alarm(s)", + _localNode, _lastScriptedAlarmPlans.Count); + _scriptedAlarmHost.Tell(new ScriptedAlarmHostActor.ApplyScriptedAlarms(_lastScriptedAlarmPlans)); + } + /// A retired child has terminated; nothing more can arrive from it. /// The termination notice. private void OnRetiredChildTerminated(Terminated msg) => _retiredChildren.Remove(msg.ActorRef); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs index 210a84ca..60f05729 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ScriptedAlarms/ScriptedAlarmHostActor.cs @@ -86,6 +86,21 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor /// The desired Equipment-namespace scripted-alarm plans. public sealed record ApplyScriptedAlarms(IReadOnlyList Plans); + /// + /// Told to the parent from . A supervision restart is otherwise invisible to the + /// parent, and the new incarnation has an EMPTY engine (factory Props) and no mux interest, so until the + /// next its inputs stop and every operator command is dropped as not + /// owned. The parent (DriverHostActor) answers by re-sending the last plans it applied, mirroring + /// how a driver child's ReprovisionRequested is answered. + /// + public sealed class ScriptedAlarmHostRestarted + { + /// The single instance. + public static readonly ScriptedAlarmHostRestarted Instance = new(); + + private ScriptedAlarmHostRestarted() { } + } + /// Marshals an engine emission off the engine's worker thread onto the actor thread. /// Carries the the engine raised on OnEvent. private sealed record EngineEmission(ScriptedAlarmEvent Event); @@ -384,9 +399,10 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor // subscriptions exist, so any DependencyValueChanged the mux delivers will be observed by the // engine. RegisterInterest replaces a subscriber's prior interest set (see DependencyMuxActor), // so a re-Apply with a fresh union simply supersedes the old one — no explicit unregister needed. - // ReplayRetained: the mux replays the last value it saw for each ref, so a fresh engine (a - // supervision restart builds one around an EMPTY upstream) converges immediately instead of - // waiting for every input to change again. + // ReplayRetained: the mux replays the last value it saw for each ref, so a freshly loaded engine + // converges immediately instead of waiting for every input to change again. That includes the engine + // a supervision restart builds around an EMPTY upstream, but only once the parent has re-sent + // ApplyScriptedAlarms in answer to ScriptedAlarmHostRestarted (PostRestart); nothing reloads it before. _mux?.Tell(new DependencyMuxActor.RegisterInterest(msg.DepRefs, Self, ReplayRetained: true)); // Prune the upstream's retained snapshots to the loaded set (R02:L3): a path no alarm references // any more will never be pushed again, so its last value is dead weight for the process lifetime. @@ -791,6 +807,15 @@ public sealed class ScriptedAlarmHostActor : ReceiveActor base.PreRestart(reason, message); } + /// + /// Tells the parent so it re-sends the last + /// : the new incarnation has nothing loaded and no mux interest. + protected override void PostRestart(Exception reason) + { + base.PostRestart(reason); + Context.Parent.Tell(ScriptedAlarmHostRestarted.Instance); + } + /// protected override void PostStop() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorScriptedAlarmRestartTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorScriptedAlarmRestartTests.cs new file mode 100644 index 00000000..2ff682f2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorScriptedAlarmRestartTests.cs @@ -0,0 +1,103 @@ +using System.Text.Json; +using Akka.Actor; +using Serilog; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Commons.Engines; +using ZB.MOM.WW.OtOpcUa.Commons.OpcUa; +using ZB.MOM.WW.OtOpcUa.Commons.Types; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; +using ZB.MOM.WW.OtOpcUa.Core.ScriptedAlarms; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.OpcUa; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; +using ZB.MOM.WW.OtOpcUa.Runtime.VirtualTags; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Review I-2: under the factory Props a supervision restart of the scripted-alarm host builds a fresh, EMPTY +/// engine. Nothing re-sent ApplyScriptedAlarms, so until the next deploy the host had no mux interest +/// (inputs stopped) and its ownership filter dropped every operator ack/shelve as "not owned". The host now +/// tells its parent from PostRestart, +/// and re-sends the last plans it applied. This test never re-applies by hand. +/// +public sealed class DriverHostActorScriptedAlarmRestartTests : RuntimeActorTestBase +{ + private static readonly NodeId TestNode = NodeId.Parse("driver-alarmhost-restart-test"); + private static readonly RevisionHash RevA = RevisionHash.Parse(new string('d', 64)); + private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(30); + + private static DeploymentId SeedAlarmDeployment(Microsoft.EntityFrameworkCore.IDbContextFactory db) + { + var artifact = JsonSerializer.SerializeToUtf8Bytes(new + { + ScriptedAlarms = new[] + { + new + { + ScriptedAlarmId = "alm-1", EquipmentId = "eq-1", Name = "HighTemp", AlarmType = "AlarmCondition", + Severity = 800, MessageTemplate = "hot", PredicateScriptId = "s-1", + HistorizeToAveva = false, Retain = true, Enabled = true, + }, + }, + Scripts = new[] { new { ScriptId = "s-1", SourceCode = "return (int)ctx.GetTag(\"M.T\").Value > 90;" } }, + }); + var id = DeploymentId.NewId(); + using var ctx = db.CreateDbContext(); + ctx.Deployments.Add(new Deployment + { + DeploymentId = id.Value, RevisionHash = RevA.Value, Status = DeploymentStatus.Sealed, + CreatedBy = "test", SealedAtUtc = DateTime.UtcNow, ArtifactBlob = artifact, + }); + ctx.SaveChanges(); + return id; + } + + [Fact] + public void After_a_supervision_restart_the_alarm_host_is_re_applied_and_inputs_and_acks_work_again() + { + var db = NewInMemoryDbFactory(); + var deploymentId = SeedAlarmDeployment(db); + var publish = this.CreateCleanOutcomePublishProbe(); + var mux = CreateTestProbe(); + var coordinator = CreateTestProbe(); + var host = Sys.ActorOf(DriverHostActor.Props( + db, TestNode, coordinator.Ref, + localRoles: new HashSet { "driver" }, + dependencyMux: mux.Ref, + opcUaPublishActor: publish.Ref, + virtualTagEvaluator: NullVirtualTagEvaluator.Instance, + virtualTagHostOverride: CreateTestProbe().Ref, + scriptRootLogger: new ScriptRootLogger(new LoggerConfiguration().CreateLogger()), + alarmStateStore: new InMemoryAlarmStateStore())); + V3ArtifactSeeder.Dispatch(host, coordinator, deploymentId, RevA); + + var first = mux.FishForMessage(r => r.TagRefs.Contains("M.T"), Timeout); + var alarmHost = first.Subscriber; + + // The crash: a batch item with a null TagId makes the upstream Push throw (the documented unguarded + // path), so supervision restarts the alarm host. NO manual re-apply follows. + alarmHost.Tell(new VirtualTagActor.DependencyValueBatch(new[] + { + new VirtualTagActor.DependencyValueChanged(null!, 1, DateTime.UtcNow), + })); + mux.FishForMessage(u => u.Subscriber.Equals(alarmHost), Timeout); + + // The parent re-applies on the restart signal: interest is registered again by the new incarnation. + mux.FishForMessage(r => r.TagRefs.Contains("M.T"), Timeout); + + // Inputs work: a dependency push activates the alarm. + alarmHost.Tell(new VirtualTagActor.DependencyValueChanged("M.T", 99, DateTime.UtcNow)); + publish.FishForMessage( + m => m.AlarmNodeId == "alm-1" && m.State.Active && !m.State.Acknowledged, Timeout); + + // Acks work: the restarted engine owns alm-1 again, so the command is not filtered out as unowned. + alarmHost.Tell(new AlarmCommand(AlarmId: "alm-1", Operation: "Acknowledge", User: "alice", Comment: null, UnshelveAtUtc: null)); + publish.FishForMessage( + m => m.AlarmNodeId == "alm-1" && m.State.Acknowledged, Timeout) + .State.Acknowledged.ShouldBeTrue(); + } +} From 4d8de20050da2e7b368238ac7f85edfce2ec57d8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:14:02 -0400 Subject: [PATCH 51/67] test(scripting): pin the Scripting section bind behind one helper Review m-1. The bind moves from Program.cs into Host.Configuration.ScriptingOptionsBinding.Bind, which binds the section and clamps it. Program.cs calls it, and ScriptingOptionsBindingTests drives it with in-memory configuration. Every documented key takes a non-default value, so a misspelled property or key goes red instead of silently falling back to the defaults. The tests also cover a clamped bind (1e300 / 1024 / 0, three adjustments), an absent section, and the sibling LogFilePath / LogTopicMinLevel keys leaving the bind untouched. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../Configuration/ScriptingOptionsBinding.cs | 23 +++++++ src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs | 4 +- .../ScriptingOptionsBindingTests.cs | 64 +++++++++++++++++++ 3 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptingOptionsBinding.cs create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptingOptionsBindingTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptingOptionsBinding.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptingOptionsBinding.cs new file mode 100644 index 00000000..1155748b --- /dev/null +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Configuration/ScriptingOptionsBinding.cs @@ -0,0 +1,23 @@ +using Microsoft.Extensions.Configuration; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; + +namespace ZB.MOM.WW.OtOpcUa.Host.Configuration; + +/// +/// Binds the node-wide Scripting section into and clamps it +/// (). One place, so Program.cs and its unit test bind the same +/// way and a misspelled key cannot silently fall back to the compiled defaults unnoticed. +/// +public static class ScriptingOptionsBinding +{ + /// Binds and clamps the Scripting section. + /// The host configuration. + /// One line per clamped value, for the caller to log as a Warning. + /// The clamped options; the compiled defaults when the section is absent. + public static ScriptingOptions Bind(IConfiguration configuration, out IReadOnlyList adjustments) + { + ArgumentNullException.ThrowIfNull(configuration); + var bound = configuration.GetSection(ScriptingOptions.SectionName).Get() ?? new ScriptingOptions(); + return bound.Sanitized(out adjustments); + } +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs index 70ecc606..c3c105ab 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Host/Program.cs @@ -370,8 +370,8 @@ if (hasDriver) // Warning rather than an OverflowException inside a script run. The one instance is shared by every script // seam on the node: the virtual-tag evaluator below, the scripted-alarm engine (DriverHostActor, via // WithOtOpcUaRuntimeActors) and every Calculation driver (DriverFactoryBootstrap). - var scriptingOptions = (builder.Configuration.GetSection(ScriptingOptions.SectionName).Get() - ?? new ScriptingOptions()).Sanitized(out var scriptingAdjustments); + var scriptingOptions = ZB.MOM.WW.OtOpcUa.Host.Configuration.ScriptingOptionsBinding.Bind( + builder.Configuration, out var scriptingAdjustments); foreach (var adjustment in scriptingAdjustments) Log.Warning("Scripting misconfiguration detected at startup: {Adjustment}", adjustment); builder.Services.AddSingleton(scriptingOptions); diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptingOptionsBindingTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptingOptionsBindingTests.cs new file mode 100644 index 00000000..f717d6c5 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Host.Tests/Configuration/ScriptingOptionsBindingTests.cs @@ -0,0 +1,64 @@ +using Microsoft.Extensions.Configuration; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Scripting; +using ZB.MOM.WW.OtOpcUa.Host.Configuration; + +namespace ZB.MOM.WW.OtOpcUa.Host.Tests.Configuration; + +/// +/// Review m-1: the Scripting section binds by the documented key names and is clamped at bind. Each +/// configured value differs from the compiled default, so a misspelled key (which the binder would silently +/// ignore, falling back to the default) goes red. +/// +public sealed class ScriptingOptionsBindingTests +{ + private static IConfiguration Config(Dictionary values) => + new ConfigurationBuilder().AddInMemoryCollection(values).Build(); + + [Fact] + public void Every_documented_key_binds_to_its_property() + { + var bound = ScriptingOptionsBinding.Bind(Config(new() + { + ["Scripting:MaxAllocBytesPerRun"] = "8388608", + ["Scripting:QuarantineAfterConsecutiveTimeouts"] = "7", + ["Scripting:StuckOrphanQuarantineFactor"] = "4.5", + // Sibling keys owned by the script-log pipeline must not disturb the bind. + ["Scripting:LogFilePath"] = "logs/x-.log", + ["Scripting:LogTopicMinLevel"] = "Warning", + }), out var adjustments); + + adjustments.ShouldBeEmpty(); + bound.MaxAllocBytesPerRun.ShouldBe(8L * 1024 * 1024); + bound.QuarantineAfterConsecutiveTimeouts.ShouldBe(7); + bound.StuckOrphanQuarantineFactor.ShouldBe(4.5); + } + + [Fact] + public void Out_of_range_values_are_clamped_at_bind_and_reported() + { + var bound = ScriptingOptionsBinding.Bind(Config(new() + { + ["Scripting:StuckOrphanQuarantineFactor"] = "1e300", + ["Scripting:MaxAllocBytesPerRun"] = "1024", + ["Scripting:QuarantineAfterConsecutiveTimeouts"] = "0", + }), out var adjustments); + + bound.StuckOrphanQuarantineFactor.ShouldBe(ScriptingOptions.MaxStuckOrphanQuarantineFactor); + bound.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.MinMaxAllocBytesPerRun); + bound.QuarantineAfterConsecutiveTimeouts.ShouldBe(1); + adjustments.Count.ShouldBe(3); + } + + [Fact] + public void An_absent_section_yields_the_compiled_defaults() + { + var bound = ScriptingOptionsBinding.Bind(Config(new()), out var adjustments); + + adjustments.ShouldBeEmpty(); + bound.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.DefaultMaxAllocBytesPerRun); + bound.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts); + bound.StuckOrphanQuarantineFactor.ShouldBe(ScriptingOptions.DefaultStuckOrphanQuarantineFactor); + } +} From 5dabafecdc94a9f5f0139c85cbd4b1ccf1ce9cba Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:15:11 -0400 Subject: [PATCH 52/67] fix(calc): log a clamped per-driver run-guard value; document the Scripting ranges Review m-2. A Calculation driver's own maxAllocBytesPerRun / quarantineAfterConsecutiveTimeouts used to be clamped silently by ScriptRunGuard. ResolveScripting now sanitizes the layered options itself and returns the adjustments, and the factory logs one Warning per clamped value, naming the driver. docs/ScriptEditor.md "Runtime safety" gains the key table: defaults, the 1 MB allocation floor, the 1000 caps, NaN and disable semantics, and the per-driver override. docs/Configuration.md has no Scripting section, so these keys have no other home. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- docs/ScriptEditor.md | 15 +++++++++++++ .../CalculationDriverFactoryExtensions.cs | 21 +++++++++++++------ ...culationScriptingOptionsResolutionTests.cs | 21 +++++++++++++++---- 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md index 25fd73e2..e039653c 100644 --- a/docs/ScriptEditor.md +++ b/docs/ScriptEditor.md @@ -209,6 +209,21 @@ scripted-alarm predicates) runs scripts through the shared `ScriptRunGuard`: `Scripting:MaxAllocBytesPerRun` (64 MB) is quarantined. This is sampled after the run returns, so a run that OOMs the process first is not stopped — that needs an out-of-process runner. +- **Configuration keys and their ranges.** All three knobs are node-wide (the + `Scripting` appsettings section, shared by virtual tags, Calculation tags and + scripted alarms) and are clamped when bound, with one startup Warning per + clamped value: + + | Key | Default | Range after the clamp | + |---|---|---| + | `Scripting:MaxAllocBytesPerRun` | `67108864` (64 MB) | `0` (or negative) disables the budget; a positive value below `1048576` (1 MB) becomes 1 MB | + | `Scripting:QuarantineAfterConsecutiveTimeouts` | `3` | `1`–`1000` | + | `Scripting:StuckOrphanQuarantineFactor` | `10` | `0` (or negative) disables the rule; above `1000` becomes `1000`; `NaN` becomes the default | + + A Calculation driver may override `maxAllocBytesPerRun` and + `quarantineAfterConsecutiveTimeouts` in its own driver config. Those keys are + layered on the node values and clamped to the same ranges, and an out-of-range + value logs a Warning naming the driver. The stuck factor has no per-driver key. - **Lifting.** A quarantine lifts when the script source changes or on redeploy, never by itself. - **Script-log rate limit.** The cluster-wide script-log fan-out admits 20 entries diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs index ce7817cf..2c417de1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs @@ -86,7 +86,14 @@ public static class CalculationDriverFactoryExtensions ?? throw new InvalidOperationException( $"Calculation driver config for '{driverInstanceId}' deserialised to null"); - var scripting = ResolveScripting(dto.MaxAllocBytesPerRun, dto.QuarantineAfterConsecutiveTimeouts, nodeScripting); + var scripting = ResolveScripting( + dto.MaxAllocBytesPerRun, dto.QuarantineAfterConsecutiveTimeouts, nodeScripting, out var adjustments); + var logger = loggerFactory?.CreateLogger(); + // An authored value is never changed silently: the run guard would clamp it anyway, without a word. + foreach (var adjustment in adjustments) + logger?.LogWarning( + "Calculation driver {DriverInstanceId}: its run-guard config was out of range and is clamped: {Adjustment}", + driverInstanceId, adjustment); var options = new CalculationDriverOptions { @@ -96,9 +103,7 @@ public static class CalculationDriverFactoryExtensions Scripting = scripting, }; - return new CalculationDriver( - options, driverInstanceId, scriptRoot, - logger: loggerFactory?.CreateLogger()); + return new CalculationDriver(options, driverInstanceId, scriptRoot, logger: logger); } /// @@ -109,10 +114,14 @@ public static class CalculationDriverFactoryExtensions /// The driver config's maxAllocBytesPerRun, or null. /// The driver config's quarantineAfterConsecutiveTimeouts, or null. /// The node-wide options, or null. + /// The clamps applied to the driver's OWN keys (the node options were already + /// clamped and logged at bind), for the caller to log. Empty when nothing was out of range. /// The options to hand the driver, or null. internal static ScriptingOptions? ResolveScripting( - long? maxAllocBytesPerRun, int? quarantineAfterConsecutiveTimeouts, ScriptingOptions? nodeScripting) + long? maxAllocBytesPerRun, int? quarantineAfterConsecutiveTimeouts, ScriptingOptions? nodeScripting, + out IReadOnlyList adjustments) { + adjustments = []; if (maxAllocBytesPerRun is null && quarantineAfterConsecutiveTimeouts is null) return nodeScripting; var resolved = new ScriptingOptions(); @@ -124,7 +133,7 @@ public static class CalculationDriverFactoryExtensions } if (maxAllocBytesPerRun is { } alloc) resolved.MaxAllocBytesPerRun = alloc; if (quarantineAfterConsecutiveTimeouts is { } n) resolved.QuarantineAfterConsecutiveTimeouts = n; - return resolved; + return resolved.Sanitized(out adjustments); } /// The merged-config DTO the calc factory binds from. The Calculation driver has no diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationScriptingOptionsResolutionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationScriptingOptionsResolutionTests.cs index 3b187df6..5d2a86b7 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationScriptingOptionsResolutionTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationScriptingOptionsResolutionTests.cs @@ -21,12 +21,12 @@ public sealed class CalculationScriptingOptionsResolutionTests [Fact] public void No_driver_keys_and_no_node_options_resolves_null_so_the_guard_uses_its_defaults() => - CalculationDriverFactoryExtensions.ResolveScripting(null, null, null).ShouldBeNull(); + CalculationDriverFactoryExtensions.ResolveScripting(null, null, null, out _).ShouldBeNull(); [Fact] public void No_driver_keys_inherits_every_node_knob() { - var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, null, Node).ShouldNotBeNull(); + var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, null, Node, out _).ShouldNotBeNull(); resolved.MaxAllocBytesPerRun.ShouldBe(Node.MaxAllocBytesPerRun); resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(7); @@ -36,19 +36,32 @@ public sealed class CalculationScriptingOptionsResolutionTests [Fact] public void A_driver_key_overrides_only_its_own_knob_and_the_node_object_is_not_mutated() { - var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, 2, Node).ShouldNotBeNull(); + var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, 2, Node, out var adjustments).ShouldNotBeNull(); resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(2); resolved.MaxAllocBytesPerRun.ShouldBe(Node.MaxAllocBytesPerRun, "the unauthored key keeps the node value"); resolved.StuckOrphanQuarantineFactor.ShouldBe(4, "the stuck factor has no driver key; it is node-wide"); resolved.ShouldNotBeSameAs(Node); Node.QuarantineAfterConsecutiveTimeouts.ShouldBe(7); + adjustments.ShouldBeEmpty(); + } + + /// Review m-2: an out-of-range value authored in the driver's own config is clamped AND reported, so + /// the factory can log it rather than the guard changing it silently. + [Fact] + public void An_out_of_range_driver_key_is_clamped_and_reported() + { + var resolved = CalculationDriverFactoryExtensions.ResolveScripting(1024L, 0, Node, out var adjustments).ShouldNotBeNull(); + + resolved.MaxAllocBytesPerRun.ShouldBe(ScriptingOptions.MinMaxAllocBytesPerRun); + resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(1); + adjustments.Count.ShouldBe(2); } [Fact] public void A_driver_key_with_no_node_options_layers_on_the_compiled_defaults() { - var resolved = CalculationDriverFactoryExtensions.ResolveScripting(0L, null, null).ShouldNotBeNull(); + var resolved = CalculationDriverFactoryExtensions.ResolveScripting(0L, null, null, out _).ShouldNotBeNull(); resolved.MaxAllocBytesPerRun.ShouldBe(0); resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts); From 54d7a88862b9cd87feb4bb25d0fa0b234ef3676c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:15:31 -0400 Subject: [PATCH 53/67] docs: name the batched value path in ReadWriteOperations Review m-4. The status-code paragraph still traced the value path through the deleted per-value AttributeValuePublished. It now reads AttributeValueBatchPublished -> AttributeValueBatchUpdate -> IOpcUaAddressSpaceSink.WriteValues, which is what OpcUaPublishActor calls. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- docs/ReadWriteOperations.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/ReadWriteOperations.md b/docs/ReadWriteOperations.md index c1f8b03a..e519f5e4 100644 --- a/docs/ReadWriteOperations.md +++ b/docs/ReadWriteOperations.md @@ -39,8 +39,8 @@ This was not always true. Until #519 the publish path projected the driver's `ui `Good`/`Uncertain`/`Bad` enum in `DriverInstanceActor`, and `OtOpcUaNodeManager` re-expanded `Bad` to a bare `StatusCodes.Bad` (`0x80000000`). Every driver's diagnostic intent survived in logs only — including the 16 status constants #497 had just corrected across six drivers. The value path now -carries the raw `uint` end to end (`AttributeValuePublished` → `AttributeValueUpdate` → -`IOpcUaAddressSpaceSink.WriteValue`), and severity classification is a helper +carries the raw `uint` end to end (`AttributeValueBatchPublished` → `AttributeValueBatchUpdate` → +`IOpcUaAddressSpaceSink.WriteValues`), and severity classification is a helper (`OpcUaStatus.IsGood`/`IsUncertain`/`IsBad`) rather than a lossy projection. **Writing a status code in engine code:** use a named constant on From f5a392c684cc0af922371152bc5fea785b4f7834 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:15:41 -0400 Subject: [PATCH 54/67] docs(scripting): note that dot-completion walks the base chain for every receiver Review m-5. The ctx.CancellationToken completion fix (d2cc0715) made TryGetDotMembers include inherited instance members for EVERY receiver, not only ctx. The visible side effect beyond the item's scope: struct and enum receivers now also complete the System.ValueType / System.Enum instance members they inherit (CompareTo, HasFlag, ...). These are valid, compilable members, so the change is kept. This commit records it here and in the code comment. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../ScriptAnalysis/ScriptAnalysisService.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs index 9df80b09..23d610b7 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs @@ -271,7 +271,9 @@ public sealed class ScriptAnalysisService // The type's own members, then the INSTANCE members it inherits (stopping before System.Object, whose // ToString/GetHashCode noise was never offered). Without the base chain, a member declared only on // ScriptContext — ctx.CancellationToken — compiled and published but never completed. Inherited statics - // (ScriptContext.Deadband) are left out: they are not reachable through an instance receiver. + // (ScriptContext.Deadband) are left out: they are not reachable through an instance receiver. This applies to + // EVERY receiver, not only ctx: a struct or enum receiver now also offers the System.ValueType / + // System.Enum instance members it inherits (CompareTo, HasFlag, ...), all of which compile. var inherited = new List(); for (var b = type.BaseType; b is not null && b.SpecialType != SpecialType.System_Object; b = b.BaseType) inherited.AddRange(b.GetMembers().Where(m => !m.IsStatic)); From d7c0135d0ba950ed6a7dbec3dedc17142aeeabd4 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:15:49 -0400 Subject: [PATCH 55/67] docs(scripting): ctx.CancellationToken also fires on caller cancellation Review m-6. The XML doc said the token is cancelled on timeout only. ScriptRunGuard links it to the caller's token (CreateLinkedTokenSource(ct)), so it is also cancelled on shutdown or when the evaluating actor stops mid-run. The doc now says so. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../ScriptContext.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptContext.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptContext.cs index e293ff98..b4fdf3cd 100644 --- a/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptContext.cs +++ b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptContext.cs @@ -82,7 +82,8 @@ public abstract class ScriptContext /// /// The current run's cancellation token. The run guard cancels it when the run exceeds its wall-clock - /// budget, so a long-running script can poll ctx.CancellationToken.IsCancellationRequested (or call + /// budget, and it is linked to the caller's token, so it is also cancelled when the caller cancels (host + /// shutdown, an actor stopping mid-run). A long-running script can poll ctx.CancellationToken.IsCancellationRequested (or call /// ThrowIfCancellationRequested()) and end its own abandoned run instead of holding a pool thread /// until it returns. Cooperative only: a script that never looks at it still runs to completion as an /// orphan. outside a run. From b45568533173e9e243f62dbf000502be223be0f2 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:16:43 -0400 Subject: [PATCH 56/67] test(adminui): classify /fleet/diagnostics in the page authorization guard PageAuthorizationGuardTests.EveryRoutablePage_isClassifiedAndMatchesExpectedPolicy was red on master: the FleetDiagnostics page (lane A, 953bd275) was routable but unclassified. It declares AuthenticatedRead, the same policy as /fleet, and is classified as such. Count comment 16 -> 17. Refs #731 #734 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../Authorization/PageAuthorizationGuardTests.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs index b58c9500..231c61cc 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Authorization/PageAuthorizationGuardTests.cs @@ -57,10 +57,11 @@ public class PageAuthorizationGuardTests // AddSecretsAuthorization(). ── [typeof(SecretsAdmin)] = global::ZB.MOM.WW.Secrets.Ui.SecretsAuthorization.ManagePolicy, - // ── AuthenticatedRead (16): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ── + // ── AuthenticatedRead (17): dashboards, lists, live tails (read-only) + the dev ContextMenu demo ── [typeof(Home)] = AdminUiPolicies.AuthenticatedRead, [typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Dev.ContextMenuDemo)] = AdminUiPolicies.AuthenticatedRead, [typeof(Fleet)] = AdminUiPolicies.AuthenticatedRead, + [typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.FleetDiagnostics)] = AdminUiPolicies.AuthenticatedRead, [typeof(global::ZB.MOM.WW.OtOpcUa.AdminUI.Components.Pages.Hosts)] = AdminUiPolicies.AuthenticatedRead, [typeof(Alerts)] = AdminUiPolicies.AuthenticatedRead, [typeof(ScriptLog)] = AdminUiPolicies.AuthenticatedRead, From 17ae2eea0b0692454e0a72c1b75b0b2e9e84aade Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:16:49 -0400 Subject: [PATCH 57/67] fix(ablegacy): any answer restores Healthy; writes feed health and the backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r0 I2 + M1/M2/M4/M7/M10. - I2: one health writer (ApplyHealth) for reads AND writes, worst wins per call: a comms fault => Degraded; otherwise any answer (Good value, device refusal, Good or refused write) => Healthy; LastSuccessfulRead advances on any answer. A refusal-only read used to leave a recovered driver Degraded, and writes never touched health at all. - M10: a no-reply write STATUS now degrades like the exception path did; a Good/refused write records an answer on the device's backoff, a no-reply one a fault (bit RMW included — it now reports its raw status). - M2: a write inside the device's backoff window fails fast with BadCommunicationError even when its handle is cached. - M1: ShutdownAsync/Dispose write their final Unknown only while the epoch they retired is still current (a newer initialise owns health otherwise). - M4: an indirect bit write releases the previous index value's parent-word handle (the per-word RMW gate is deliberately kept: removing a gate another writer may hold would let two RMWs of one word race). - M7: backoff decisions read an injectable Clock; the isolation tests drop their stopwatch asserts and cross the window on a manual clock. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriver.cs | 188 ++++++++++----- .../AbLegacyDeviceIsolationTests.cs | 13 +- .../AbLegacyHealthEvidenceTests.cs | 222 ++++++++++++++++++ 3 files changed, 360 insertions(+), 63 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 2f303ee8..899e2c38 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -177,7 +177,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag // allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + runtimes BEFORE the // new table is published, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT // ShutdownAsync: that also drops _poll and every subscription, which are owned by the shutdown path (the actor re-subscribes after every successful init). - await ReleaseDeviceStatesAsync(RetireTables(bumpEpoch: false), "re-initialise").ConfigureAwait(false); + await ReleaseDeviceStatesAsync(RetireTables(bumpEpoch: false).States, "re-initialise").ConfigureAwait(false); SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Initializing, null, null)); _logger.LogInformation( "AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}", @@ -328,8 +328,9 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// is clearing. /// /// True for a teardown: supersedes any in-flight initialise. - /// The device states that were published. - private List RetireTables(bool bumpEpoch) + /// The device states that were published, and the epoch as of the retirement (a teardown writes its final + /// health only while that epoch is still current — review 07 r0 M1). + private (List States, long Epoch) RetireTables(bool bumpEpoch) { lock (_lifecycleLock) { @@ -340,7 +341,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag } var retired = _devices.Values.ToList(); _devices = new Dictionary(StringComparer.OrdinalIgnoreCase); - return retired; + return (retired, _lifecycleEpoch); } } @@ -401,15 +402,19 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag public async Task ShutdownAsync(CancellationToken cancellationToken) { // Unpublish FIRST, so an in-flight initialise is superseded and new reads resolve nothing. - var retired = RetireTables(bumpEpoch: true); + var (retired, teardownEpoch) = RetireTables(bumpEpoch: true); await _poll.DisposeAsync().ConfigureAwait(false); // Cancel the probe loops, JOIN them (bounded), then dispose the CTSs and runtimes. // The loop used to be fire-and-forget, so one parked in a probe-runtime create/read could // outlive shutdown and keep a libplctag handle alive past retirement. await ReleaseDeviceStatesAsync(retired, "shutdown").ConfigureAwait(false); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses + // A newer InitializeAsync that ran while this teardown was still joining owns health now. lock (_lifecycleLock) - _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + { + if (teardownEpoch == _lifecycleEpoch) + _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + } } /// @@ -445,6 +450,13 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// internal const int FaultsToMarkDeviceDown = 2; + /// + /// The clock every per-device backoff decision reads (window open? when does it end?). Wall-clock UTC in + /// production; a test substitutes a manual clock so the window can be crossed deterministically rather than by + /// sleeping (review 07 r0 M7). + /// + internal Func Clock { get; set; } = () => DateTime.UtcNow; + /// One resolved reference queued for its device's pass. private sealed record ReadItem(int Index, string Reference, AbLegacyTagDefinition Def, AbLegacyAddress Parsed); @@ -552,19 +564,30 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag anyAnswered |= o.AnyAnswered; fault ??= o.FaultMessage; } - if (fault is null && !anyGood && !anyAnswered) return; + ApplyHealth(epoch, fault, anyGood, anyAnswered, now); + } + /// + /// The single health write for a read or write call (worst wins): any comms fault ⇒ Degraded; otherwise any + /// answer — a Good value OR a device refusal, both prove the device replied — ⇒ Healthy (review 07 r0 I2: a + /// refusal-only pass used to leave a recovered driver Degraded). LastSuccessfulRead advances whenever + /// any device answered. A call that reached no wire changes nothing; a Faulted (config-level) state is kept. + /// Epoch-guarded so a late call cannot rewrite the health of a teardown or newer initialise. + /// + private void ApplyHealth(long epoch, string? fault, bool anyGood, bool anyAnswered, DateTime now) + { + if (fault is null && !anyGood && !anyAnswered) return; lock (_lifecycleLock) { if (epoch != _lifecycleEpoch) return; var current = _health; var lastRead = anyAnswered ? now : current.LastSuccessfulRead; - if (fault is not null) - _health = new DriverHealth(DriverState.Degraded, lastRead, fault); - else if (anyGood) - _health = new DriverHealth(DriverState.Healthy, lastRead, null); - else + if (current.State == DriverState.Faulted) _health = current with { LastSuccessfulRead = lastRead }; + else if (fault is not null) + _health = new DriverHealth(DriverState.Degraded, lastRead, fault); + else + _health = new DriverHealth(DriverState.Healthy, lastRead, null); } } @@ -577,7 +600,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag while (work.TryDequeue(out var unit)) { var members = unit.Block?.Members ?? [unit.Single!]; - if (device.IsThrottled(DateTime.UtcNow)) + if (device.IsThrottled(Clock())) { foreach (var m in members) results[m.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); @@ -613,7 +636,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag break; case OpVerdict.Fault: outcome.FaultMessage ??= message; - device.RecordCommsFault(DateTime.UtcNow); + device.RecordCommsFault(Clock()); break; case OpVerdict.Throttled: outcome.FaultMessage ??= message; @@ -785,6 +808,9 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag var epoch = CurrentEpoch; var results = new WriteResult[writes.Count]; var devices = _devices; // one snapshot of the published table for the whole call + // Writes feed health too (review 07 r0 I2): a Good or device-refused write proves the device answered; a + // no-reply failure is a comms fault. Collected per call and written once, worst wins. + var outcome = new DeviceReadOutcome(); for (var i = 0; i < writes.Count; i++) { @@ -817,6 +843,15 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag continue; } + // Inside the device's backoff window nothing is dialled — the write fails fast exactly as a read + // does, instead of paying the full Timeout on a handle that is still cached (review 07 r0 M2). + if (device.IsThrottled(Clock())) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); + outcome.FaultMessage ??= $"device {device.Options.HostAddress} is in its reconnect backoff window"; + continue; + } + // Indirect: resolve the index first (non-atomic with the write - the index may change // between the two PLC accesses), then write the concrete address through the normal path. if (parsed.IsIndirect) @@ -826,8 +861,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag { results[i] = new WriteResult(res.Status); if (res.TransportFault) - SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, - $"transport fault resolving indirect address for {w.FullReference}")); + { + device.RecordCommsFault(Clock()); + outcome.FaultMessage ??= $"transport fault resolving indirect address for {w.FullReference}"; + } continue; } def = res.Def; @@ -872,8 +909,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag // may reject it; we surface that real PCCC status rather than pre-rejecting at the driver. else if (def.DataType == AbLegacyDataType.Bit && parsed.BitIndex is int bit) { - results[i] = new WriteResult( - await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false)); + var (code, raw) = await WriteBitInWordAsync(device, def, parsed, bit, w.Value, cancellationToken) + .ConfigureAwait(false); + results[i] = new WriteResult(code); + RecordWriteStatus(device, def, raw, ref outcome); continue; } @@ -896,36 +935,23 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag opLock.Release(); } - if (status == 0) - { - device.RecordAnswered(); - results[i] = new WriteResult(AbLegacyStatusMapper.Good); - } - else if (IsDeviceRefusal((AbStatus)status)) - { - // The device answered and refused this write deterministically: keep the handle, warn once. - device.RecordAnswered(); - WarnRefusalOnce(device, def, "write", $"device status {(AbStatus)status}"); - results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status)); - } - else - { - // Evict the stale handle so the next call re-creates it (mirrors AbCip). - EvictRuntime(device, def.Name); - device.RecordCommsFault(DateTime.UtcNow); - results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status)); - } + results[i] = new WriteResult(status == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.MapStatus(status)); + // A no-reply status evicts the stale handle so the next call re-creates it (mirrors AbCip). + if (IsNoReply((AbStatus)status) && status != 0) EvictRuntime(device, def.Name); + RecordWriteStatus(device, def, status, ref outcome); } catch (OperationCanceledException) { throw; } - catch (ConnectionThrottledException) + catch (ConnectionThrottledException tex) { - // The device is in its reconnect backoff window: fail fast, no wire, no new evidence. + // The device is in its reconnect backoff window: fail fast, no wire, no new evidence for the backoff. results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); + outcome.FaultMessage ??= tex.Message; } catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status)) { device.RecordAnswered(); - WarnRefusalOnce(device, def, "write", ex.Message); + outcome.AnyAnswered = true; + WarnRefusalOnce(device, def, "write", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status)); results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(ex.Status)); } catch (NotSupportedException nse) @@ -953,15 +979,38 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag { // Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip). EvictRuntime(device, def.Name); - device.RecordCommsFault(DateTime.UtcNow); + device.RecordCommsFault(Clock()); results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); - SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message)); + outcome.FaultMessage ??= ex.Message; } } + ApplyHealth(epoch, outcome.FaultMessage, anyGood: outcome.AnyAnswered, anyAnswered: outcome.AnyAnswered, DateTime.UtcNow); return results; } + /// + /// Classifies a completed write's raw status into the call's health evidence and the device's backoff: + /// Good or a device refusal = answered; a no-reply status = comms fault (it used to leave health untouched on + /// this path while the exception path degraded — review 07 r0 M10); Unsupported = neutral. + /// + private void RecordWriteStatus(DeviceState device, AbLegacyTagDefinition def, int status, ref DeviceReadOutcome outcome) + { + var s = (AbStatus)status; + if (status == 0 || IsDeviceRefusal(s)) + { + device.RecordAnswered(); + outcome.AnyAnswered = true; + if (status != 0) + WarnRefusalOnce(device, def, "write", $"device status {s}", AbLegacyStatusMapper.MapStatus(status)); + } + else if (IsNoReply(s)) + { + device.RecordCommsFault(Clock()); + outcome.FaultMessage ??= $"transport status {status} writing {def.Name}"; + } + } + // ---- ITagDiscovery ---- /// @@ -1052,7 +1101,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag // The probe's verdict feeds the data path's backoff (review 07 P7): an answer resets the window so a // recovered device is read again at once; a comms fault counts toward marking it down. if (success || IsDeviceRefusal((AbStatus)status)) state.RecordAnswered(); - else if ((AbStatus)status != AbStatus.Unsupported) state.RecordCommsFault(DateTime.UtcNow); + else if ((AbStatus)status != AbStatus.Unsupported) state.RecordCommsFault(Clock()); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) @@ -1061,7 +1110,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag probeRuntime = null; state.ProbeInitialized = false; if (ex is AbTransportException ate && IsDeviceRefusal(ate.Status)) state.RecordAnswered(); - else if (!IsLocalRefusal(ex)) state.RecordCommsFault(DateTime.UtcNow); + else if (!IsLocalRefusal(ex)) state.RecordCommsFault(Clock()); } TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); @@ -1155,8 +1204,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// . The parent word is masked to its native width before the /// bit operation so a sign-extended decode never corrupts the high bits. /// - private async Task WriteBitInWordAsync( - AbLegacyDriver.DeviceState device, AbLegacyAddress bitAddress, int bit, object? value, CancellationToken ct) + /// The OPC UA status the write is answered with, and the raw transport status (0 = Good) for health. + private async Task<(uint Code, int Raw)> WriteBitInWordAsync( + AbLegacyDriver.DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress bitAddress, int bit, object? value, + CancellationToken ct) { // The parent word width follows the file letter: an L-file element is 32-bit, every // other bit-addressable PCCC file (N/I/O/S/A) is a 16-bit word. bit is already @@ -1169,6 +1220,23 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag var parentAddress = bitAddress with { BitIndex = null }; var parentName = parentAddress.ToLibplctagName(); + // An indirect bit write (N7:[N7:0]/3) resolves to a different parent word per index value: release the + // previous resolution's parent handle so a sweeping index does not keep one per word (review 07 r0 M4). The + // per-word RMW gate is kept — removing a gate another writer may hold would let two RMWs of one word race. + var arrow = def.Name.IndexOf("=>", StringComparison.Ordinal); + if (arrow > 0) + { + var indirectName = def.Name[..arrow]; + var previous = device.IndirectLastParent.TryGetValue(indirectName, out var p) ? p : null; + device.IndirectLastParent[indirectName] = parentName; + if (previous is not null && !string.Equals(previous, parentName, StringComparison.OrdinalIgnoreCase)) + { + var oldGate = device.GetRmwLock(previous); + await oldGate.WaitAsync(ct).ConfigureAwait(false); + try { EvictParentRuntime(device, previous); } finally { oldGate.Release(); } + } + } + var rmwLock = device.GetRmwLock(parentName); await rmwLock.WaitAsync(ct).ConfigureAwait(false); try @@ -1176,7 +1244,11 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false); await parentRuntime.ReadAsync(ct).ConfigureAwait(false); var readStatus = parentRuntime.GetStatus(); - if (readStatus != 0) return AbLegacyStatusMapper.MapStatus(readStatus); + if (readStatus != 0) + { + if (IsNoReply((AbStatus)readStatus)) EvictParentRuntime(device, parentName); + return (AbLegacyStatusMapper.MapStatus(readStatus), readStatus); + } // Mask the decoded word to its native width: DecodeValue for a 16-bit Int returns a // sign-extended int, so a word with bit 15 set decodes negative. Masking pins the @@ -1193,9 +1265,8 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag parentRuntime.EncodeValue(AbLegacyDataType.Int, bitIndex: null, (short)updated); await parentRuntime.WriteAsync(ct).ConfigureAwait(false); var writeStatus = parentRuntime.GetStatus(); - return writeStatus == 0 - ? AbLegacyStatusMapper.Good - : AbLegacyStatusMapper.MapStatus(writeStatus); + if (writeStatus != 0 && IsNoReply((AbStatus)writeStatus)) EvictParentRuntime(device, parentName); + return (writeStatus == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.MapStatus(writeStatus), writeStatus); } finally { @@ -1381,7 +1452,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// (). The HOT path (cached handle) never touches the gate. The /// caller records the outcome on the device's backoff (once — recording here as well would double-count). /// - private static async Task CreateSingleFlightAsync( + private async Task CreateSingleFlightAsync( DeviceState device, System.Collections.Concurrent.ConcurrentDictionary table, string key, @@ -1395,7 +1466,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag { // Re-check under the gate: a sibling caller may have connected this very key while we waited. if (table.TryGetValue(key, out existing)) return existing; - if (device.IsThrottled(DateTime.UtcNow)) + if (device.IsThrottled(Clock())) throw new ConnectionThrottledException( $"AbLegacy connect for '{key}' on {device.Options.HostAddress} throttled: the device is in its reconnect backoff window."); @@ -1544,7 +1615,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// public void Dispose() { - var retired = RetireTables(bumpEpoch: true); + var (retired, teardownEpoch) = RetireTables(bumpEpoch: true); // ValueTask.ConfigureAwait(false).GetAwaiter().GetResult() — drains the poll engine // shutdown on the current thread without capturing the SynchronizationContext. The // engine cancels every loop CTS up-front then either completes immediately (no @@ -1557,8 +1628,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag JoinProbeLoopsSync(retired); foreach (var state in retired) state.Retire(); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses + // A newer InitializeAsync that ran while this teardown was still joining owns health now. lock (_lifecycleLock) - _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + { + if (teardownEpoch == _lifecycleEpoch) + _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + } } /// @@ -1620,6 +1695,11 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag public System.Collections.Concurrent.ConcurrentDictionary IndirectLastName { get; } = new(StringComparer.OrdinalIgnoreCase); + /// Indirect bit-write tag name to the parent word it last read-modify-wrote, so a moved index releases + /// the previous parent handle. + public System.Collections.Concurrent.ConcurrentDictionary IndirectLastParent { get; } = + new(StringComparer.OrdinalIgnoreCase); + /// /// Per-device connect gate (review 07 P7): every COLD runtime create + initialise on this device runs under /// it, so connects are single-flight per device — a down device sees one attempt at a time, not one per diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs index 213fea69..16064b8d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -67,12 +66,9 @@ public sealed class AbLegacyDeviceIsolationTests var before = drv.GetHealth().LastSuccessfulRead; Interlocked.Exchange(ref DeadTag.WireOps, 0); - var sw = Stopwatch.StartNew(); var first = await drv.ReadAsync(refs, CancellationToken.None); - sw.Stop(); - // Serial-with-no-backoff would pay 20 x 150 ms = 3 s on device B alone. - sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1.5)); + // The proof is the wire-op count, not a stopwatch: serial-with-no-backoff would dial B 20 times. Volatile.Read(ref DeadTag.WireOps).ShouldBeLessThanOrEqualTo(2); first.Take(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); // positive control: A is served first.Skip(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError); @@ -83,11 +79,8 @@ public sealed class AbLegacyDeviceIsolationTests // Inside B's backoff window the next poll touches B's wire not at all. var opsAfterFirst = Volatile.Read(ref DeadTag.WireOps); - sw.Restart(); var second = await drv.ReadAsync(refs, CancellationToken.None); - sw.Stop(); Volatile.Read(ref DeadTag.WireOps).ShouldBe(opsAfterFirst); - sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(500)); second.Take(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); await drv.ShutdownAsync(CancellationToken.None); } @@ -239,6 +232,8 @@ public sealed class AbLegacyDeviceIsolationTests }, }; var drv = NewDriver(factory, new AbLegacyTagDefinition("B", HostB, "N7:0", AbLegacyDataType.Int)); + var now = new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc); + drv.Clock = () => now; await drv.InitializeAsync("{}", CancellationToken.None); // Two consecutive comms faults mark the device down and open its first (1 s) window. @@ -249,7 +244,7 @@ public sealed class AbLegacyDeviceIsolationTests // Inside the window: fails fast without a connect attempt, although the device is back. (await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); Volatile.Read(ref creates).ShouldBe(attempts); - await Task.Delay(TimeSpan.FromMilliseconds(1100)); + now = now.AddMilliseconds(1100); // cross the first (1 s) window on the driver's clock, no sleeping var recovered = await drv.ReadAsync(["B"], CancellationToken.None); recovered[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs new file mode 100644 index 00000000..a982e84e --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs @@ -0,0 +1,222 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; + +/// +/// Review 07 r0 I2 + M1/M2/M4/M10: any evidence that the device answered — a Good value, a device refusal, a Good +/// or refused write — restores Healthy and advances LastSuccessfulRead; a no-reply failure on either path +/// degrades; writes respect the device's backoff window; teardown health is epoch-guarded. +/// +[Trait("Category", "Unit")] +public sealed class AbLegacyHealthEvidenceTests +{ + private const string Host = "ab://10.0.0.5/1,0"; + + private sealed class SwitchableFactory : IAbLegacyTagFactory + { + public Func Make { get; set; } = p => new FakeAbLegacyTag(p) { Value = 1 }; + public System.Collections.Concurrent.ConcurrentDictionary Tags { get; } = new(); + + public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) => Tags[p.TagName] = Make(p); + } + + private static FakeAbLegacyTag Dead(AbLegacyTagCreateParams p) => + new(p) { ThrowOnRead = true, ThrowOnWrite = true, Exception = new System.Net.Sockets.SocketException(10060) }; + + private static AbLegacyDriver NewDriver(IAbLegacyTagFactory f, params AbLegacyTagDefinition[] tags) => + new(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host)], + RawTags = AbLegacyRawTags.Entries(tags), + Probe = new AbLegacyProbeOptions { Enabled = false }, + CoalesceReads = false, + }, "drv-health", f); + + private static async Task DegradedDriverAsync(SwitchableFactory f, params AbLegacyTagDefinition[] tags) + { + var drv = NewDriver(f, tags); + await drv.InitializeAsync("{}", CancellationToken.None); + f.Make = Dead; + (await drv.ReadAsync([tags[0].Name], CancellationToken.None))[0].StatusCode + .ShouldBe(AbLegacyStatusMapper.BadCommunicationError); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); // precondition + return drv; + } + + [Fact] + public async Task A_refusal_only_read_after_a_fault_restores_Healthy_and_advances_LastSuccessfulRead() + { + var f = new SwitchableFactory(); + var drv = await DegradedDriverAsync(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)); + var before = drv.GetHealth().LastSuccessfulRead; + f.Make = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound }; + await Task.Delay(5); + + var r = await drv.ReadAsync(["X"], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); + var h = drv.GetHealth(); + h.State.ShouldBe(DriverState.Healthy); + h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Theory] + [InlineData(0)] // a Good write + [InlineData((int)AbStatus.NotAllowed)] // a write the device refused + public async Task A_good_or_refused_write_after_a_fault_restores_Healthy(int writeStatus) + { + var f = new SwitchableFactory(); + var drv = await DegradedDriverAsync(f, + new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int), + new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int)); + var before = drv.GetHealth().LastSuccessfulRead; + f.Make = p => new FakeAbLegacyTag(p) { WriteStatusOverride = writeStatus }; + await Task.Delay(5); + + var w = await drv.WriteAsync([new WriteRequest("W", 3)], CancellationToken.None); + + w[0].StatusCode.ShouldBe(writeStatus == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.BadNotWritable); + f.Tags["N7:5"].WriteCount.ShouldBe(1); // positive control: the write reached the device + var h = drv.GetHealth(); + h.State.ShouldBe(DriverState.Healthy); + h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task A_no_reply_write_status_degrades_like_the_exception_path_does() + { + var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { WriteStatusOverride = (int)AbStatus.Timeout } }; + var drv = NewDriver(f, new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var w = await drv.WriteAsync([new WriteRequest("W", 3)], CancellationToken.None); + + w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTimeout); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task One_write_call_is_worst_wins_but_still_advances_LastSuccessfulRead() + { + var f = new SwitchableFactory + { + Make = p => p.TagName == "N7:6" ? Dead(p) : new FakeAbLegacyTag(p) { Value = 1 }, + }; + var drv = NewDriver(f, + new AbLegacyTagDefinition("Good", Host, "N7:5", AbLegacyDataType.Int), + new AbLegacyTagDefinition("Bad", Host, "N7:6", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + var before = drv.GetHealth().LastSuccessfulRead; + await Task.Delay(5); + + var w = await drv.WriteAsync([new WriteRequest("Good", 1), new WriteRequest("Bad", 2)], CancellationToken.None); + + w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + w[1].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); + var h = drv.GetHealth(); + h.State.ShouldBe(DriverState.Degraded); + h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task A_write_inside_the_backoff_window_fails_fast_even_with_a_cached_handle() + { + var now = new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc); + var f = new SwitchableFactory(); + var drv = NewDriver(f, + new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int), + new AbLegacyTagDefinition("D1", Host, "N7:6", AbLegacyDataType.Int), + new AbLegacyTagDefinition("D2", Host, "N7:7", AbLegacyDataType.Int)); + drv.Clock = () => now; + await drv.InitializeAsync("{}", CancellationToken.None); + (await drv.ReadAsync(["W"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // cache W + var cached = f.Tags["N7:5"]; + f.Make = Dead; + await drv.ReadAsync(["D1", "D2"], CancellationToken.None); // two consecutive no-reply faults: device down + drv.GetDeviceState(Host)!.IsThrottled(now).ShouldBeTrue(); + + var w = await drv.WriteAsync([new WriteRequest("W", 9)], CancellationToken.None); + + w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); + cached.WriteCount.ShouldBe(0); + + // Positive control: once the window has passed, the same cached handle is written. + now = now.AddSeconds(2); + (await drv.WriteAsync([new WriteRequest("W", 9)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + cached.WriteCount.ShouldBe(1); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task An_indirect_bit_write_releases_the_previous_parent_word_handle() + { + var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? (short)5 : (short)0 } }; + var drv = NewDriver(f, new AbLegacyTagDefinition("IndBit", Host, "N7:[N7:0]/3", AbLegacyDataType.Bit)); + await drv.InitializeAsync("{}", CancellationToken.None); + + (await drv.WriteAsync([new WriteRequest("IndBit", true)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var state = drv.GetDeviceState(Host)!; + state.ParentRuntimes.ContainsKey("N7:5").ShouldBeTrue(); // positive control: the parent word was RMW'd + f.Tags["N7:0"].Value = (short)6; + (await drv.WriteAsync([new WriteRequest("IndBit", true)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + + state.ParentRuntimes.ContainsKey("N7:5").ShouldBeFalse(); + state.ParentRuntimes.ContainsKey("N7:6").ShouldBeTrue(); + f.Tags["N7:5"].Disposed.ShouldBeTrue(); + await drv.ShutdownAsync(CancellationToken.None); + } + + /// A probe read that parks ignoring cancellation, so ShutdownAsync is still joining. + private sealed class ParkingTag(AbLegacyTagCreateParams p, Task release, TaskCompletionSource entered) : FakeAbLegacyTag(p) + { + public override async Task ReadAsync(CancellationToken ct) + { + entered.TrySetResult(); + await release.ConfigureAwait(false); + } + } + + [Fact] + public async Task A_slow_teardown_does_not_overwrite_a_newer_initialise_health() + { + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var parked = false; + var f = new SwitchableFactory + { + Make = p => + { + if (parked) return new FakeAbLegacyTag(p) { Value = 1 }; + parked = true; + return new ParkingTag(p, release.Task, entered); + }, + }; + var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(Host)], + Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) }, + }, "drv-m1", f); + await drv.InitializeAsync("{}", CancellationToken.None); + await entered.Task; + + var shutdown = drv.ShutdownAsync(CancellationToken.None); // parks joining the wedged probe loop + await Task.Delay(50); + shutdown.IsCompleted.ShouldBeFalse(); + await drv.InitializeAsync("{}", CancellationToken.None); // a newer initialise wins + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + release.SetResult(); + await shutdown; + + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + // Positive control: an uncontested shutdown still writes Unknown. + await drv.ShutdownAsync(CancellationToken.None); + drv.GetHealth().State.ShouldBe(DriverState.Unknown); + } +} From d758dd1e168261a3c0fcd6a6e7751a250229fd99 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:20:10 -0400 Subject: [PATCH 58/67] fix(ablegacy): encode array writes at the data file's element width Review r0 I3. The array encoder used the AUTHORED dataType's width while both runtimes size their buffer from the FILE: an Int array on F8 wrote four shorts into a 16-byte float buffer and sent it all (garbage neighbours, answered Good); Long/Float on N overran it. - The write path resolves the element width from the data file the address names (N/B/A/I/O/S 16-bit word, L 32-bit, F float) and encodes at THAT width. - An authored type that does not match its file is refused BadTypeMismatch before any runtime exists (warned once, no health write). - The same rule (AbLegacyTagDefinitionFactory.ArrayFileMismatch) backs an Inspect deploy warning and the AdminUI model's Validate error; FromTagConfig still accepts the tag so persisted configs keep loading. - M6: the skipped F1 parity test gains the shapes the write path refuses (/bit, .sub, indirect, type/file mismatch, PLC-5 native). Tests: one case per file type, a mismatch per pairing with nothing created, and a positive control. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyTagDefinitionFactory.cs | 51 +++++++++++++++ .../AbLegacyDriver.cs | 25 ++++++-- .../Uns/TagEditors/AbLegacyTagConfigModel.cs | 5 ++ .../AbLegacyArrayWriteSupportParityTests.cs | 19 +++++- .../AbLegacyArrayWriteTests.cs | 62 +++++++++++++++++++ .../Uns/AbLegacyTagConfigModelTests.cs | 18 ++++++ 6 files changed, 175 insertions(+), 5 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs index c6ab0a97..ea87f079 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs @@ -177,6 +177,9 @@ public static class AbLegacyTagDefinitionFactory { if (AbLegacyAddress.Validate(accepted.Address, family) is { } addressError) warnings.Add($"AbLegacy tag address: {addressError} The driver reads this tag as BadNodeIdUnknown."); + else if (accepted.ArrayLength is not null + && ArrayFileMismatch(accepted.DataType, accepted.Address) is { } widthError) + warnings.Add(widthError); return warnings; } @@ -252,6 +255,54 @@ public static class AbLegacyTagDefinitionFactory $"AbLegacy {type} is not supported as an array element (String, Timer, Counter and Control elements are " + "structured); author a scalar tag per element instead. The driver skips this tag and it reads BadNodeIdUnknown."; + /// + /// The element width an array on 's data file has, as the data type that decodes / + /// encodes it: N, B, A and the I/O/S word files are 16-bit words (), L is + /// 32-bit (), F is IEEE float (). + /// for a file with no flat array layout (ST, T, C, R, function files) or an address that + /// does not parse. + /// + /// The authored address. + /// The file's element type, or . + public static AbLegacyDataType? ArrayFileElementType(string address) => + AbLegacyAddress.TryParse(address) is { } a ? ArrayFileElementType(a) : null; + + /// + public static AbLegacyDataType? ArrayFileElementType(AbLegacyAddress a) => a.FileLetter switch + { + "N" or "B" or "A" or "I" or "O" or "S" => AbLegacyDataType.Int, + "L" => AbLegacyDataType.Long, + "F" => AbLegacyDataType.Float, + _ => null, + }; + + /// + /// Why an ARRAY authored as on does not match its data + /// file's element width (review 07 r0 I3), or when it does. Int / AnalogInt need a 16-bit + /// word file (N/B/A/I/O/S), Long an L file, Float an F file; Bit arrays are bit-packed words and fit any 16-bit + /// word file. A mismatch reads the file at the wrong width (garbage) and the driver refuses to WRITE it + /// (BadTypeMismatch), because encoding at the authored width would corrupt the neighbouring elements. + /// + /// The authored element data type. + /// The authored address. + /// The mismatch message, or . + public static string? ArrayFileMismatch(AbLegacyDataType type, string address) + { + if (!IsArrayElementTypeSupported(type)) return null; // structured arrays have their own refusal + if (AbLegacyAddress.TryParse(address) is not { } a || a.IsIndirect) return null; + var file = ArrayFileElementType(a); + var ok = type switch + { + AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit => file == AbLegacyDataType.Int, + _ => file == type, + }; + return ok + ? null + : $"AbLegacy {type} array on '{address}' does not match the {a.FileLetter} file's element width " + + "(Int/AnalogInt/Bit need an N/B/A word file, Long an L file, Float an F file): reads decode the wrong width " + + "and writes are refused BadTypeMismatch."; + } + private static int ReadInt(JsonElement o, string name) => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) ? v : 0; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 899e2c38..ffc96f46 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -885,12 +885,29 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag // Array writes (review 07 U10): convert and length-check BEFORE any runtime exists, then encode every // element. A bit / sub-element array has no flat encoding. + // The encode width is the DATA FILE's element width (review 07 r0 I3), never the authored type's: an + // authored type that does not match its file is refused BadTypeMismatch before anything is encoded. Array? arrayValue = null; + AbLegacyDataType arrayFileType = default; if (IsArrayTag(def)) { - var conversion = parsed.BitIndex is not null || parsed.SubElement is not null - ? AbLegacyStatusMapper.BadNotSupported - : AbLegacyArrayWriteEncoder.TryConvert(def.DataType, w.Value, EffectiveArrayLength(def), out arrayValue); + var fileType = AbLegacyTagDefinitionFactory.ArrayFileElementType(parsed); + var widthRefusal = AbLegacyTagDefinitionFactory.ArrayFileMismatch(def.DataType, def.Address) + ?? (fileType is null ? $"{def.Address} has no flat array layout." : null); + uint conversion; + if (parsed.BitIndex is not null || parsed.SubElement is not null || def.DataType == AbLegacyDataType.Bit) + conversion = AbLegacyStatusMapper.BadNotSupported; + else if (widthRefusal is not null || fileType is null) + { + WarnRefusalOnce(device, def, "write", widthRefusal ?? "no flat array layout", AbLegacyStatusMapper.BadTypeMismatch); + results[i] = new WriteResult(AbLegacyStatusMapper.BadTypeMismatch); + continue; + } + else + { + arrayFileType = fileType.Value; + conversion = AbLegacyArrayWriteEncoder.TryConvert(arrayFileType, w.Value, EffectiveArrayLength(def), out arrayValue); + } if (conversion != AbLegacyStatusMapper.Good) { if (conversion == AbLegacyStatusMapper.BadNotSupported) @@ -925,7 +942,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag await opLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (arrayValue is not null) runtime.EncodeArray(def.DataType, arrayValue); + if (arrayValue is not null) runtime.EncodeArray(arrayFileType, arrayValue); else runtime.EncodeValue(def.DataType, parsed.BitIndex, w.Value); await runtime.WriteAsync(cancellationToken).ConfigureAwait(false); status = runtime.GetStatus(); diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs index dcb67114..4d5a88ee 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs @@ -92,6 +92,11 @@ public sealed class AbLegacyTagConfigModel if (isArray && arrayLength >= 1 && !AbLegacyTagDefinitionFactory.IsArrayElementTypeSupported(DataType)) return AbLegacyTagDefinitionFactory.ArrayElementTypeError(DataType); + // An array must match its data file's element width (review 07 r0 I3): the driver refuses to write a + // mismatched one, and reads it at the wrong width. + if (isArray && arrayLength >= 1 && AbLegacyTagDefinitionFactory.ArrayFileMismatch(DataType, Address.Trim()) is { } widthError) + return widthError; + // Parse-gate backstop: the checks above mirror the reader's whole rejection surface today, so this is // expected to be silent for AbLegacy. It is here so a future reader rule cannot become a gap. return TagConfigParseGate.Describe( diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs index acae0cdd..3f5a50f6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs @@ -43,7 +43,24 @@ public sealed class AbLegacyArrayWriteSupportParityTests DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json).ShouldBe(encoderAccepts, dataType.ToString()); } - /// What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float). + /// + /// The shapes the WRITE PATH refuses although their element type alone looks writable (review 07 r0 M6): a + /// /bit or .sub address, an indirect address, a type that does not match its data file (I3), and + /// a native PLC-5 device. The F1 row must answer false for every one — keyed on dataType alone it would + /// advertise them writable. Skipped with the parity theory until F1 lands. + /// + [Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")] + [InlineData("""{"address":"N7:0/3","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")] + [InlineData("""{"address":"T4:0.ACC","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")] + [InlineData("""{"address":"N7:[N7:0]","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")] + [InlineData("""{"address":"F8:0","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")] + [InlineData("""{"address":"N7:0","dataType":"Long","isArray":true,"arrayLength":2}""", "{}")] + [InlineData("""{"address":"N7:0","dataType":"Int","isArray":true,"arrayLength":2}""", + """{"transport":"Native","devices":[{"hostAddress":"ab://10.0.0.5/1,0","plcFamily":"Plc5"}]}""")] + public void Table_refuses_the_shapes_the_write_path_refuses(string tagConfig, string driverConfig) => + DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", tagConfig, driverConfig).ShouldBeFalse(); + + /// What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float on a matching file). [Theory] [InlineData(AbLegacyDataType.Int, true)] [InlineData(AbLegacyDataType.AnalogInt, true)] diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs index 0ef846a0..20d9f203 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs @@ -72,6 +72,68 @@ public sealed class AbLegacyArrayWriteTests drv.GetHealth().State.ShouldBe(DriverState.Healthy); } + /// Review 07 r0 I3: every data file type encodes at ITS element width (one case per file). + [Theory] + [InlineData("N7:0", AbLegacyDataType.Int, AbLegacyDataType.Int)] + [InlineData("N7:0", AbLegacyDataType.AnalogInt, AbLegacyDataType.Int)] + [InlineData("B3:0", AbLegacyDataType.Int, AbLegacyDataType.Int)] + [InlineData("A9:0", AbLegacyDataType.Int, AbLegacyDataType.Int)] + [InlineData("L9:0", AbLegacyDataType.Long, AbLegacyDataType.Long)] + [InlineData("F8:0", AbLegacyDataType.Float, AbLegacyDataType.Float)] + public async Task Each_file_type_encodes_at_the_file_element_width(string address, AbLegacyDataType authored, AbLegacyDataType fileWidth) + { + var (drv, f) = await NewAsync(authored, address, 2); + + var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2 })], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var tag = (RecordingTag)f.Tags[address]; + tag.EncodedType.ShouldBe(fileWidth); + tag.Encoded!.GetType().ShouldBe(fileWidth switch + { + AbLegacyDataType.Long => typeof(int[]), + AbLegacyDataType.Float => typeof(float[]), + _ => typeof(short[]), + }); + tag.WriteCount.ShouldBe(1); + } + + /// + /// Review 07 r0 I3: an array whose authored type does not match its data file (Int on F8, Long on N7, Float on + /// L9) used to be encoded at the AUTHORED width into the file-typed buffer and sent — garbage in the + /// neighbouring elements, answered Good. It is now refused BadTypeMismatch before any runtime exists. + /// + [Theory] + [InlineData("F8:0", AbLegacyDataType.Int)] + [InlineData("N7:0", AbLegacyDataType.Long)] + [InlineData("N7:0", AbLegacyDataType.Float)] + [InlineData("L9:0", AbLegacyDataType.Float)] + [InlineData("L9:0", AbLegacyDataType.Int)] + public async Task A_type_that_does_not_match_its_file_is_refused_and_never_written(string address, AbLegacyDataType authored) + { + var (drv, f) = await NewAsync(authored, address, 4); + + var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch); + f.Tags.ShouldBeEmpty(); // nothing created, nothing on the wire + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + AbLegacyTagDefinitionFactory.Inspect( + $$"""{"address":"{{address}}","dataType":"{{authored}}","isArray":true,"arrayLength":4}""") + .ShouldContain(w => w.Contains("element width")); // and the deploy gate names it + } + + [Fact] + public async Task Positive_control_a_Float_array_on_an_F_file_is_written() + { + var (drv, f) = await NewAsync(AbLegacyDataType.Float, "F8:0", 4); + (await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None))[0] + .StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + ((RecordingTag)f.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1f, 2f, 3f, 4f }); + AbLegacyTagDefinitionFactory.Inspect("""{"address":"F8:0","dataType":"Float","isArray":true,"arrayLength":4}""") + .ShouldBeEmpty(); + } + [Fact] public async Task Float_and_Long_arrays_encode_at_their_element_width() { diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs index 2bf866bb..5d8ebfe1 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs @@ -136,6 +136,24 @@ public sealed class AbLegacyTagConfigModelTests AbLegacyTagConfigModel.FromJson($$"""{"address":"{{address}}","dataType":"{{dataType}}"}""").Validate().ShouldBeNull(); } + /// Review 07 r0 I3: an array must match its data file's element width. + [Theory] + [InlineData("F8:0", "Int", false)] + [InlineData("N7:0", "Long", false)] + [InlineData("N7:0", "Int", true)] + [InlineData("L9:0", "Long", true)] + [InlineData("F8:0", "Float", true)] + public void Validate_refuses_an_array_whose_type_does_not_match_its_file(string address, string dataType, bool valid) + { + var m = AbLegacyTagConfigModel.FromJson( + $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}"""); + + if (valid) m.Validate().ShouldBeNull(); + else m.Validate().ShouldNotBeNull().ShouldContain("element width"); + // A SCALAR of the same shape is not this rule's business. + AbLegacyTagConfigModel.FromJson($$"""{"address":"{{address}}","dataType":"{{dataType}}"}""").Validate().ShouldBeNull(); + } + [Fact] public void Validate_returns_null_for_scalar_tag_regardless_of_no_array_length() { From a1f20e78f905bd0b5411847e8c71b508366967d8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:20:51 -0400 Subject: [PATCH 59/67] fix(s7): a CPU refusal is per-tag Bad and counts as answered (R07:H4) A deterministic PLC refusal (object does not exist, address out of range, type inconsistent...) degraded driver health, and an all-refused driver (a wrong DB number) sat Degraded with LastSuccessfulRead frozen, so the 90 s comms-loss watchdog reconnected it forever against a reachable PLC. - IsRefusal / IsAccessDenied classify over the whole exception chain. Live probe on the snap7 fixture: S7.Net's READ paths raise the item error as a BARE Exception ("Received error from PLC: ..."), its write paths as PlcException(WriteData). The old PlcException-only match missed the read shape entirely, so a real read access-denied was BadCommunicationError + Degraded instead of the Faulted PUT/GET alert; the Test Connect probe had the same blind spot. - A refusal (read or write) is BadDeviceFailure with a once-per-tag Warning, no health write and no reopen; it counts as answered, so it advances LastSuccessfulRead and restores Healthy after a comms fault. An undecodable buffer (S7ValueRangeException) also counts as answered. - A write batch the CPU answered without a failure advances LastSuccessfulRead and retires a Degraded the write side itself published. - Tests that used ReadData as their generic "device fault" now use SendData so their Degraded leg is still exercised. Refs #742 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- docs/drivers/S7.md | 15 +- .../ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 192 ++++++++++++++--- .../S7DriverProbe.cs | 10 +- .../S7_1500/S7_1500RefusalTests.cs | 83 ++++++++ .../CapturingS7Logger.cs | 32 +++ .../S7RefusalHealthTests.cs | 193 ++++++++++++++++++ .../S7SpanReadParityTests.cs | 22 +- .../S7TsapAndPduTests.cs | 17 ++ 8 files changed, 515 insertions(+), 49 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500RefusalTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/CapturingS7Logger.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RefusalHealthTests.cs diff --git a/docs/drivers/S7.md b/docs/drivers/S7.md index 18e07821..448c7689 100644 --- a/docs/drivers/S7.md +++ b/docs/drivers/S7.md @@ -312,7 +312,8 @@ Portal under *Protection & Security* for the CPU. | Read-only tag written | `BadNotWritable` | unchanged | | Unimplemented data type | `BadNotSupported` | unchanged | | PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) | -| CPU / hardware fault | `BadDeviceFailure` | `Degraded` | +| CPU refusal of the item (object does not exist, address out of range, type inconsistent, hardware fault) | `BadDeviceFailure` | unchanged — counts as *answered*, advances `LastSuccessfulRead`; one Warning per tag | +| Other device-layer fault (`PlcException` that is neither a refusal nor connection-fatal) | `BadDeviceFailure` | `Degraded` | | Socket / timeout | `BadCommunicationError` | `Degraded` | | Reconnect suppressed (backoff window) | `BadCommunicationError` | `Degraded` | | Array write of the wrong shape / length / element type | `BadTypeMismatch` | unchanged | @@ -323,7 +324,17 @@ accumulate their outcomes and write health once (`Healthy < Degraded < Faulted`) `Faulted` item is not erased by a later `Degraded` or successful sibling. The write-side `Faulted` is **sticky against reads**: a clean read pass (reads often work against a write-protected CPU) leaves `Faulted` in place and only advances `LastSuccessfulRead`; the next write that succeeds clears it. A -transient `Degraded` write failure is still cleared by a clean read, as before. +transient `Degraded` write failure is cleared by a clean read, or by the next write batch the CPU +answers without a failure. + +**A refusal is an answer (R07:H4).** S7.Net's read paths raise the CPU's item error as a bare +`Exception` ("Received error from PLC: Object does not exist." — live-probed on the snap7 fixture), its +write paths as a `PlcException(WriteData)`; both are classified over the whole exception chain, and the +access-denied text inside either is the `Faulted` alert. A refusal is deterministic, so it neither +degrades health nor reopens the connection, and because it proves the CPU is reachable it advances +`LastSuccessfulRead` and restores `Healthy` after an earlier comms fault. Before this, a driver whose +every tag was refused (a wrong DB number) sat `Degraded` with a frozen timestamp and the comms-loss +watchdog reconnected it every 90 s against a perfectly reachable PLC. **Connect backoff.** A dead PLC costs one connect attempt per backoff window (1 s doubling to a 30 s cap), not one per read / poll tick / probe tick: inside the window `EnsureConnectedAsync` fails fast diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index acade74e..f1136908 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -208,6 +208,19 @@ public sealed class S7Driver /// Guarded by . /// private (DriverState State, string Message)? _writeFault; + + /// + /// The message of the last Degraded the WRITE side published, or null. A clean write batch + /// retires a Degraded carrying exactly this message (a good write proves the comms fault it + /// reported is gone) and leaves any other Degraded to the read pass. Guarded by . + /// + private string? _writeOwnedError; + + /// + /// Tags whose refusal has already been logged this generation — the once-per-tag Warning. Cleared + /// by every initialize publish. + /// + private readonly ConcurrentDictionary _refusalWarned = new(StringComparer.Ordinal); private bool _disposed; /// @@ -797,7 +810,7 @@ public sealed class S7Driver } catch (Exception ex) { - results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now); + results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [resolved[i]!.Name]), null, now); } } } @@ -903,6 +916,13 @@ public sealed class S7Driver /// Records that one tag read successfully. public void RecordSuccess() => AnySuccess = true; + /// + /// Records that the CPU answered for one tag without serving it (a refusal, an undecodable + /// buffer). Not a failure — it ranks nothing — but it IS proof of a live device, so it + /// advances LastSuccessfulRead exactly like a success. + /// + public void RecordAnswered() => AnySuccess = true; + /// /// Records one failed tag, keeping the worst state and the message that explains it. /// @@ -1098,7 +1118,7 @@ public sealed class S7Driver { // Decoding is pure and synchronous — no cancellation can surface here, so there // is deliberately no OperationCanceledException arm to shadow the request one. - results[index] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now); + results[index] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [tag.Name]), null, now); } } } @@ -1195,7 +1215,7 @@ public sealed class S7Driver } catch (Exception ex) { - var status = RecordReadFailure(ex, outcome); + var status = RecordReadFailure(ex, outcome, span.Members.Select(m => m.Tag.Name)); foreach (var member in span.Members) results[member.OriginalIndex] = new DataValueSnapshot(null, status, null, now); return; @@ -1214,7 +1234,7 @@ public sealed class S7Driver // Decoding is pure and synchronous — no cancellation can surface here, so there is // deliberately no OperationCanceledException arm to shadow the block-read one above. results[member.OriginalIndex] = - new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now); + new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [member.Tag.Name]), null, now); } } } @@ -1289,8 +1309,10 @@ public sealed class S7Driver /// /// The exception observed on a read. /// The pass's outcome accumulator this failure is recorded into. + /// The tags the failure belongs to (every member of a failed span), for the + /// once-per-tag refusal Warning. /// The OPC UA status code every affected tag must be stamped with. - private uint RecordReadFailure(Exception ex, ReadPassOutcome outcome) + private uint RecordReadFailure(Exception ex, ReadPassOutcome outcome, IEnumerable tagNames) { switch (ex) { @@ -1299,19 +1321,33 @@ public sealed class S7Driver case S7ValueRangeException: // A PLC buffer the type codec refuses (uninitialised DTL, non-BCD S5TIME…) is a data - // problem, not a transport fault — no health change, no connection teardown. + // problem, not a transport fault — no health change, no connection teardown. The + // bytes came off the wire, so the CPU answered. + outcome.RecordAnswered(); return StatusBadOutOfRange; - case PlcException pex when IsAccessDenied(pex): + case var _ when IsAccessDenied(ex): // PUT/GET-disabled (S7-1200/1500) / access-protection — a permanent configuration // fault, NOT a transient one. Blind retry is wasted effort, so map it to // BadNotSupported and flag the driver as a config alert (Faulted) rather than - // Degraded — per driver-specs.md §5. + // Degraded — per driver-specs.md §5. Matched over the whole chain: S7.Net's READ + // paths raise the CPU's item error as a bare Exception, not a PlcException. outcome.RecordFailure(DriverState.Faulted, "S7 access denied — enable PUT/GET communication in TIA Portal " + - $"(Protection & Security) for this CPU. PLC reported: {pex.Message}"); + $"(Protection & Security) for this CPU. PLC reported: {ex.Message}"); return StatusBadNotSupported; + case var _ when IsRefusal(ex): + // The CPU answered and refused the item (object does not exist, address out of + // range, type inconsistent...) — deterministic, so a retry, a reopen or a Degraded + // would all be wrong: per-tag Bad, a once-per-tag Warning, no health write, and it + // counts as answered so LastSuccessfulRead advances (R07:H4). An all-refused driver + // (wrong DB number) used to sit Degraded with a frozen timestamp and trip the + // comms-loss watchdog every 90 s against a reachable PLC. + outcome.RecordAnswered(); + foreach (var name in tagNames) WarnRefusalOnce(name, "read", ex); + return StatusBadDeviceFailure; + case PlcException pex: // A genuine device-layer fault (CPU error, hardware fault) — transient enough to // keep retrying; report BadDeviceFailure and degrade health. A connection-fatal @@ -1969,7 +2005,7 @@ public sealed class S7Driver // Client-supplied value of the wrong shape — not a device or comms fault. results[i] = new WriteResult(StatusBadTypeMismatch); } - catch (PlcException pex) when (IsAccessDenied(pex)) + catch (Exception ex) when (IsAccessDenied(ex)) { // PUT/GET-disabled / access-protection on write — same permanent // configuration fault as on read. BadNotSupported + @@ -1977,7 +2013,15 @@ public sealed class S7Driver results[i] = new WriteResult(StatusBadNotSupported); outcome.RecordFailure(DriverState.Faulted, "S7 access denied — enable PUT/GET communication in TIA Portal " + - $"(Protection & Security) for this CPU. PLC reported: {pex.Message}"); + $"(Protection & Security) for this CPU. PLC reported: {ex.Message}"); + } + catch (Exception ex) when (IsRefusal(ex)) + { + // The CPU answered and refused the item — per-tag Bad, once-per-tag Warning, no + // Degraded, and it counts as answered (R07:H4). + results[i] = new WriteResult(StatusBadDeviceFailure); + outcome.RecordAnswered(); + WarnRefusalOnce(tag.Name, "write", ex); } catch (PlcException pex) { @@ -2029,16 +2073,24 @@ public sealed class S7Driver else if (outcome.AnySuccess) _writeFault = null; // a write got through — the protection fault is gone + var answeredAt = outcome.AnyAnswered ? DateTime.UtcNow : (DateTime?)null; if (worst == DriverState.Healthy) { - // Success-only batch: no health write, except to retire a Faulted that WE (the write - // side) were holding — a clean write is the only evidence the alert has cleared. - // Only a Faulted the write side owns: a read-side Faulted (e.g. access denied on another DB) is the - // read pass's to clear, and Health still carries the write fault's own message iff it is ours. - if (outcome.AnySuccess && _writeFault is null && priorWriteFault is { } owned) - TryUpdateHealth(gen, h => h.State == DriverState.Faulted && h.LastError == owned.Message - ? new DriverHealth(DriverState.Healthy, h.LastSuccessfulRead, null) - : null); + // Clean batch (successes and/or refusals): the CPU answered, so LastSuccessfulRead + // advances, and a non-Healthy state the WRITE side owns is retired — a Faulted only by a + // real success (a refusal does not prove PUT/GET is enabled), a write-side comms Degraded + // by any answer. A state the read side owns (its own message) is the read pass's to clear. + var ownedFault = outcome.AnySuccess && _writeFault is null ? priorWriteFault?.Message : null; + var ownedDegraded = _writeOwnedError; + _writeOwnedError = null; + TryUpdateHealth(gen, h => + { + var lsr = answeredAt ?? h.LastSuccessfulRead; + if ((h.State == DriverState.Faulted && ownedFault is not null && h.LastError == ownedFault) + || (h.State == DriverState.Degraded && ownedDegraded is not null && h.LastError == ownedDegraded)) + return new DriverHealth(DriverState.Healthy, lsr, null); + return lsr == h.LastSuccessfulRead ? null : h with { LastSuccessfulRead = lsr }; + }); return; } @@ -2050,7 +2102,8 @@ public sealed class S7Driver state = wf.State; error = wf.Message; } - TryUpdateHealth(gen, h => new DriverHealth(state, h.LastSuccessfulRead, error)); + if (state == DriverState.Degraded) _writeOwnedError = error; + TryUpdateHealth(gen, h => new DriverHealth(state, answeredAt ?? h.LastSuccessfulRead, error)); } /// @@ -2068,10 +2121,18 @@ public sealed class S7Driver public DriverState WorstFailure => _inner.WorstFailure; /// The message explaining . public string? WorstError => _inner.WorstError; - /// True once the batch observed a success or a failure. - public bool HasObservations => _inner.HasObservations; + /// True once the batch observed a success, a refusal or a failure. + public bool HasObservations => _inner.HasObservations || AnyAnswered; + /// True once the CPU answered any item (a success or a refusal) — proof of a live device. + public bool AnyAnswered { get; private set; } /// Records one successful write. - public void RecordSuccess() => _inner.RecordSuccess(); + public void RecordSuccess() + { + _inner.RecordSuccess(); + AnyAnswered = true; + } + /// Records a write the CPU answered by refusing it: not a success, not a failure. + public void RecordAnswered() => AnyAnswered = true; /// Records one failed write. /// The state the failure would have stamped alone. /// The operator-facing message. @@ -2407,19 +2468,82 @@ public sealed class S7Driver } /// - /// Detects an S7 PUT/GET-disabled / access-protection fault inside an S7.Net - /// . S7.Net's read/write paths wrap every PLC-side error in a - /// PlcException with / ; - /// the response-code validator throws a plain for the S7 - /// AccessingObjectNotAllowed status, which lands as the inner exception. There is - /// no typed error code for it, so the inner message is the only discriminator - /// S7.Net exposes. + /// True when is a deterministic PLC REFUSAL of the item: the CPU answered + /// with an S7 item error (object does not exist, address out of range, data type inconsistent / + /// not supported, hardware fault, or an unknown status), or S7.Net raised a + /// / over a response it could + /// not use. Excludes the access-denied refusal (its own sticky Faulted config alert) and + /// anything carrying a socket / timeout / framing / cancellation fault in its chain — those + /// wrap a transport failure, not an answer. /// - /// The exception raised by S7.Net. - /// when the PLC refused access to the object. - internal static bool IsAccessDenied(PlcException pex) + /// + /// Live-probed against the snap7 fixture (2026-10-22): S7.Net's READ paths + /// (ReadAsync(string), ReadBytesAsync) raise the item error as a BARE + /// — "Received error from PLC: Object does not exist." — not wrapped in + /// a ; its write paths wrap whatever they hit in a + /// PlcException(WriteData). Both shapes are matched. + /// + /// The exception raised by S7.Net. + /// when the CPU answered and refused. + internal static bool IsRefusal(Exception ex) { - for (Exception? e = pex; e is not null; e = e.InnerException) + if (IsAccessDenied(ex) || IsS7ConnectionFatal(ex) || HasCancellationInChain(ex)) return false; + if (ex is PlcException { ErrorCode: ErrorCode.ReadData or ErrorCode.WriteData }) return true; + for (Exception? e = ex; e is not null; e = e.InnerException) + if (IsS7ItemError(e)) return true; + return false; + } + + /// + /// True for the bare S7.Net's response-code validator raises for an S7 + /// item error ("Received error from PLC: …" / "Invalid response from PLC: statusCode=…"). + /// Exact type only: a derived exception carrying similar words is not S7.Net's validator. + /// + /// One exception of a chain. + /// for the validator's item-error shape. + private static bool IsS7ItemError(Exception e) + => e.GetType() == typeof(Exception) + && (e.Message.StartsWith("Received error from PLC", StringComparison.Ordinal) + || e.Message.StartsWith("Invalid response from PLC", StringComparison.Ordinal)); + + /// True when a cancellation is buried in the exception chain (a half-read PDU, not a refusal). + /// The exception to inspect. + /// when any inner exception is an . + private static bool HasCancellationInChain(Exception ex) + { + for (var e = ex.InnerException; e is not null; e = e.InnerException) + if (e is OperationCanceledException) return true; + return false; + } + + /// Logs a refusal once per tag per generation. + /// The refused tag. + /// "read" or "write". + /// The refusal. + private void WarnRefusalOnce(string tagName, string operation, Exception ex) + { + if (!_refusalWarned.TryAdd(tagName, 0)) return; + _logger.LogWarning( + "S7 CPU refused the {Operation} of tag {Tag} ({Reason}); it reads Bad until the tag or the PLC program is fixed. " + + "Driver health is unaffected. Further refusals of this tag are not logged. Driver={DriverInstanceId}", + operation, tagName, ex.InnerException?.Message ?? ex.Message, _driverInstanceId); + } + + /// + /// Detects an S7 PUT/GET-disabled / access-protection fault. S7.Net's response-code validator + /// throws a plain for the S7 AccessingObjectNotAllowed status — + /// bare on the read paths, wrapped in a () + /// on the write paths. There is no typed error code for it, so the message is the only + /// discriminator S7.Net exposes. The top-level exception must be S7.Net-shaped (a + /// or the validator's bare item error), so an unrelated exception that + /// happens to say "not allowed" is not mistaken for it. + /// + /// The exception raised by S7.Net. + /// when the PLC refused access to the object. + internal static bool IsAccessDenied(Exception ex) + { + if (ex is not PlcException && !IsS7ItemError(ex)) return false; + for (Exception? e = ex; e is not null; e = e.InnerException) { if (e.Message.Contains("Accessing object not allowed", StringComparison.OrdinalIgnoreCase) || e.Message.Contains("not allowed", StringComparison.OrdinalIgnoreCase)) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs index d936b98a..13925989 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs @@ -128,15 +128,17 @@ public sealed class S7DriverProbe : IDriverProbe return null; } catch (OperationCanceledException) { throw; } - catch (PlcException pex) when (S7Driver.IsAccessDenied(pex)) + // S7.Net's read paths raise the CPU's item error as a BARE Exception ("Received error from + // PLC: …"), not a PlcException, so both classifiers walk the whole chain (the driver's own). + catch (Exception ex) when (S7Driver.IsAccessDenied(ex)) { return new(false, "S7 connected but data access is denied — enable PUT/GET communication in TIA Portal " + - $"(Protection & Security) for this CPU. PLC reported: {pex.Message}", null); + $"(Protection & Security) for this CPU. PLC reported: {ex.Message}", null); } - catch (PlcException pex) when (pex.ErrorCode == ErrorCode.ReadData) + catch (Exception ex) when (S7Driver.IsRefusal(ex)) { - return null; // address not available on this CPU — not a connection problem + return null; // address not available on this CPU — the CPU answered; not a connection problem } catch (Exception ex) { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500RefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500RefusalTests.cs new file mode 100644 index 00000000..709be4c2 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500RefusalTests.cs @@ -0,0 +1,83 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500; + +/// +/// Live gate for R07:H4 — a real CPU refusal over real S7comm. The snap7 server has no DB99, so it +/// answers a read of DB99.DBW0 with an S7 item error. That is a per-tag Bad with no health +/// impact (and LastSuccessfulRead advances), not a Degraded that would trip the comms-loss watchdog +/// against a reachable PLC. +/// +[Collection(Snap7ServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Device", "S7_1500")] +public sealed class S7_1500RefusalTests(Snap7ServerFixture sim) +{ + /// A refused DB read is per-tag Bad, keeps the driver Healthy, and its neighbour reads Good. + [Fact] + public async Task A_read_of_a_missing_DB_is_refused_per_tag_without_degrading() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + var ct = TestContext.Current.CancellationToken; + + var baseOptions = S7_1500Profile.BuildOptions(sim.Host, sim.Port); + var options = new S7DriverOptions + { + Host = baseOptions.Host, Port = baseOptions.Port, CpuType = baseOptions.CpuType, + Rack = baseOptions.Rack, Slot = baseOptions.Slot, Timeout = baseOptions.Timeout, + Probe = baseOptions.Probe, + RawTags = [.. baseOptions.RawTags, .. S7RawTags.Entries( + new S7TagDefinition("Missing/Db99", "DB99.DBW0", S7DataType.UInt16, Writable: false))], + }; + await using var drv = new S7Driver(options, driverInstanceId: "s7-refusal-live"); + await drv.InitializeAsync("{}", ct); + var before = drv.GetHealth().LastSuccessfulRead!.Value; + + // Read the refused tag ALONE first: an all-refused pass is exactly the case that used to + // freeze LastSuccessfulRead and latch Degraded. + var refused = await drv.ReadAsync(["Missing/Db99"], ct); + refused[0].StatusCode.ShouldNotBe(0u); + refused[0].Value.ShouldBeNull(); + var health = drv.GetHealth(); + health.State.ShouldBe(DriverState.Healthy, "a CPU refusal is not a comms fault"); + health.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(before); + + // Positive control: the same connection still serves the seeded neighbour. + var good = await drv.ReadAsync([S7_1500Profile.ProbeTag], ct); + good[0].StatusCode.ShouldBe(0u); + Convert.ToInt32(good[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue); + } + + /// A refused write is per-tag Bad and keeps the driver Healthy; a good write still lands. + [Fact] + public async Task A_write_to_a_missing_DB_is_refused_per_tag_without_degrading() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + var ct = TestContext.Current.CancellationToken; + + var baseOptions = S7_1500Profile.BuildOptions(sim.Host, sim.Port); + var options = new S7DriverOptions + { + Host = baseOptions.Host, Port = baseOptions.Port, CpuType = baseOptions.CpuType, + Rack = baseOptions.Rack, Slot = baseOptions.Slot, Timeout = baseOptions.Timeout, + Probe = baseOptions.Probe, + RawTags = [.. baseOptions.RawTags, .. S7RawTags.Entries( + new S7TagDefinition("Missing/Db99W", "DB99.DBW0", S7DataType.UInt16, Writable: true))], + }; + await using var drv = new S7Driver(options, driverInstanceId: "s7-refusal-write-live"); + await drv.InitializeAsync("{}", ct); + + var refused = await drv.WriteAsync([new WriteRequest("Missing/Db99W", (ushort)5)], ct); + refused[0].StatusCode.ShouldNotBe(0u); + drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a CPU write refusal is not a comms fault"); + + // Positive control: a write the CPU accepts still lands (and reads back). + var ok = await drv.WriteAsync([new WriteRequest(S7_1500Profile.WriteScratchTag, (ushort)4711)], ct); + ok[0].StatusCode.ShouldBe(0u); + var back = await drv.ReadAsync([S7_1500Profile.WriteScratchTag], ct); + Convert.ToInt32(back[0].Value).ShouldBe(4711); + await drv.WriteAsync([new WriteRequest(S7_1500Profile.WriteScratchTag, (ushort)0)], ct); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/CapturingS7Logger.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/CapturingS7Logger.cs new file mode 100644 index 00000000..c2664116 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/CapturingS7Logger.cs @@ -0,0 +1,32 @@ +using Microsoft.Extensions.Logging; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// An that records (level, message) pairs. +internal sealed class CapturingS7Logger : ILogger +{ + private readonly List<(LogLevel Level, string Message)> _entries = []; + + /// A snapshot of every entry logged so far. + public IReadOnlyList<(LogLevel Level, string Message)> Entries + { + get { lock (_entries) return [.. _entries]; } + } + + /// The Warning messages logged so far. + public IReadOnlyList Warnings + => [.. Entries.Where(e => e.Level == LogLevel.Warning).Select(e => e.Message)]; + + /// + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => true; + + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) + { + lock (_entries) _entries.Add((logLevel, formatter(state, exception))); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RefusalHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RefusalHealthTests.cs new file mode 100644 index 00000000..06aa3277 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RefusalHealthTests.cs @@ -0,0 +1,193 @@ +using S7.Net; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// R07:H4 — a deterministic PLC refusal (S7.Net ReadData / WriteData carrying an S7 +/// item error such as "object does not exist", not a socket/timeout/framing fault) proves the CPU +/// answered. It is per-tag Bad with a once-per-tag Warning and NO Degraded; it advances +/// LastSuccessfulRead, so a driver whose every tag is refused (wrong DB number) no longer +/// trips the 90 s comms-loss watchdog against a reachable PLC. +/// +[Trait("Category", "Unit")] +public sealed class S7RefusalHealthTests +{ + private const uint BadDeviceFailure = 0x808B0000u; + + /// + /// The shape S7.Net's READ paths really raise for a CPU item error — a BARE + /// , live-probed against the snap7 fixture (not a PlcException). + /// + private static Exception ReadRefusal() => new("Received error from PLC: Object does not exist."); + + private static PlcException WriteRefusal() => + new(ErrorCode.WriteData, new Exception("Received error from PLC: Address out of range.")); + + private static S7DriverOptions Options() => new() + { + Host = "192.0.2.1", + Timeout = TimeSpan.FromSeconds(5), + Probe = new S7ProbeOptions { Enabled = false }, + // Grouping off: one wire request per tag keeps each tag's outcome its own. + SpanReadMaxGapBytes = -1, + RawTags = S7RawTags.Entries( + new S7TagDefinition("Missing", "DB99.DBW0", S7DataType.UInt16), + new S7TagDefinition("Good", "DB1.DBW0", S7DataType.UInt16)), + }; + + private static async Task<(S7Driver Driver, ScriptedS7PlcFactory Factory, CapturingS7Logger Log)> StartAsync() + { + var factory = new ScriptedS7PlcFactory(); + var log = new CapturingS7Logger(); + var drv = new S7Driver(Options(), "s7-refusal", factory, log); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + return (drv, factory, log); + } + + /// A refused tag is Bad, health stays Healthy, LastSuccessfulRead advances, and it warns once. + [Fact] + public async Task A_read_refusal_is_per_tag_bad_with_no_health_impact_and_one_warning() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory, log) = await StartAsync(); + using var _ = drv; + factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult((ushort)7); + + var before = drv.GetHealth().LastSuccessfulRead!.Value; + for (var pass = 0; pass < 3; pass++) + { + var r = await drv.ReadAsync(["Missing"], ct); + r[0].StatusCode.ShouldBe(BadDeviceFailure); + drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault"); + } + + drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(before, + "the CPU answered, so the all-refused pass still advances LastSuccessfulRead"); + log.Warnings.Count(w => w.Contains("Missing")).ShouldBe(1, "one Warning per refused tag, not one per poll"); + + // Positive control: the good tag still reads, and nothing reopened the connection. + (await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0u); + factory.Created.Count.ShouldBe(1); + } + + /// + /// A refusal after a comms fault restores Healthy — the CPU is back and answering. Before the + /// fix the refusal latched Degraded with LastSuccessfulRead frozen, which is exactly the + /// watchdog's trip condition. + /// + [Fact] + public async Task A_refusal_after_a_comms_fault_restores_healthy() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory, _) = await StartAsync(); + using var __ = drv; + + // Positive control: a genuine comms fault DOES degrade. + factory.Read = (_, _) => throw new TimeoutException("frozen peer"); + await drv.ReadAsync(["Missing"], ct); + var degraded = drv.GetHealth(); + degraded.State.ShouldBe(DriverState.Degraded); + + factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult((ushort)7); + var r = await drv.ReadAsync(["Missing"], ct); + r[0].StatusCode.ShouldBe(BadDeviceFailure); + var after = drv.GetHealth(); + after.State.ShouldBe(DriverState.Healthy); + after.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(degraded.LastSuccessfulRead!.Value); + } + + /// The ReadData-wrapped shape of a refusal is classified the same way. + [Fact] + public async Task A_read_data_wrapped_refusal_is_also_a_refusal() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory, _) = await StartAsync(); + using var __ = drv; + factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, ReadRefusal()); + + (await drv.ReadAsync(["Missing"], ct))[0].StatusCode.ShouldBe(BadDeviceFailure); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// + /// The bare read-path shape of access denied (PUT/GET disabled) is the Faulted config alert. + /// Before, only a PlcException-wrapped "not allowed" was recognised, so a real CPU's read + /// refusal fell through to BadCommunicationError + Degraded. + /// + [Fact] + public async Task A_bare_access_denied_read_is_the_faulted_config_alert() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory, _) = await StartAsync(); + using var __ = drv; + factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed."); + + (await drv.ReadAsync(["Good"], ct))[0].StatusCode.ShouldBe(0x803D0000u); // BadNotSupported + drv.GetHealth().State.ShouldBe(DriverState.Faulted); + drv.GetHealth().LastError!.ShouldContain("PUT/GET"); + } + + /// A ReadData wrapping a socket fault is still a comms fault (Degraded), not a refusal. + [Fact] + public async Task A_read_data_wrapping_a_socket_fault_still_degrades() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory, _) = await StartAsync(); + using var __ = drv; + factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, new System.IO.IOException("connection reset")); + + await drv.ReadAsync(["Good"], ct); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + } + + /// A write refusal is per-tag Bad, counts as answered and leaves health alone. + [Fact] + public async Task A_write_refusal_is_per_tag_bad_and_counts_as_answered() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory, log) = await StartAsync(); + using var _ = drv; + factory.Write = (addr, _, _) => addr == "DB99.DBW0" ? throw WriteRefusal() : Task.CompletedTask; + + // Put the driver into a comms-fault Degraded first; the refusal must lift it. + factory.Read = (_, _) => throw new TimeoutException("frozen peer"); + await drv.ReadAsync(["Good"], ct); + var degraded = drv.GetHealth(); + degraded.State.ShouldBe(DriverState.Degraded); + + for (var i = 0; i < 2; i++) + { + var w = await drv.WriteAsync([new WriteRequest("Missing", (ushort)1)], ct); + w[0].StatusCode.ShouldBe(BadDeviceFailure); + } + + var after = drv.GetHealth(); + after.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(degraded.LastSuccessfulRead!.Value); + log.Warnings.Count(m => m.Contains("Missing")).ShouldBe(1); + } + + /// + /// A good write clears a Degraded the write side wrote. Before the fix a success-only batch + /// never wrote health, so a transient write fault latched Degraded until some read pass ran. + /// + [Fact] + public async Task A_good_write_clears_a_write_side_degraded() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory, _) = await StartAsync(); + using var _ = drv; + + factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset"); + var bad = await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct); + bad[0].StatusCode.ShouldNotBe(0u); + drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: a comms fault on write degrades"); + + factory.Write = (_, _, _) => Task.CompletedTask; + var good = await drv.WriteAsync([new WriteRequest("Good", (ushort)2)], ct); + good[0].StatusCode.ShouldBe(0u); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs index 2dc2152f..ad23e466 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanReadParityTests.cs @@ -202,9 +202,10 @@ public sealed class S7SpanReadParityTests public async Task A_span_read_failure_degrades_every_member_like_the_per_tag_path() { var ct = TestContext.Current.CancellationToken; - // ErrorCode.ReadData with a message that is NOT access-denied ("not allowed") — a genuine - // device-layer fault, which the driver maps to BadDeviceFailure + Degraded. - var fault = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault"); + // A device-layer fault that is neither a refusal (a ReadData item error is per-tag Bad with + // no health impact since R07:H4, and a refused span falls back per member since R07:P2) nor + // connection-fatal — the driver maps it to BadDeviceFailure + Degraded on every member. + var fault = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault"); var groupingFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault }; var perTagFactory = new ImageS7PlcFactory(Images()) { ReadFault = fault }; @@ -468,8 +469,11 @@ public sealed class S7SpanReadParityTests } /// - /// The SPAN path obeys the same rule: one member that fails to decode leaves the pass - /// Degraded even though a later member of the same block decoded fine. + /// The SPAN path guards each member's decode: one member that fails to decode is Bad on its + /// own while a later member of the same block decodes fine. S7.Net rejects the malformed + /// STRING with a ReadData — the CPU answered, the buffer is + /// what is wrong — so since R07:H4 it is a per-tag refusal with no Degraded (like the + /// codec's own BadOutOfRange), and LastSuccessfulRead still advances. /// /// /// The block read itself succeeds — this is the per-member decode guard, the span-path @@ -480,7 +484,7 @@ public sealed class S7SpanReadParityTests /// last-writer-wins used to erase. /// [Fact] - public async Task One_undecodable_span_member_leaves_the_pass_Degraded() + public async Task One_undecodable_span_member_is_bad_alone_without_degrading() { var ct = TestContext.Current.CancellationToken; // The STRING occupies DB1 bytes 100..111 (StringLength + 2), so the word at 112 is @@ -516,7 +520,7 @@ public sealed class S7SpanReadParityTests results[1].Value.ShouldNotBeNull(); var health = driver.GetHealth(); - health.State.ShouldBe(DriverState.Degraded, "a span member that failed to decode must not be erased"); + health.State.ShouldBe(DriverState.Healthy, "an undecodable buffer is a data problem, not a comms fault"); health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value); } @@ -542,7 +546,7 @@ public sealed class S7SpanReadParityTests // which is the Faulted config alert. B: an ordinary device-layer fault, merely Degraded, // observed AFTER it. C: no fault at all. factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed"); - factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault"); + factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault"); // maxGap -1 turns grouping off entirely, so every tag takes the per-tag read path and the // faults can be aimed at individual addresses. using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-access-denied", factory); @@ -584,7 +588,7 @@ public sealed class S7SpanReadParityTests var factory = new ImageS7PlcFactory(Images()); // A: an ordinary device-layer fault (Degraded) FIRST. B: the access-denied config alert // (Faulted) after it. C: no fault at all. - factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.ReadData, "S7 CPU reported a device fault"); + factory.PerTagFaults["DB1.DBW0"] = new PlcException(ErrorCode.SendData, "S7 CPU reported a device fault"); factory.PerTagFaults["DB1.DBW2"] = new PlcException(ErrorCode.ReadData, "Accessing object not allowed"); using var driver = new S7Driver(Options(NarrowTags(), maxGap: -1), "s7-escalation-message", factory); await driver.InitializeAsync("{}", ct); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TsapAndPduTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TsapAndPduTests.cs index 4f9bd37f..a69cd4a1 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TsapAndPduTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7TsapAndPduTests.cs @@ -224,6 +224,23 @@ public sealed class S7TsapAndPduTests (await S7DriverProbe.CheckDataAccessAsync(_ => throw missing, TestContext.Current.CancellationToken)).ShouldBeNull(); } + /// + /// The shapes a real read raises — S7.Net's read paths throw the CPU's item error as a BARE + /// Exception (live-probed on the snap7 fixture). Before, the probe only matched PlcException, so a + /// PUT/GET-disabled CPU got the generic "test read failed" text and a missing address failed the probe. + /// + [Fact] + public async Task Probe_classifies_the_bare_read_path_shapes() + { + var ct = TestContext.Current.CancellationToken; + var denied = await S7DriverProbe.CheckDataAccessAsync( + _ => throw new Exception("Received error from PLC: Accessing object not allowed."), ct); + denied.ShouldNotBeNull().Message!.ShouldContain("PUT/GET"); + + (await S7DriverProbe.CheckDataAccessAsync( + _ => throw new Exception("Received error from PLC: Object does not exist."), ct)).ShouldBeNull(); + } + [Fact] public async Task Probe_reports_any_other_read_fault_as_a_failure() { From 16c2045cab3a987c75312503f62556b969c21eb8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:22:17 -0400 Subject: [PATCH 60/67] fix(ablegacy): never operate on a handle evicted while waiting for its gate Review r0 M3. The indirect sweep disposed stale resolutions without holding their gates, and any reader/writer that fetched a handle before waiting on its gate could then use an instance a sibling had evicted and disposed - a spurious comms fault and Degraded. The sweep now evicts under each key's gate, and read/write re-resolve the handle once the gate is held (reconnecting single-flight if it was evicted; a retired state never reconnects). Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriver.cs | 24 ++++++++++++- .../AbLegacyHealthEvidenceTests.cs | 36 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index ffc96f46..28b6fbe7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -680,6 +680,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag await opLock.WaitAsync(ct).ConfigureAwait(false); try { + runtime = await CurrentRuntimeUnderLockAsync(device, def, runtime, ct).ConfigureAwait(false); await runtime.ReadAsync(ct).ConfigureAwait(false); status = runtime.GetStatus(); var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); @@ -942,6 +943,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag await opLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { + runtime = await CurrentRuntimeUnderLockAsync(device, def, runtime, cancellationToken).ConfigureAwait(false); if (arrayValue is not null) runtime.EncodeArray(arrayFileType, arrayValue); else runtime.EncodeValue(def.DataType, parsed.BitIndex, w.Value); await runtime.WriteAsync(cancellationToken).ConfigureAwait(false); @@ -1356,7 +1358,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag { if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || string.Equals(key, resolvedName, StringComparison.OrdinalIgnoreCase)) continue; - EvictRuntime(device, key); + // Under the key's own gate (review 07 r0 M3), so no reader is mid-operation on the handle we dispose. + var gate = device.GetRuntimeLock(key); + await gate.WaitAsync(ct).ConfigureAwait(false); + try { EvictRuntime(device, key); } finally { gate.Release(); } device.ForgetGates(key); } } @@ -1601,6 +1606,23 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag .ConfigureAwait(false); } + /// + /// Re-resolves a tag's runtime once its operation gate is HELD (review 07 r0 M3): the handle fetched before + /// the wait may since have been evicted and disposed (a failed sibling call, or an indirect index sweep), and + /// using it would fail spuriously as a comms fault. Returns the live cached handle, or connects a fresh one. + /// + private async Task CurrentRuntimeUnderLockAsync( + DeviceState device, AbLegacyTagDefinition def, IAbLegacyTagRuntime fetched, CancellationToken ct) + { + if (device.Runtimes.TryGetValue(def.Name, out var live)) + return live; + // Evicted between the fetch and the lock: a native runtime refused at init is cached too, so absence means + // it really was released. Connect again (single-flight per device). + // A retired (torn-down) state never reconnects: the in-flight call finishes on what it has. + if (device.IsRetired) return fetched; + return await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false); + } + /// /// Evict the runtime for from the device's cache and dispose it so /// the next read/write call re-creates and re-initializes a fresh libplctag handle. Called diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs index a982e84e..c65004f0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs @@ -219,4 +219,40 @@ public sealed class AbLegacyHealthEvidenceTests await drv.ShutdownAsync(CancellationToken.None); drv.GetHealth().State.ShouldBe(DriverState.Unknown); } + + private sealed class DisposeAwareTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p) + { + public override Task ReadAsync(CancellationToken ct) => + Disposed ? throw new ObjectDisposedException("tag") : base.ReadAsync(ct); + } + + /// + /// Review 07 r0 M3: a reader that fetched a handle and then waited on its gate while another path evicted and + /// disposed it used to read the disposed instance — a spurious comms fault and Degraded. It now re-resolves + /// the handle once the gate is held. + /// + [Fact] + public async Task A_handle_evicted_while_a_reader_waited_for_its_gate_is_re_resolved() + { + var f = new SwitchableFactory { Make = p => new DisposeAwareTag(p) { Value = 4 } }; + var drv = NewDriver(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var state = drv.GetDeviceState(Host)!; + var first = f.Tags["N7:0"]; + + var gate = state.GetRuntimeLock("X"); + await gate.WaitAsync(); + var read = drv.ReadAsync(["X"], CancellationToken.None); // fetches `first`, then parks on the gate + await Task.Delay(30); + state.Runtimes.TryRemove("X", out var evicted).ShouldBeTrue(); + evicted!.Dispose(); + gate.Release(); + var r = await read; + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + f.Tags["N7:0"].ShouldNotBeSameAs(first); // a fresh handle served it + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + await drv.ShutdownAsync(CancellationToken.None); + } } From efa040813a0910e50d457bd626cf6ce28368c075 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:24:30 -0400 Subject: [PATCH 61/67] fix(ablegacy): coalesceReads absent means default; the form never pins it Review r0 M9. The AdminUI form wrote coalesceReads:true on every save, pinning today's default into every edited driver. AbLegacyDriverOptions.CoalesceReads is now bool? (null = default ON, never serialised, same encoding as Transport); the form omits the key when the switch is on and writes only an explicit false. Persisted coalesceReads:true keeps loading as on. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriverOptions.cs | 7 +++++-- .../AbLegacyDriver.Coalescing.cs | 2 +- .../AbLegacyDriverFactoryExtensions.cs | 2 +- .../Shared/Drivers/Forms/AbLegacyDriverForm.razor | 5 +++-- .../AbLegacyReadCoalescingTests.cs | 2 +- .../AbLegacyDriverPageFormSerializationTests.cs | 8 ++++++-- 6 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs index 36b21186..c083d592 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs @@ -51,9 +51,12 @@ public sealed class AbLegacyDriverOptions /// /// Whether scalar tags on adjacent elements of one N / B / F / L file are read as ONE multi-element PCCC read /// (driver-config key coalesceReads, default ). A block the device refuses falls - /// back to per-tag reads on its own; set this false to read every tag singly. + /// back to per-tag reads on its own; set this false to read every tag singly. (the key + /// absent) means the default, ON - and null is never written, so a save through any serializer (the AdminUI + /// form included) keeps a defaulted driver defaulted instead of pinning today's default (review 07 r0 M9). /// - public bool CoalesceReads { get; init; } = true; + [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)] + public bool? CoalesceReads { get; init; } /// /// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs index 401ce098..416d1b62 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs @@ -72,7 +72,7 @@ public sealed partial class AbLegacyDriver /// private List PlanReadUnits(DeviceState device, List items) { - if (!_options.CoalesceReads || items.Count < 2) return [.. items.Select(i => new ReadUnit(i, null))]; + if (_options.CoalesceReads == false || items.Count < 2) return [.. items.Select(i => new ReadUnit(i, null))]; var units = new List(items.Count); var groups = new Dictionary<(string Letter, int File, AbLegacyDataType Type), List>(); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs index d06effd9..418ead02 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs @@ -97,7 +97,7 @@ public static class AbLegacyDriverFactoryExtensions ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0", }, Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000), - CoalesceReads = dto.CoalesceReads ?? true, + CoalesceReads = dto.CoalesceReads, // Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver); // a bad value still fails exactly as any other bad enum field does. Transport = string.IsNullOrWhiteSpace(dto.Transport) diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor index a938652b..81ec9d9c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor @@ -208,7 +208,7 @@ _loadedProbeTimeout = o.Probe.Timeout, TimeoutSeconds = (int)o.Timeout.TotalSeconds, Transport = o.Transport, - CoalesceReads = o.CoalesceReads, + CoalesceReads = o.CoalesceReads ?? true, ProbeEnabled = o.Probe.Enabled, ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, @@ -229,7 +229,8 @@ }, Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout), Transport = Transport, - CoalesceReads = CoalesceReads, + // On = the default: OMIT the key (never pin a concrete default); off is the only value written. + CoalesceReads = CoalesceReads ? null : false, ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, }; } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs index 0dc8cf89..b1adfc1a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs @@ -180,5 +180,5 @@ public sealed class AbLegacyReadCoalescingTests [InlineData("""{"coalesceReads":false,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", false)] [InlineData("""{"coalesceReads":true,"someFutureKey":1,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)] public void ParseOptions_reads_coalesceReads_defaulting_true(string json, bool expected) => - AbLegacyDriverFactoryExtensions.ParseOptions("drv", json).CoalesceReads.ShouldBe(expected); + (AbLegacyDriverFactoryExtensions.ParseOptions("drv", json).CoalesceReads != false).ShouldBe(expected); } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs index 162336cc..7dc4c263 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs @@ -269,8 +269,12 @@ public sealed class AbLegacyDriverPageFormSerializationTests var saved = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), original); - ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.AbLegacyDriverFactoryExtensions.ParseOptions("t", saved) - .CoalesceReads.ShouldBe(edited); + (ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.AbLegacyDriverFactoryExtensions.ParseOptions("t", saved) + .CoalesceReads != false).ShouldBe(edited); + // Review 07 r0 M9: ON is the default and is OMITTED, never pinned; only an explicit off is written. + var root = System.Text.Json.Nodes.JsonNode.Parse(saved)!.AsObject(); + if (edited) root.ContainsKey("coalesceReads").ShouldBeFalse(); + else root["coalesceReads"]!.GetValue().ShouldBeFalse(); } [Fact] From 1d83cde6bc9e189f0d8e6826fa532b0da2051679 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:26:14 -0400 Subject: [PATCH 62/67] perf(s7): fail the rest of a pass fast once the handle is dead (R07:P1) No read or write loop checked _plcDead between items: on a frozen-but- established peer the first op blew its deadline and marked the handle dead, and every remaining span / tag / write still went to the wire and could wait its own deadline under _gate (a 20-span pass ~100 s), with Degraded and the comms-loss watchdog's clock deferred until the pass ended. - The span loop, the multi-var chunk loop, the per-tag loop and the write loop stamp BadCommunicationError without I/O once _plcDead is set. - A multi-var chunk that dies on a transport loss (socket / IO / timeout / ConnectionError) marks the handle dead instead of falling back to one doomed per-tag read per member. The framing class is deliberately excluded there: snap7/S7.Net report a multi-var with one refused item as ReadData(WrongNumberReceivedBytes) over a healthy stream. - Live gate via an in-test freezable TCP proxy (counts request frames, not seconds). Mutation shows S7.Net 0.20 already self-closes on a cancelled op, so the gate pins the end-to-end property; the driver guard itself is proven by the unit tests (an op that ignores its token keeps the socket open). Refs #742 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- docs/drivers/S7.md | 8 + .../ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 69 ++++++++ .../FreezableTcpProxy.cs | 104 +++++++++++ .../S7_1500/S7_1500FrozenPeerTests.cs | 71 ++++++++ .../S7ArrayWriteAndHealthTests.cs | 4 +- .../S7DeadHandleShortCircuitTests.cs | 167 ++++++++++++++++++ 6 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/FreezableTcpProxy.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500FrozenPeerTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DeadHandleShortCircuitTests.cs diff --git a/docs/drivers/S7.md b/docs/drivers/S7.md index 448c7689..9491c93c 100644 --- a/docs/drivers/S7.md +++ b/docs/drivers/S7.md @@ -336,6 +336,14 @@ degrades health nor reopens the connection, and because it proves the CPU is rea every tag was refused (a wrong DB number) sat `Degraded` with a frozen timestamp and the comms-loss watchdog reconnected it every 90 s against a perfectly reachable PLC. +**A dead handle ends the pass's wire traffic (R07:P1).** Once a read pass or write batch marks the +connection dead (a wire op blew its deadline on a frozen-but-established peer, a socket dropped), the +remaining spans / multi-var chunks / tags / writes of that pass are stamped `BadCommunicationError` +without I/O; the next call reopens. A multi-var chunk that dies on a transport loss no longer falls back +to one doomed per-tag read per member. Without this an op that ignores its cancellation token would cost +one full deadline per remaining item under the gate, and `Degraded` (and so the comms-loss watchdog's +clock) only became visible after the whole pass. + **Connect backoff.** A dead PLC costs one connect attempt per backoff window (1 s doubling to a 30 s cap), not one per read / poll tick / probe tick: inside the window `EnsureConnectedAsync` fails fast (`BadCommunicationError`, `Degraded`) without a connect. A successful connect clears the window, so a diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index f1136908..e413125c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -762,6 +762,13 @@ public sealed class S7Driver perTag.Add(span.Members[0].OriginalIndex); continue; } + // Fail fast once an earlier span marked the handle dead (R07:P1): every + // further wire op on it would pay the full deadline under the gate. + if (_plcDead) + { + foreach (var member in span.Members) results[member.OriginalIndex] = DeadHandleSnapshot(now); + continue; + } await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false); } perTag.AddRange(plan.LeftoverIndexes); @@ -790,6 +797,12 @@ public sealed class S7Driver foreach (var i in perTag) { + // Fail fast once the handle is dead (R07:P1) — see the span loop. + if (_plcDead) + { + results[i] = DeadHandleSnapshot(now); + continue; + } try { var value = await ReadOneAsync(plc, resolved[i]!, cancellationToken).ConfigureAwait(false); @@ -1069,6 +1082,13 @@ public sealed class S7Driver { var chunk = batchable.GetRange(start, Math.Min(MultiVarReadMaxItems, batchable.Count - start)); + // Fail fast once the handle is dead (R07:P1) — see ReadAsync's span loop. + if (_plcDead) + { + foreach (var (index, _, _) in chunk) results[index] = DeadHandleSnapshot(now); + continue; + } + IReadOnlyList answered; try { @@ -1094,6 +1114,17 @@ public sealed class S7Driver _plcDead = true; throw; } + catch (Exception ex) when (IsS7TransportLoss(ex)) + { + // A transport loss is the connection's fault, not the batch's: falling back would + // send one more doomed per-tag read per member, each paying the full deadline + // (R07:P1). Mark the handle dead and fail the chunk (and, via the guards, the rest + // of the pass) without I/O. + _plcDead = true; + outcome.RecordFailure(DriverState.Degraded, ex.Message); + foreach (var (index, _, _) in chunk) results[index] = DeadHandleSnapshot(now); + continue; + } catch (Exception ex) { _logger.LogDebug(ex, @@ -1974,6 +2005,13 @@ public sealed class S7Driver results[i] = new WriteResult(StatusBadNotWritable); continue; } + // Fail the rest of the batch fast once a write marked the handle dead (R07:P1): the + // next call reopens; this batch must not serialise one deadline per remaining write. + if (_plcDead) + { + results[i] = new WriteResult(StatusBadCommunicationError); + continue; + } try { await WriteOneAsync(plc, tag, w.Value, cancellationToken).ConfigureAwait(false); @@ -2415,6 +2453,37 @@ public sealed class S7Driver if (IsS7ConnectionFatal(ex)) _plcDead = true; } + /// + /// The result stamped, without any I/O, on an item a pass skipped because an earlier item had + /// already marked the connection dead (R07:P1). The pass's health already carries the failure + /// that marked it. + /// + /// The pass timestamp. + /// A BadCommunicationError snapshot. + private static DataValueSnapshot DeadHandleSnapshot(DateTime now) => new(null, StatusBadCommunicationError, null, now); + + /// + /// The TRANSPORT subset of : a socket / IO / disposed handle / + /// blown deadline / anywhere in the chain. Excludes the + /// framing class, because S7.Net reports a multi-var request with one refused item as + /// PlcException(ReadData, PlcException(WrongNumberReceivedBytes)) (live-probed on the snap7 + /// fixture) over a perfectly healthy stream — the multi-var path falls back per tag for that + /// shape, and a genuine desync still surfaces on the per-tag reads. + /// + /// The exception to classify. + /// for a transport loss. + private static bool IsS7TransportLoss(Exception ex) + { + for (Exception? e = ex; e is not null; e = e.InnerException) + { + if (e is System.Net.Sockets.SocketException or System.IO.IOException or ObjectDisposedException or TimeoutException) + return true; + if (e is PlcException { ErrorCode: ErrorCode.ConnectionError }) + return true; + } + return false; + } + /// /// True when (or any inner exception) is a socket-level / connection /// loss OR an ISO-on-TCP framing/desync fault that a reopen can repair — a diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/FreezableTcpProxy.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/FreezableTcpProxy.cs new file mode 100644 index 00000000..6c5e48ce --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/FreezableTcpProxy.cs @@ -0,0 +1,104 @@ +using System.Net; +using System.Net.Sockets; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests; + +/// +/// An in-test TCP forwarder in front of the S7 fixture that can be FROZEN: while frozen it keeps +/// every socket open but forwards nothing in either direction — the frozen-but-established peer +/// (cable pull, firewall DROP) that only a wire-op deadline can detect, produced without touching +/// the shared fixture. It counts the ISO-on-TCP (TPKT) request frames the client sends while frozen, +/// so a test can assert how many wire operations went out without timing anything. +/// +internal sealed class FreezableTcpProxy : IAsyncDisposable +{ + private readonly string _targetHost; + private readonly int _targetPort; + private readonly TcpListener _listener = new(IPAddress.Loopback, 0); + private readonly CancellationTokenSource _cts = new(); + private readonly List _sockets = []; + private volatile bool _frozen; + private int _framesWhileFrozen; + + /// Starts a proxy forwarding to :. + /// The fixture host. + /// The fixture port. + public FreezableTcpProxy(string targetHost, int targetPort) + { + _targetHost = targetHost; + _targetPort = targetPort; + _listener.Start(); + _ = AcceptLoopAsync(); + } + + /// The local port clients connect to. + public int Port => ((IPEndPoint)_listener.LocalEndpoint).Port; + + /// TPKT request frames the client sent while the proxy was frozen. + public int FramesWhileFrozen => Volatile.Read(ref _framesWhileFrozen); + + /// Stops forwarding (sockets stay open). + public void Freeze() => _frozen = true; + + /// Resumes forwarding for connections made from now on. + public void Unfreeze() => _frozen = false; + + private async Task AcceptLoopAsync() + { + try + { + while (!_cts.IsCancellationRequested) + { + var client = await _listener.AcceptTcpClientAsync(_cts.Token).ConfigureAwait(false); + var upstream = new TcpClient(); + await upstream.ConnectAsync(_targetHost, _targetPort, _cts.Token).ConfigureAwait(false); + lock (_sockets) { _sockets.Add(client); _sockets.Add(upstream); } + // A connection accepted while frozen stays frozen for its whole life (its peer is "gone"). + var frozenAtBirth = _frozen; + _ = PumpAsync(client.GetStream(), upstream.GetStream(), countFrames: true, frozenAtBirth); + _ = PumpAsync(upstream.GetStream(), client.GetStream(), countFrames: false, frozenAtBirth); + } + } + catch { /* disposed */ } + } + + private async Task PumpAsync(NetworkStream from, NetworkStream to, bool countFrames, bool frozenAtBirth) + { + var buffer = new byte[8192]; + var pending = new List(); + try + { + while (!_cts.IsCancellationRequested) + { + var n = await from.ReadAsync(buffer, _cts.Token).ConfigureAwait(false); + if (n == 0) return; + var frozen = _frozen || frozenAtBirth; + if (countFrames && frozen) + { + // Count complete TPKT frames: 0x03 0x00 then a big-endian total length. + pending.AddRange(buffer.AsSpan(0, n).ToArray()); + while (pending.Count >= 4) + { + var len = (pending[2] << 8) | pending[3]; + if (len < 4 || pending.Count < len) break; + pending.RemoveRange(0, len); + Interlocked.Increment(ref _framesWhileFrozen); + } + } + if (frozen) continue; // swallow — the peer never answers + await to.WriteAsync(buffer.AsMemory(0, n), _cts.Token).ConfigureAwait(false); + } + } + catch { /* socket closed / disposed */ } + } + + /// + public ValueTask DisposeAsync() + { + _cts.Cancel(); + _listener.Stop(); + lock (_sockets) foreach (var s in _sockets) s.Dispose(); + _cts.Dispose(); + return ValueTask.CompletedTask; + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500FrozenPeerTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500FrozenPeerTests.cs new file mode 100644 index 00000000..088931de --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500FrozenPeerTests.cs @@ -0,0 +1,71 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500; + +/// +/// Live gate for R07:P1 over real S7comm: a frozen-but-established peer (produced by an in-test +/// , so the shared fixture is never touched). The first wire op of +/// the pass blows its deadline and marks the connection dead; the rest of the pass must NOT go to +/// the wire. The assertion counts request frames, not seconds. +/// +/// +/// What this gate does and does not prove. Verified by mutation (2026-10-22): on the real +/// stack this test also passes with the driver's dead-handle guard removed, because S7.Net 0.20 +/// honours the deadline's cancellation token and closes its own socket, so the next op fails at once +/// with ConnectionError and sends nothing. The driver guard (proven by +/// S7DeadHandleShortCircuitTests against a fake that does not self-close) is what covers an +/// op that ignores its token — the WaitAsync leg of S7OperationDeadline, where the +/// abandoned socket stays open and every further op would pay the full deadline. This test pins the +/// end-to-end property (one request per frozen pass, then recovery) against S7.Net upgrades. +/// +[Collection(Snap7ServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Device", "S7_1500")] +public sealed class S7_1500FrozenPeerTests(Snap7ServerFixture sim) +{ + /// A pass against a frozen peer issues ONE request, then fails the rest fast; it recovers after. + [Fact] + public async Task A_frozen_peer_costs_one_wire_op_per_pass_not_one_per_tag() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + var ct = TestContext.Current.CancellationToken; + + await using var proxy = new FreezableTcpProxy(sim.Host, sim.Port); + var baseOptions = S7_1500Profile.BuildOptions("127.0.0.1", proxy.Port); + var options = new S7DriverOptions + { + Host = baseOptions.Host, Port = baseOptions.Port, CpuType = baseOptions.CpuType, + Rack = baseOptions.Rack, Slot = baseOptions.Slot, + Timeout = TimeSpan.FromSeconds(1), // the per-op deadline that detects the frozen peer + Probe = baseOptions.Probe, + SpanReadMaxGapBytes = -1, // one wire request per tag, so "one per tag" is countable + RawTags = baseOptions.RawTags, + }; + string[] refs = + [ + S7_1500Profile.ProbeTag, S7_1500Profile.SmokeI16Tag, S7_1500Profile.SmokeI32Tag, + S7_1500Profile.SmokeF32Tag, S7_1500Profile.SmokeBoolTag, S7_1500Profile.WriteScratchTag, + ]; + + await using var drv = new S7Driver(options, driverInstanceId: "s7-frozen-peer-live"); + await drv.InitializeAsync("{}", ct); + + // Positive control: through the proxy, every tag reads Good. + (await drv.ReadAsync(refs, ct)).ShouldAllBe(s => s.StatusCode == 0u); + + proxy.Freeze(); + var frozen = await drv.ReadAsync(refs, ct); + frozen.ShouldAllBe(s => s.StatusCode != 0u); + proxy.FramesWhileFrozen.ShouldBe(1, "only the request that discovered the dead connection may go out"); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + + // Recovery: the next pass reopens (a fresh proxy connection) and reads Good again. + proxy.Unfreeze(); + var recovered = await drv.ReadAsync(refs, ct); + recovered.ShouldAllBe(s => s.StatusCode == 0u); + Convert.ToInt32(recovered[0].Value).ShouldBe(S7_1500Profile.ProbeSeedValue); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayWriteAndHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayWriteAndHealthTests.cs index fab2b496..27c5e20a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayWriteAndHealthTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ArrayWriteAndHealthTests.cs @@ -243,7 +243,9 @@ public sealed class S7ArrayWriteAndHealthTests var (drv, factory) = await TwoTagDriver(); using var _ = drv; factory.Created[0].WriteFaults["DB1.DBW0"] = new PlcException(ErrorCode.WriteData, "Accessing object not allowed"); - factory.Created[0].WriteFaults["DB1.DBW2"] = new IOException("socket glitch"); + // A Degraded failure that is NOT connection-fatal: since R07:P1 a fatal one (an IOException) + // would fail the rest of the batch without I/O, and C must reach the wire to be the success. + factory.Created[0].WriteFaults["DB1.DBW2"] = new PlcException(ErrorCode.SendData, "device glitch"); var res = await drv.WriteAsync( [new WriteRequest("A", (ushort)1), new WriteRequest("B", (ushort)2), new WriteRequest("C", (ushort)3)], diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DeadHandleShortCircuitTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DeadHandleShortCircuitTests.cs new file mode 100644 index 00000000..852e9d47 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7DeadHandleShortCircuitTests.cs @@ -0,0 +1,167 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// R07:P1 — once a pass has marked the connection dead (a wire op blew its deadline on a frozen +/// peer), the rest of that pass must not go to the wire: each further op would pay the full +/// timeout under the gate, so a 20-span pass took ~100 s and the comms-loss watchdog's clock only +/// started after it. The remaining items are stamped BadCommunicationError without I/O. +/// Every assertion counts wire operations; nothing is timed. +/// +[Trait("Category", "Unit")] +public sealed class S7DeadHandleShortCircuitTests +{ + private const uint BadCommunicationError = 0x80050000u; + + private static S7DriverOptions Options(int maxGap, params S7TagDefinition[] tags) => new() + { + Host = "192.0.2.1", + Timeout = TimeSpan.FromSeconds(5), + Probe = new S7ProbeOptions { Enabled = false }, + SpanReadMaxGapBytes = maxGap, + RawTags = S7RawTags.Entries(tags), + }; + + /// Five isolated words — far enough apart that each is its own span. + private static S7TagDefinition[] FarApartWords() => + [.. Enumerable.Range(0, 5).Select(i => new S7TagDefinition($"W{i}", $"DB{i + 1}.DBW0", S7DataType.UInt16, Writable: true))]; + + private static string[] Refs(int n) => [.. Enumerable.Range(0, n).Select(i => $"W{i}")]; + + private static async Task<(S7Driver, ScriptedS7PlcFactory)> StartAsync(S7DriverOptions options) + { + var factory = new ScriptedS7PlcFactory(); + var drv = new S7Driver(options, "s7-dead-short-circuit", factory); + await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); + return (drv, factory); + } + + /// Per-tag path: the first timeout ends the pass's wire traffic. + [Fact] + public async Task Per_tag_pass_stops_issuing_wire_ops_once_the_handle_is_dead() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords())); + using var _ = drv; + factory.Read = (_, _) => throw new TimeoutException("frozen peer"); + + var before = factory.WireOps; + var r = await drv.ReadAsync(Refs(5), ct); + + (factory.WireOps - before).ShouldBe(1, "only the op that discovered the dead handle may go to the wire"); + r.ShouldAllBe(s => s.StatusCode == BadCommunicationError); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + + // Positive control: the next pass reopens and serves every tag. + factory.Read = (_, _) => Task.FromResult((ushort)1); + var recovered = await drv.ReadAsync(Refs(5), ct); + recovered.ShouldAllBe(s => s.StatusCode == 0u); + factory.Created.Count.ShouldBe(2); + } + + /// Span path: arrays are block reads even alone, so five arrays are five spans. + [Fact] + public async Task Span_pass_stops_issuing_wire_ops_once_the_handle_is_dead() + { + var ct = TestContext.Current.CancellationToken; + S7TagDefinition[] arrays = + [.. Enumerable.Range(0, 5).Select(i => new S7TagDefinition($"W{i}", $"DB{i + 1}.DBW0", S7DataType.UInt16, Writable: false, ArrayCount: 2))]; + var (drv, factory) = await StartAsync(Options(maxGap: 16, arrays)); + using var _ = drv; + factory.ReadBytes = (_, _, _, _, _) => throw new TimeoutException("frozen peer"); + + var before = factory.WireOps; + var r = await drv.ReadAsync(Refs(5), ct); + + (factory.WireOps - before).ShouldBe(1); + r.ShouldAllBe(s => s.StatusCode == BadCommunicationError); + } + + /// + /// Multi-var path: a chunk that dies on a transport loss used to fall back to one per-tag read + /// per member — each paying the timeout again. Now it marks the handle dead and the fallback is + /// short-circuited. + /// + [Fact] + public async Task Multi_var_transport_loss_does_not_fall_back_to_per_tag_wire_reads() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory) = await StartAsync(Options(maxGap: 0, FarApartWords())); + using var _ = drv; + factory.ReadMulti = (_, _) => throw new TimeoutException("frozen peer"); + factory.Read = (_, _) => throw new TimeoutException("frozen peer"); + + var before = factory.WireOps; + var r = await drv.ReadAsync(Refs(5), ct); + + (factory.WireOps - before).ShouldBe(1); + r.ShouldAllBe(s => s.StatusCode == BadCommunicationError); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + } + + /// + /// Positive control for the classification: a multi-var REFUSAL (not a transport loss) still + /// falls back per tag, so the members the CPU will serve are served. + /// + [Fact] + public async Task Multi_var_refusal_still_falls_back_per_tag() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory) = await StartAsync(Options(maxGap: 0, FarApartWords())); + using var _ = drv; + // The live snap7 shape of a multi-var with one refused item. + factory.ReadMulti = (_, _) => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData, + new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongNumberReceivedBytes)); + factory.Read = (_, _) => Task.FromResult((ushort)9); + + var before = factory.WireOps; + var r = await drv.ReadAsync(Refs(5), ct); + + (factory.WireOps - before).ShouldBe(6, "one multi-var request, then one per-tag read each"); + r.ShouldAllBe(s => s.StatusCode == 0u); + factory.Created.Count.ShouldBe(1, "a refusal is not a reason to reopen"); + } + + /// A refusal does not short-circuit the pass: the CPU answered, the handle is fine. + [Fact] + public async Task A_refusal_does_not_short_circuit_the_pass() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords())); + using var _ = drv; + factory.Read = (addr, _) => addr == "DB1.DBW0" + ? throw new Exception("Received error from PLC: Object does not exist.") + : Task.FromResult((ushort)3); + + var before = factory.WireOps; + var r = await drv.ReadAsync(Refs(5), ct); + + (factory.WireOps - before).ShouldBe(5); + r.Skip(1).ShouldAllBe(s => s.StatusCode == 0u); + } + + /// Write batch: the first transport loss fails the rest of the batch without I/O. + [Fact] + public async Task Write_batch_stops_issuing_wire_ops_once_the_handle_is_dead() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory) = await StartAsync(Options(maxGap: -1, FarApartWords())); + using var _ = drv; + factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset"); + + var before = factory.WireOps; + var w = await drv.WriteAsync([.. Refs(5).Select(r => new WriteRequest(r, (ushort)1))], ct); + + (factory.WireOps - before).ShouldBe(1); + w.ShouldAllBe(x => x.StatusCode == BadCommunicationError); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + + // Positive control: the next batch reopens and writes. + factory.Write = (_, _, _) => Task.CompletedTask; + (await drv.WriteAsync([.. Refs(5).Select(r => new WriteRequest(r, (ushort)1))], ct)) + .ShouldAllBe(x => x.StatusCode == 0u); + } +} From 1f7ab80b814402a9bf93b6999b5848bd294da5c7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:30:12 -0400 Subject: [PATCH 63/67] fix(s7): a refused span falls back per member and is not regrouped (R07:P2) A span's block is the union of its members' ranges, so one member that runs past the DB's real end (or over-declares a STRING) made the CPU refuse the whole block, and every neighbour within it was Bad on every poll. The planner's stated invariant (a grouped read cannot fault where per-tag reads would succeed) was false; the multi-var path already fell back, spans did not. - A refused multi-member span hands its members back; they are read one at a time for that pass, bypassing the multi-var pass. - _noGroup remembers the members refused on their own (or every member when none was) and keeps them out of later plans, so the rest group again. It is cleared by every initialize publish. Non-refusal span failures are unchanged (every member stamped). - The once-per-tag refusal Warning memo (R07:H4) is now also cleared by the publish, and shutdown resets the write-side Degraded owner, as documented. - Live gate: MW10 + MD254 (2 bytes past the 256-byte M area) merged into one refused block; MW10 keeps reading 4097 on every pass. Refs #742 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- docs/drivers/S7.md | 7 + .../ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 74 +++++++- .../S7ReadSpanPlanner.cs | 10 +- .../S7_1500/S7_1500SpanRefusalTests.cs | 49 ++++++ .../S7SpanRefusalFallbackTests.cs | 161 ++++++++++++++++++ 5 files changed, 295 insertions(+), 6 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanRefusalTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanRefusalFallbackTests.cs diff --git a/docs/drivers/S7.md b/docs/drivers/S7.md index 9491c93c..207d26b1 100644 --- a/docs/drivers/S7.md +++ b/docs/drivers/S7.md @@ -344,6 +344,13 @@ to one doomed per-tag read per member. Without this an op that ignores its cance one full deadline per remaining item under the gate, and `Degraded` (and so the comms-loss watchdog's clock) only became visible after the whole pass. +**A refused span falls back per member (R07:P2).** A grouped block read covers the union of its members' +ranges, so one member the CPU refuses (running past the DB's real end, an over-declared `stringLength`) +used to make every neighbour in the block Bad on every poll. When the CPU refuses a multi-member span the +driver re-reads its members one at a time for that pass and remembers which tags must not be grouped — +the members refused on their own, or every member when none was — so later passes group only the rest. +The memo lasts until the next initialize. A span failure that is not a refusal still stamps every member. + **Connect backoff.** A dead PLC costs one connect attempt per backoff window (1 s doubling to a 30 s cap), not one per read / poll tick / probe tick: inside the window `EnsureConnectedAsync` fails fast (`BadCommunicationError`, `Degraded`) without a connect. A successful connect clears the window, so a diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index e413125c..9b0ce86b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -221,6 +221,14 @@ public sealed class S7Driver /// by every initialize publish. /// private readonly ConcurrentDictionary _refusalWarned = new(StringComparer.Ordinal); + + /// + /// Tags the span planner must not group (R07:P2): members of a span the CPU refused as a block — + /// the ones that were refused on their own, or every member when none was. They are read + /// individually for the rest of the generation; a re-initialize (a corrected DB, a fixed tag) + /// groups everything again. Guarded by . + /// + private readonly HashSet _noGroup = new(StringComparer.Ordinal); private bool _disposed; /// @@ -338,6 +346,8 @@ public sealed class S7Driver _options = options; Volatile.Write(ref _tables, new TagTables(tags, parsed)); + _refusalWarned.Clear(); + _noGroup.Clear(); _skippedTags.BeginRebuild(options.RawTags.Count); foreach (var (rawPath, reason) in skipped) { @@ -453,6 +463,7 @@ public sealed class S7Driver { if (_generation != gen) return; // a newer initialize owns the state now _writeFault = null; + _writeOwnedError = null; _connectBackoff.RecordSuccess(); Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null); } @@ -732,6 +743,13 @@ public sealed class S7Driver // Indexes the per-tag path must serve: everything, when grouping is off; otherwise the // planner's leftovers (Timer/Counter) plus any tag whose address won't parse. var perTag = new List(); + // Indexes that must take the per-tag path WITHOUT the multi-var pass: tags the planner may + // not group (R07:P2) and the members of a span refused this pass. Batching them into a + // multi-var request would just refuse the whole chunk again. + List? forcedPerTag = null; + // Members of each span refused this pass, and which of them were then refused on their own. + List>? refusedSpans = null; + HashSet? refusedAlone = null; if (_options.SpanReadMaxGapBytes >= 0) { @@ -739,6 +757,11 @@ public sealed class S7Driver for (var i = 0; i < fullReferences.Count; i++) { if (resolved[i] is not { } tag) continue; + if (_noGroup.Contains(tag.Name)) + { + (forcedPerTag ??= []).Add(i); + continue; + } if (!TryGetParsedAddress(tag, out var addr)) { // Defensive: every tag pre-parses at init. A tag that somehow didn't goes @@ -769,7 +792,12 @@ public sealed class S7Driver foreach (var member in span.Members) results[member.OriginalIndex] = DeadHandleSnapshot(now); continue; } - await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false); + if (await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false) + is { } refusedMembers) + { + (refusedSpans ??= []).Add(refusedMembers); + (forcedPerTag ??= []).AddRange(refusedMembers); + } } perTag.AddRange(plan.LeftoverIndexes); } @@ -794,6 +822,11 @@ public sealed class S7Driver perTag = await ReadLeftoversByMultiVarAsync( plc, resolved, perTag, results, now, outcome, cancellationToken).ConfigureAwait(false); } + if (forcedPerTag is not null) + { + perTag.AddRange(forcedPerTag); + perTag.Sort(); + } foreach (var i in perTag) { @@ -823,9 +856,25 @@ public sealed class S7Driver } catch (Exception ex) { + if (refusedSpans is not null && IsRefusal(ex)) (refusedAlone ??= []).Add(i); results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [resolved[i]!.Name]), null, now); } } + + // Remember what must not be grouped again (R07:P2). Skipped when the handle died mid-pass: + // the per-member reads then proved nothing about which member the CPU refuses. + if (refusedSpans is not null && !_plcDead) + { + foreach (var members in refusedSpans) + { + var bad = members.Where(m => refusedAlone?.Contains(m) == true).ToList(); + var memo = bad.Count > 0 ? bad : [.. members]; + foreach (var m in memo) _noGroup.Add(resolved[m]!.Name); + _logger.LogInformation( + "S7 CPU refused a grouped read of {MemberCount} tag(s); reading {Tags} individually from now on. Driver={DriverInstanceId}", + members.Count, string.Join(", ", memo.Select(m => resolved[m]!.Name)), _driverInstanceId); + } + } } finally { @@ -1222,8 +1271,12 @@ public sealed class S7Driver /// The batch timestamp, shared by every tag in the batch. /// The pass's outcome accumulator; every member records into it. /// Cancellation token for the read. - /// A task that completes once every member of the span has been stamped. - private async Task ReadSpanAsync( + /// + /// once every member has been stamped; or, when the CPU REFUSED the block + /// of a multi-member span (R07:P2 — one member past the DB's end refuses them all), the members' + /// original indexes, unstamped, for the caller to read one at a time. + /// + private async Task?> ReadSpanAsync( IS7Plc plc, S7ReadSpanPlanner.Span span, DataValueSnapshot[] results, DateTime now, ReadPassOutcome outcome, CancellationToken ct) { @@ -1244,12 +1297,24 @@ public sealed class S7Driver _plcDead = true; throw; } + catch (Exception ex) when (span.Members.Count > 1 && IsRefusal(ex)) + { + // The planner's old invariant ("a grouped read cannot fault where the per-tag reads would + // have succeeded") is false: the block is the UNION of the members' ranges, so one member + // running past the DB's real end, or over-declaring a STRING, makes the CPU refuse every + // neighbour with it. Hand the members back for individual reads. + _logger.LogDebug(ex, + "S7 driver {DriverInstanceId}: CPU refused the block read of {Area} db{Db} @{Start}+{Length}; " + + "re-reading its {Count} member(s) individually", _driverInstanceId, span.Area, span.DbNumber, + span.StartByte, span.LengthBytes, span.Members.Count); + return [.. span.Members.Select(m => m.OriginalIndex)]; + } catch (Exception ex) { var status = RecordReadFailure(ex, outcome, span.Members.Select(m => m.Tag.Name)); foreach (var member in span.Members) results[member.OriginalIndex] = new DataValueSnapshot(null, status, null, now); - return; + return null; } foreach (var member in span.Members) @@ -1268,6 +1333,7 @@ public sealed class S7Driver new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [member.Tag.Name]), null, now); } } + return null; } /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ReadSpanPlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ReadSpanPlanner.cs index 96f7770a..dfe05b77 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ReadSpanPlanner.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7ReadSpanPlanner.cs @@ -11,8 +11,14 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.S7; /// The safety invariant is that a span never extends past its last member's end. A /// span may deliberately read bytes nobody asked for BETWEEN its members (that is what the /// gap budget buys), but every byte it requests lies inside a range some member was going -/// to read anyway. A grouped read therefore cannot fault (area overrun, DB overrun) where -/// the individual per-tag reads would have succeeded. +/// to read anyway. +/// +/// +/// That does NOT make a grouped read as safe as the per-tag reads (R07:P2): the block is +/// the union of its members' ranges, so ONE member the CPU refuses (running past the DB's real +/// end, an over-declared STRING) makes it refuse the whole block, neighbours included. +/// owns that case: a refused multi-member span is re-read one +/// member at a time, and the members that must not be grouped are left out of later plans. /// /// /// Timer and Counter tags are never grouped. Their diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanRefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanRefusalTests.cs new file mode 100644 index 00000000..96dde491 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500SpanRefusalTests.cs @@ -0,0 +1,49 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests.S7_1500; + +/// +/// Live gate for R07:P2 over real S7comm. The fixture's M area is 256 bytes. MW10 is seeded +/// (4097); MD254 runs two bytes past the end of the area. With the gap budget opened wide +/// the planner merges them into ONE block MB10..258, which the CPU refuses — before the fix +/// that made the perfectly readable MW10 Bad (and the driver Degraded) on every poll. +/// +[Collection(Snap7ServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Device", "S7_1500")] +public sealed class S7_1500SpanRefusalTests(Snap7ServerFixture sim) +{ + /// The good neighbour of an over-running member keeps reading its seeded value, every pass. + [Fact] + public async Task A_member_past_the_area_end_does_not_spoil_its_span_neighbour() + { + if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason); + var ct = TestContext.Current.CancellationToken; + + var options = new S7DriverOptions + { + Host = sim.Host, Port = sim.Port, CpuType = S7CpuType.S71500, + Timeout = TimeSpan.FromSeconds(5), + Probe = new S7ProbeOptions { Enabled = false }, + // Wide enough that MW10 and MD254 land in one planned span. + SpanReadMaxGapBytes = 256, + SpanReadMaxSpanBytes = 256, + RawTags = S7RawTags.Entries( + new S7TagDefinition("Mk/Word10", "MW10", S7DataType.UInt16, Writable: false), + new S7TagDefinition("Mk/PastEnd", "MD254", S7DataType.Int32, Writable: false)), + }; + await using var drv = new S7Driver(options, driverInstanceId: "s7-span-refusal-live"); + await drv.InitializeAsync("{}", ct); + + for (var pass = 0; pass < 3; pass++) + { + var r = await drv.ReadAsync(["Mk/Word10", "Mk/PastEnd"], ct); + r[0].StatusCode.ShouldBe(0u, $"pass {pass}: the in-range neighbour must be served"); + Convert.ToInt32(r[0].Value).ShouldBe(4097); + r[1].StatusCode.ShouldNotBe(0u, "the over-running member is refused on its own"); + drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault"); + } + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanRefusalFallbackTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanRefusalFallbackTests.cs new file mode 100644 index 00000000..ce6ff0ca --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanRefusalFallbackTests.cs @@ -0,0 +1,161 @@ +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// R07:P2 — a span's block is the union of its members' ranges, so ONE member that runs past the +/// DB's real end (or over-declares a STRING) made the CPU refuse the whole block and every +/// neighbour inside it went Bad on every poll. A refused span now re-reads its members one at a +/// time for that pass and remembers which tags must not be grouped, so later passes group only the +/// members that can be grouped. +/// +[Trait("Category", "Unit")] +public sealed class S7SpanRefusalFallbackTests +{ + private const uint BadDeviceFailure = 0x808B0000u; + + /// DB1 is 4 bytes long on this fake CPU: anything reaching past byte 4 is refused. + private const int DbLength = 4; + + private static Exception OutOfRange() => new("Received error from PLC: Address out of range."); + + private static (S7Driver Driver, ScriptedS7PlcFactory Factory, Func BlockReads, Func TagReads) Start( + params S7TagDefinition[] tags) + { + var factory = new ScriptedS7PlcFactory(); + var blockReads = 0; + var tagReads = 0; + factory.ReadBytes = (_, _, start, count, _) => + { + Interlocked.Increment(ref blockReads); + if (start + count > DbLength) throw OutOfRange(); + var b = new byte[count]; + for (var i = 0; i < count; i++) b[i] = (byte)(start + i + 1); + return Task.FromResult(b); + }; + factory.Read = (addr, _) => + { + Interlocked.Increment(ref tagReads); + var offset = int.Parse(addr[(addr.LastIndexOf('W') + 1)..], System.Globalization.CultureInfo.InvariantCulture); + if (offset + 2 > DbLength) throw OutOfRange(); + return Task.FromResult((ushort)(((offset + 1) << 8) | (offset + 2))); + }; + var drv = new S7Driver(new S7DriverOptions + { + Host = "192.0.2.1", + Probe = new S7ProbeOptions { Enabled = false }, + RawTags = S7RawTags.Entries(tags), + }, "s7-span-fallback", factory); + return (drv, factory, () => Volatile.Read(ref blockReads), () => Volatile.Read(ref tagReads)); + } + + private static S7TagDefinition Word(string name, int offset) => + new(name, $"DB1.DBW{offset}", S7DataType.UInt16, Writable: false); + + /// + /// The over-running member is refused on its own; its neighbours are served from the very first + /// pass, and from the second pass on the neighbours are grouped again WITHOUT the bad member. + /// + [Fact] + public async Task A_refused_span_falls_back_per_member_and_stops_grouping_the_bad_member() + { + var ct = TestContext.Current.CancellationToken; + var (drv, _, blockReads, tagReads) = Start(Word("A", 0), Word("B", 2), Word("C", 4)); + using var _ = drv; + await drv.InitializeAsync("{}", ct); + string[] refs = ["A", "B", "C"]; + + // Pass 1: the 6-byte span is refused, then each member is read on its own. + var r1 = await drv.ReadAsync(refs, ct); + r1[0].StatusCode.ShouldBe(0u); + r1[0].Value.ShouldBe((ushort)0x0102); + r1[1].StatusCode.ShouldBe(0u); + r1[1].Value.ShouldBe((ushort)0x0304); + r1[2].StatusCode.ShouldBe(BadDeviceFailure); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + blockReads().ShouldBe(1); + tagReads().ShouldBe(3); + + // Pass 2: A+B ride ONE block read again (the planner no longer sees C); C reads alone. + var r2 = await drv.ReadAsync(refs, ct); + r2[0].Value.ShouldBe((ushort)0x0102); + r2[1].Value.ShouldBe((ushort)0x0304); + r2[2].StatusCode.ShouldBe(BadDeviceFailure); + blockReads().ShouldBe(2, "A+B grouped again, without the refused member"); + tagReads().ShouldBe(4, "only C took the per-tag path on pass 2"); + } + + /// + /// When the block is refused but every member reads fine alone, the span itself is the problem: + /// all of its members stop being grouped (and stop re-trying the refused block every pass). + /// + [Fact] + public async Task A_refused_span_whose_members_all_read_alone_is_not_grouped_again() + { + var ct = TestContext.Current.CancellationToken; + var factory = new ScriptedS7PlcFactory(); + var blockReads = 0; + factory.ReadBytes = (_, _, _, _, _) => { Interlocked.Increment(ref blockReads); throw OutOfRange(); }; + factory.Read = (_, _) => Task.FromResult((ushort)7); + using var drv = new S7Driver(new S7DriverOptions + { + Host = "192.0.2.1", + Probe = new S7ProbeOptions { Enabled = false }, + RawTags = S7RawTags.Entries(Word("A", 0), Word("B", 2)), + }, "s7-span-fallback-all", factory); + await drv.InitializeAsync("{}", ct); + + (await drv.ReadAsync(["A", "B"], ct)).ShouldAllBe(s => s.StatusCode == 0u); + (await drv.ReadAsync(["A", "B"], ct)).ShouldAllBe(s => s.StatusCode == 0u); + Volatile.Read(ref blockReads).ShouldBe(1, "the refused block is not re-issued on later passes"); + } + + /// + /// Positive control: a span failure that is NOT a refusal (a device-layer fault) does not fall + /// back — re-reading N members one by one against a failing device would only multiply the fault. + /// + [Fact] + public async Task A_non_refusal_span_failure_does_not_fall_back() + { + var ct = TestContext.Current.CancellationToken; + var factory = new ScriptedS7PlcFactory + { + ReadBytes = (_, _, _, _, _) => throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.SendData, "device fault"), + }; + using var drv = new S7Driver(new S7DriverOptions + { + Host = "192.0.2.1", + Probe = new S7ProbeOptions { Enabled = false }, + RawTags = S7RawTags.Entries(Word("A", 0), Word("B", 2)), + }, "s7-span-nofallback", factory); + await drv.InitializeAsync("{}", ct); + + var before = factory.WireOps; + var r = await drv.ReadAsync(["A", "B"], ct); + r.ShouldAllBe(s => s.StatusCode == BadDeviceFailure); + (factory.WireOps - before).ShouldBe(1); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + } + + /// The memo is per generation: a re-initialize (e.g. a corrected DB) groups everything again. + [Fact] + public async Task Reinitialize_clears_the_do_not_group_memo() + { + var ct = TestContext.Current.CancellationToken; + var (drv, _, blockReads, tagReads) = Start(Word("A", 0), Word("B", 2), Word("C", 4)); + using var _ = drv; + await drv.InitializeAsync("{}", ct); + await drv.ReadAsync(["A", "B", "C"], ct); + blockReads().ShouldBe(1); + tagReads().ShouldBe(3); + + await drv.InitializeAsync("{}", ct); + await drv.ReadAsync(["A", "B", "C"], ct); + // With the memo still in force pass 2 would be one A+B block read plus ONE per-tag read (C). + // Cleared, the full A..C span is tried again, refused again, and all three re-read alone. + blockReads().ShouldBe(2); + tagReads().ShouldBe(6, "after re-init the full span is tried again (and refused again)"); + } +} From a0b635f67383e5c21532deeede1633082980cb34 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:33:38 -0400 Subject: [PATCH 64/67] fix(s7): probe gate contention is no verdict; Test Connect honours timeoutMs (R07:H7) - The background probe reported the host Stopped whenever a read pass held _gate past the probe deadline: contention is the connection being used, not lost. A contended tick now gives no verdict (counted for tests); a real probe failure still reports Stopped. A tick that races a teardown no longer reopens a connection on an uninitialised driver. - S7DriverProbe deserialised S7DriverOptions directly, whose Timeout is a TimeSpan no config names, so "timeoutMs" was ignored and every handshake waited the whole Test Connect budget. The handshake and test read are now bounded by timeoutMs, capped by the caller's timeout. Refs #742 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- docs/drivers/S7.md | 5 +- .../ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 112 ++++++++++------ .../S7DriverProbe.cs | 39 +++++- .../S7ProbeContentionAndTimeoutTests.cs | 121 ++++++++++++++++++ 4 files changed, 233 insertions(+), 44 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ProbeContentionAndTimeoutTests.cs diff --git a/docs/drivers/S7.md b/docs/drivers/S7.md index 207d26b1..889d08be 100644 --- a/docs/drivers/S7.md +++ b/docs/drivers/S7.md @@ -88,6 +88,9 @@ After the handshake, Test Connect reads 2 bytes from `MB0`. A CPU with PUT/GET d default) completes the handshake but denies every read, so the probe fails with an actionable "enable PUT/GET communication" message instead of a false green tick. A CPU that merely rejects that address (no `MB0`) still passes — the connection is fine; any other read fault fails the probe with its message. +Both are recognised in the bare-`Exception` shape S7.Net's read path really raises. The handshake and +test read are bounded by the config's `timeoutMs` (the driver's own per-operation timeout), capped by the +Test Connect timeout — before R07:H7 the config value was ignored. ## Address forms @@ -286,7 +289,7 @@ AdminUI `/hosts` page (Gitea #640). See `docs/Telemetry.md` ("Driver diagnostics | `IWritable` | `WriteAsync` → `S7.Net.Plc.WriteAsync` | Read-only tags (`Writable=false`) return `BadNotWritable` | | `ITagDiscovery` | `DiscoverAsync` | Emits a flat `S7/` folder of the configured tags — no live browse | | `ISubscribable` | per-tag poll loop with capped exponential backoff | S7 has no push model; floor is 100 ms (the CPU services the comms mailbox once per scan) | -| `IHostConnectivityProbe` | periodic `S7.Net.Plc.ReadStatusAsync` (CPU-status PDU) | `host:port` host key; `Running`/`Stopped` transitions raise `OnHostStatusChanged` | +| `IHostConnectivityProbe` | periodic `S7.Net.Plc.ReadStatusAsync` (CPU-status PDU) | `host:port` host key; `Running`/`Stopped` transitions raise `OnHostStatusChanged`. A tick that finds the gate held past the probe deadline (a long read pass) gives **no verdict** — it no longer reports `Stopped` for a connection that is busy, not lost (R07:H7) | ### Single-connection policy diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index 9b0ce86b..9f0e98ae 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -2792,66 +2792,96 @@ public sealed class S7Driver { while (!ct.IsCancellationRequested) { - var success = false; + // Running / Stopped, or null for "no verdict this tick" — which leaves the host state alone. + HostState? verdict = null; try { // Probe via S7.Net's low-cost GetCpuStatus — returns the CPU state (Run/Stop) // and is intentionally light on the comms mailbox. Single-word Plc.ReadAsync // would also work but GetCpuStatus doubles as a "PLC actually up" check. - using var gateCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - gateCts.CancelAfter(_options.Probe.Timeout); - - await _gate.WaitAsync(gateCts.Token).ConfigureAwait(false); - try + // + // Gate CONTENTION is not a verdict (R07:H7): a read pass or write batch holding the + // gate past the probe deadline is the connection being USED, and that pass reports + // its own outcome through health. Reporting the host Stopped for it flapped /hosts on + // every long pass against a perfectly healthy PLC. + if (!await _gate.WaitAsync(_options.Probe.Timeout, ct).ConfigureAwait(false)) + { + Interlocked.Increment(ref _probeGateContentions); + } + else { try { - // EnsureConnectedAsync doubles as the reconnect backstop: if a read/write - // marked the handle dead (or the socket dropped between ticks), the probe - // reopens it here so recovery doesn't wait for the next data call. - // It runs on the LOOP token, not the (shorter) probe deadline: the connect is bounded by - // _options.Timeout inside, and a timeout there must count as a failure so the - // backoff window opens — cutting it with the probe deadline looked like a caller cancel, - // recorded nothing, and let a black-holed host cost a connect attempt every probe tick. - var plc = await EnsureConnectedAsync(ct).ConfigureAwait(false); - // The probe deadline bounds only the CPU-status round trip. - using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - probeCts.CancelAfter(_options.Probe.Timeout); - await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false); - success = true; - } - catch (OperationCanceledException) when (ct.IsCancellationRequested) - { - // Loop teardown — rethrow to the outer filter (after finally releases the gate). - throw; - } - catch (OperationCanceledException) - { - // Probe deadline fired mid-PDU: the CPU-status response may still be in - // flight, so the handle can't be reused (half-read ⇒ desync). We hold _gate - // here, keeping _plcDead gate-guarded. - _plcDead = true; - } - catch (Exception ex) - { - // Classify + mark while holding _gate so a wire-dead/desynced - // handle whose TcpClient still claims Connected is reopened by the NEXT - // tick's EnsureConnectedAsync instead of being re-probed forever. - MarkConnectionDeadIfFatal(ex); + // A tick that raced a teardown / re-initialize has nothing to probe and must + // not reopen a connection on a driver that is not initialized. + if (_initialized) + verdict = await ProbeOnceAsync(ct).ConfigureAwait(false); } + finally { _gate.Release(); } } - finally { _gate.Release(); } } catch (OperationCanceledException) when (ct.IsCancellationRequested) { return; } - catch { /* gate-wait timeout / other — treated as Stopped below (gate contention is not a connection fault) */ } + catch { verdict = HostState.Stopped; /* any other probe fault */ } - TransitionTo(success ? HostState.Running : HostState.Stopped); + if (verdict is { } state) TransitionTo(state); try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); } catch (OperationCanceledException) { return; } } } + /// + /// One probe round trip. Caller holds . Returns the verdict; rethrows only + /// loop teardown. + /// + /// The probe loop's token. + /// or . + private async Task ProbeOnceAsync(CancellationToken ct) + { + try + { + // EnsureConnectedAsync doubles as the reconnect backstop: if a read/write marked the handle + // dead (or the socket dropped between ticks), the probe reopens it here so recovery doesn't + // wait for the next data call. It runs on the LOOP token, not the (shorter) probe deadline: + // the connect is bounded by _options.Timeout inside, and a timeout there must count as a + // failure so the backoff window opens — cutting it with the probe deadline looked like a + // caller cancel, recorded nothing, and let a black-holed host cost a connect attempt every + // probe tick. + var plc = await EnsureConnectedAsync(ct).ConfigureAwait(false); + // The probe deadline bounds only the CPU-status round trip. + using var probeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + probeCts.CancelAfter(_options.Probe.Timeout); + await plc.ReadStatusAsync(probeCts.Token).ConfigureAwait(false); + return HostState.Running; + } + catch (OperationCanceledException) when (ct.IsCancellationRequested) + { + throw; // loop teardown — the caller's finally releases the gate + } + catch (OperationCanceledException) + { + // Probe deadline fired mid-PDU: the CPU-status response may still be in flight, so the + // handle can't be reused (half-read ⇒ desync). The caller holds _gate, keeping _plcDead + // gate-guarded. + _plcDead = true; + return HostState.Stopped; + } + catch (Exception ex) + { + // Classify + mark while holding _gate so a wire-dead/desynced handle whose TcpClient still + // claims Connected is reopened by the NEXT tick's EnsureConnectedAsync instead of being + // re-probed forever. + MarkConnectionDeadIfFatal(ex); + return HostState.Stopped; + } + } + + /// Probe ticks that found the gate held past the probe deadline and gave no verdict. + private int _probeGateContentions; + + /// Count of probe ticks skipped for gate contention. Internal so a test can wait on it. + internal int ProbeGateContentions => Volatile.Read(ref _probeGateContentions); + private void TransitionTo(HostState newState) { HostState old; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs index 13925989..ad53b8b7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs @@ -42,6 +42,14 @@ public sealed class S7DriverProbe : IDriverProbe if (string.IsNullOrWhiteSpace(host) || port <= 0) return new(false, "Config has no host/port to probe.", null); + // The S7 handshake + test read are bounded by the driver's own per-operation timeout + // (config "timeoutMs", what the deployed driver will wait), capped by the caller's overall + // Test Connect timeout. The options type carries it as a TimeSpan the JSON never names, so it + // used to be ignored and every handshake waited the full Test Connect timeout (R07:H7). + var opTimeout = ReadTimeoutMs(configJson) is { } ms && TimeSpan.FromMilliseconds(ms) < timeout + ? TimeSpan.FromMilliseconds(ms) + : timeout; + // Phase 1: bare TCP preflight — fast rejection for unreachable hosts. var sw = Stopwatch.StartNew(); try @@ -71,9 +79,9 @@ public sealed class S7DriverProbe : IDriverProbe var plc = S7Tsap.Resolve(opts) is { } tsap ? new Plc(host, port, tsap) : new Plc(S7CpuTypeMap.ToS7Net(opts.CpuType), host, port, opts.Rack, opts.Slot); - plc.ReadTimeout = (int)timeout.TotalMilliseconds; + plc.ReadTimeout = (int)opTimeout.TotalMilliseconds; using var handshakeCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - handshakeCts.CancelAfter(timeout); + handshakeCts.CancelAfter(opTimeout); try { await plc.OpenAsync(handshakeCts.Token); @@ -148,4 +156,31 @@ public sealed class S7DriverProbe : IDriverProbe private static (string host, int port) ExtractTarget(S7DriverOptions opts) => (opts.Host, opts.Port); + + /// + /// The config's timeoutMs (any casing, as the driver factory binds it), or null when absent + /// or not a positive integer. + /// + /// The driver config. + /// The configured per-operation timeout in milliseconds, or null. + internal static int? ReadTimeoutMs(string configJson) + { + try + { + using var doc = JsonDocument.Parse(configJson); + if (doc.RootElement.ValueKind != JsonValueKind.Object) return null; + foreach (var prop in doc.RootElement.EnumerateObject()) + { + if (!string.Equals(prop.Name, "timeoutMs", StringComparison.OrdinalIgnoreCase)) continue; + return prop.Value.ValueKind == JsonValueKind.Number && prop.Value.TryGetInt32(out var ms) && ms > 0 + ? ms + : null; + } + return null; + } + catch (JsonException) + { + return null; + } + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ProbeContentionAndTimeoutTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ProbeContentionAndTimeoutTests.cs new file mode 100644 index 00000000..6289a9ea --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7ProbeContentionAndTimeoutTests.cs @@ -0,0 +1,121 @@ +using System.Net; +using System.Net.Sockets; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.S7.Tests; + +/// +/// R07:H7 — (a) the background probe used to report the host Stopped whenever a long read +/// pass held the gate past the probe deadline: contention is the connection being USED, not lost; +/// (b) Test Connect ignored the config's timeoutMs. Waits are condition waits on counters and +/// events; bounds are generous. +/// +[Trait("Category", "Unit")] +public sealed class S7ProbeContentionAndTimeoutTests +{ + private static readonly TimeSpan Bound = TimeSpan.FromSeconds(15); + + private static async Task WaitUntilAsync(Func condition, CancellationToken ct) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(Bound); + while (!condition()) + await Task.Delay(20, cts.Token); // polling a condition, not asserting on elapsed time + } + + /// A read pass holding the gate past the probe deadline does not make the host Stopped. + [Fact] + public async Task Gate_contention_is_no_verdict_but_a_real_probe_failure_still_is() + { + var ct = TestContext.Current.CancellationToken; + var factory = new ScriptedS7PlcFactory(); + var readEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseRead = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var drv = new S7Driver(new S7DriverOptions + { + Host = "192.0.2.1", + Timeout = TimeSpan.FromSeconds(30), + Probe = new S7ProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromSeconds(1) }, + RawTags = S7RawTags.Entries(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)), + }, "s7-probe-contention", factory); + var transitions = new List(); + drv.OnHostStatusChanged += (_, e) => { lock (transitions) transitions.Add(e.NewState); }; + await drv.InitializeAsync("{}", ct); + + await WaitUntilAsync(() => drv.GetHostStatuses()[0].State == HostState.Running, ct); + + // A read that holds the gate until released. + factory.Read = async (_, token) => + { + readEntered.TrySetResult(); + await releaseRead.Task.WaitAsync(token); + return (ushort)1; + }; + var read = drv.ReadAsync(["W0"], ct); + await readEntered.Task.WaitAsync(Bound, ct); + + // At least two probe ticks give up on the gate... + var contentionsBefore = drv.ProbeGateContentions; + await WaitUntilAsync(() => drv.ProbeGateContentions >= contentionsBefore + 2, ct); + // ...and the host is still Running — no Stopped transition was published for them. + drv.GetHostStatuses()[0].State.ShouldBe(HostState.Running); + lock (transitions) transitions.ShouldNotContain(HostState.Stopped); + + releaseRead.SetResult(); + (await read.WaitAsync(Bound, ct))[0].StatusCode.ShouldBe(0u); + + // Positive control: a genuine probe failure still reports Stopped. + factory.ReadStatus = _ => throw new System.IO.IOException("connection reset"); + await WaitUntilAsync(() => drv.GetHostStatuses()[0].State == HostState.Stopped, ct); + } + + /// Test Connect's handshake is bounded by the config's timeoutMs, not the caller's whole budget. + [Fact] + public async Task Test_connect_honours_the_config_timeoutMs() + { + var ct = TestContext.Current.CancellationToken; + // A TCP endpoint that accepts and then never answers the ISO-on-TCP handshake. + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var held = new List(); + _ = Task.Run(async () => + { + try { while (true) { var c = await listener.AcceptTcpClientAsync(ct); lock (held) held.Add(c); } } + catch { /* stopped */ } + }, ct); + try + { + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + var json = $$"""{"host":"127.0.0.1","port":{{port}},"timeoutMs":1000}"""; + + // The caller's budget (60 s) is far beyond the safety token (20 s): only a probe that honours + // timeoutMs returns its own "handshake failed: timed out"; one that waits the caller's budget + // is cut by the safety token and reports the caller-timeout message instead. + using var safety = CancellationTokenSource.CreateLinkedTokenSource(ct); + safety.CancelAfter(TimeSpan.FromSeconds(20)); + var r = await new S7DriverProbe().ProbeAsync(json, TimeSpan.FromSeconds(60), safety.Token); + + r.Ok.ShouldBeFalse(); + r.Message!.ShouldContain("handshake failed: timed out"); + safety.IsCancellationRequested.ShouldBeFalse(); + } + finally + { + listener.Stop(); + lock (held) foreach (var c in held) c.Dispose(); + } + } + + /// The config reader: any casing, positive integers only. + [Fact] + public void ReadTimeoutMs_reads_the_driver_key() + { + S7DriverProbe.ReadTimeoutMs("""{"TimeoutMs":2500}""").ShouldBe(2500); + S7DriverProbe.ReadTimeoutMs("""{"timeoutms":10}""").ShouldBe(10); + S7DriverProbe.ReadTimeoutMs("""{"timeoutMs":0}""").ShouldBeNull(); + S7DriverProbe.ReadTimeoutMs("""{"host":"x"}""").ShouldBeNull(); + S7DriverProbe.ReadTimeoutMs("not json").ShouldBeNull(); + } +} From e3cb4f0c7b6909bb5610edd1cc0c4c49f13577ec Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:45:04 -0400 Subject: [PATCH 65/67] fix(ablegacy): opaque replies are neither answers nor faults; round-2 minors Review r1 N1 (Important): the I1 fix made IsDeviceRefusal default-open and mapped libplctag's ERR_REMOTE_ERR to an answer. libplctag folds EVERY non-zero PCCC STS and CIP status into that code, including a routing hop's "unconnected send timed out" / "connection lost" / "remote node missing", and native BadReply covers truncated / mismatched / unmapped replies - so a powered-off PLC behind a bridge read as reachable: Healthy, LastSuccessfulRead advancing, the comms-loss watchdog unable to fire. Three classes now, each pinned both ways: - no reply (Timeout/BadConnection/Abort/Pending/unknown): comms fault; - answered - an ALLOW-LIST again (NotFound/NotAllowed/OutOfBounds/TooLarge/ TooSmall/TypeMismatch/DeviceStateConflict): proves reachability; - OPAQUE (BadReply/Partial; ERR_REMOTE_ERR/ERR_BAD_STATUS now translate to the existing BadReply, OPC UA BadCommunicationError as before r0): per-tag Bad, handle kept, coalescing fallback, never fail-fast, never an answer. Option (a): a device whose pass/call produced only opaque outcomes is Degraded with LastSuccessfulRead not advanced, so the watchdog reaches a dead routed PLC. The probe and index reads classify the same way. Minors: - N2: a mismatched array (type vs data file) READS BadTypeMismatch instead of decoding the file at the wrong width as Good. - N3: a swept indirect resolution is never reconnected under a forgotten gate; that one call answers neutrally. - N4: an index read answered Unsupported is neutral, not a transport fault. - N5: ArrayFileMismatch checks indirect addresses (the file letter is static). - Test nit: the evicted-while-waiting test signals gate arrival via a seam instead of sleeping. Live: 51/51 on ab_server (slc500/micrologix/plc5 x both transports), incl. an opaque-only libplctag device staying Degraded with LastSuccessfulRead frozen. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyTagDefinitionFactory.cs | 4 +- .../AbLegacyDriver.cs | 167 ++++++++++++++---- .../LibplctagLegacyStatusTranslation.cs | 17 +- .../AbLegacyReadCoalescingIntegrationTests.cs | 41 ++++- .../AbLegacyAnsweredStatusTests.cs | 166 ++++++++++++++--- .../AbLegacyArrayWriteTests.cs | 15 ++ .../AbLegacyHealthEvidenceTests.cs | 96 +++++++++- .../AbLegacyHostAndStatusTests.cs | 3 - 8 files changed, 434 insertions(+), 75 deletions(-) diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs index ea87f079..c10832d7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs @@ -289,7 +289,9 @@ public static class AbLegacyTagDefinitionFactory public static string? ArrayFileMismatch(AbLegacyDataType type, string address) { if (!IsArrayElementTypeSupported(type)) return null; // structured arrays have their own refusal - if (AbLegacyAddress.TryParse(address) is not { } a || a.IsIndirect) return null; + // The file LETTER is static even for an indirect address (N7:[N7:0], N[N7:0]:5) — only the numbers move — + // so the width rule applies to it too (review 07 r1 N5). + if (AbLegacyAddress.TryParse(address) is not { } a) return null; var file = ArrayFileElementType(a); var ok = type switch { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 28b6fbe7..2444f3c1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -468,7 +468,11 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// evidence for the backoff); = nothing reached the wire or it says nothing about /// reachability (a local refusal, a bad index value). /// - private enum OpVerdict { Neutral, Answered, Fault, Throttled } + /// = a reply arrived that cannot be classified (libplctag ERR_REMOTE_ERR, native + /// BadReply/Partial): it may be the device refusing OR a routing hop reporting that the end device is + /// gone, so it is NOT an answer (no backoff reset, no LastSuccessfulRead) and NOT a fault (no fail-fast); + /// a pass that produced only opaque outcomes for a device degrades (review 07 r1 N1). + private enum OpVerdict { Neutral, Answered, Fault, Throttled, Opaque } /// The health evidence one device pass produced (collected, then written once per read). private struct DeviceReadOutcome @@ -476,6 +480,18 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag public bool AnyGood; public bool AnyAnswered; public string? FaultMessage; + public string? OpaqueMessage; + + /// + /// Folds an opaque-only device into the fault class for STATE (Degraded, LastSuccessfulRead not advanced + /// by it) — option (a) of review 07 r1 N1: a dead PLC behind a bridge answers every request with a fast remote + /// error, and only this lets the comms-loss watchdog (non-Healthy AND frozen LastSuccessfulRead) reach + /// it. Cost: a single-tag device whose only tag draws an opaque refusal is re-initialised every 90 s. + /// + public void FoldOpaque() + { + if (OpaqueMessage is not null && !AnyAnswered) FaultMessage ??= OpaqueMessage; + } } /// @@ -641,8 +657,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag case OpVerdict.Throttled: outcome.FaultMessage ??= message; break; + case OpVerdict.Opaque: + outcome.OpaqueMessage ??= message; + break; } } + outcome.FoldOpaque(); return outcome; } @@ -664,11 +684,22 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag results[item.Index] = new DataValueSnapshot(null, res.Status, null, now); return res.TransportFault ? (OpVerdict.Fault, $"transport fault resolving indirect address for {reference}") - : (OpVerdict.Neutral, null); + : res.Opaque + ? (OpVerdict.Opaque, $"unclassifiable reply resolving indirect address for {reference}") + : (OpVerdict.Neutral, null); } def = res.Def; } + // A mis-authored array (its type does not match its data file) would decode the file at the wrong width + // and serve garbage as Good (review 07 r1 N2): answered BadTypeMismatch, warned once, no health write. + if (IsArrayTag(def) && AbLegacyTagDefinitionFactory.ArrayFileMismatch(def.DataType, def.Address) is { } widthError) + { + WarnRefusalOnce(device, def, "read", widthError, AbLegacyStatusMapper.BadTypeMismatch); + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadTypeMismatch, null, now); + return (OpVerdict.Neutral, null); + } + var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false); int status; @@ -677,10 +708,17 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag // runtime — the server read path and the poll loop both call ReadAsync against // the same cached libplctag Tag handle, which is not concurrency-safe. var opLock = device.GetRuntimeLock(def.Name); + device.BeforeOperationGate?.Invoke(def.Name); await opLock.WaitAsync(ct).ConfigureAwait(false); try { - runtime = await CurrentRuntimeUnderLockAsync(device, def, runtime, ct).ConfigureAwait(false); + if (await CurrentRuntimeUnderLockAsync(device, def, runtime, ct).ConfigureAwait(false) is not { } live) + { + // A swept indirect resolution (the index moved under us): never resurrect it under a forgotten gate. + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); + return (OpVerdict.Neutral, null); + } + runtime = live; await runtime.ReadAsync(ct).ConfigureAwait(false); status = runtime.GetStatus(); var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); @@ -713,6 +751,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag WarnRefusalOnce(device, def, "read", "status Unsupported", AbLegacyStatusMapper.BadNotSupported); return (OpVerdict.Neutral, null); } + if (IsOpaque((AbStatus)status)) + { + // A reply we cannot classify: per-tag Bad, keep the handle, warn once — neither an answer nor a fault. + WarnRefusalOnce(device, def, "read", $"unclassifiable reply {(AbStatus)status}", results[item.Index].StatusCode); + return (OpVerdict.Opaque, $"unclassifiable reply {(AbStatus)status} reading {reference}"); + } // Evict the stale handle so the next call re-creates it (mirrors AbCip). EvictRuntime(device, def.Name); if (!device.FirstNonZeroStatusLogged) @@ -752,6 +796,13 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now); return (OpVerdict.Answered, null); } + catch (AbTransportException ex) when (IsOpaque(ex.Status)) + { + // A truncated / mismatched / unmapped reply (native BadReply, Partial): not proof the device answered. + WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status)); + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now); + return (OpVerdict.Opaque, ex.Message); + } catch (NotSupportedException nse) { // A deterministic decode refusal (review 07 H16) — the same answer every poll. BadNotSupported, keep @@ -781,23 +832,34 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag !Enum.IsDefined(status) || status is AbStatus.Timeout or AbStatus.BadConnection or AbStatus.Abort or AbStatus.Pending; /// - /// True for a non-zero status that proves the DEVICE answered and refused this one operation — the file or - /// element does not exist, the access is not allowed, a size / type is wrong, a remote error (libplctag - /// ERR_REMOTE_ERR, translated to ), or a reply the transport - /// could not map (, ). Such a status is per-tag Bad, - /// keeps the handle, and is evidence of reachability. is classified - /// separately (: it says nothing about reachability). + /// True for a status that PROVES the device answered and refused this one operation — an ALLOW-LIST (review 07 + /// r1 N1): the file or element does not exist, the access is not allowed, it is out of bounds, a size or type is + /// wrong, or the processor is in a conflicting state. Per-tag Bad, keeps the handle, and is evidence of + /// reachability (backoff reset, LastSuccessfulRead, Healthy). /// /// - /// ⚠️ Depends on the transport's PCCC STS table (AbStatusCodes.FromPccc, Lane K): today it maps STS 0x30 - /// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to answered values, so on the - /// NATIVE transport such a dead node reads as answered and never trips the backoff or the comms-loss watchdog. - /// The remap is follow-up F8 (review 07 r0 I4); this classification needs no change once it lands. + /// ⚠️ Inherits the native transport's PCCC STS table (AbStatusCodes.FromPccc, Lane K): it maps STS 0x30 + /// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to NotAllowed/NotFound, + /// so on the NATIVE transport such a dead node still reads as answered. Follow-up F8 (review 07 r0 I4); this + /// classification needs no change once it lands. /// /// The transport status. /// for a device refusal. - internal static bool IsDeviceRefusal(AbStatus status) => - status is not (AbStatus.Ok or AbStatus.Unsupported) && !IsNoReply(status); + internal static bool IsDeviceRefusal(AbStatus status) => status is + AbStatus.NotFound or AbStatus.NotAllowed or AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall + or AbStatus.TypeMismatch or AbStatus.DeviceStateConflict; + + /// + /// True for a reply that arrived but cannot be classified (review 07 r1 N1): — + /// libplctag's opaque ERR_REMOTE_ERR/ERR_BAD_STATUS, which folds EVERY non-zero CIP general status + /// and PCCC STS together (including a routing hop's "unconnected send timed out", "connection lost" and "remote + /// node missing"), and the native transport's unmapped / truncated / mismatched reply — and + /// . Opaque statuses are per-tag Bad and trigger the coalescing fallback, but are + /// neither an answer nor a comms fault; see for the health rule. + /// + /// The transport status. + /// for an unclassifiable reply. + internal static bool IsOpaque(AbStatus status) => status is AbStatus.BadReply or AbStatus.Partial; // ---- IWritable ---- @@ -866,6 +928,8 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag device.RecordCommsFault(Clock()); outcome.FaultMessage ??= $"transport fault resolving indirect address for {w.FullReference}"; } + else if (res.Opaque) + outcome.OpaqueMessage ??= $"unclassifiable reply resolving indirect address for {w.FullReference}"; continue; } def = res.Def; @@ -943,7 +1007,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag await opLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - runtime = await CurrentRuntimeUnderLockAsync(device, def, runtime, cancellationToken).ConfigureAwait(false); + if (await CurrentRuntimeUnderLockAsync(device, def, runtime, cancellationToken).ConfigureAwait(false) is not { } live) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); + continue; // the index moved under us (N3): not written, no health evidence + } + runtime = live; if (arrayValue is not null) runtime.EncodeArray(arrayFileType, arrayValue); else runtime.EncodeValue(def.DataType, parsed.BitIndex, w.Value); await runtime.WriteAsync(cancellationToken).ConfigureAwait(false); @@ -973,6 +1042,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag WarnRefusalOnce(device, def, "write", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status)); results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(ex.Status)); } + catch (AbTransportException ex) when (IsOpaque(ex.Status)) + { + outcome.OpaqueMessage ??= ex.Message; + WarnRefusalOnce(device, def, "write", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status)); + results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(ex.Status)); + } catch (NotSupportedException nse) { // Deterministic (review 07 H16): nothing reached the wire, so it says nothing about the device. @@ -1004,6 +1079,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag } } + outcome.FoldOpaque(); ApplyHealth(epoch, outcome.FaultMessage, anyGood: outcome.AnyAnswered, anyAnswered: outcome.AnyAnswered, DateTime.UtcNow); return results; } @@ -1028,6 +1104,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag device.RecordCommsFault(Clock()); outcome.FaultMessage ??= $"transport status {status} writing {def.Name}"; } + else if (IsOpaque(s)) + { + // Neither an answer nor a fault (review 07 r1 N1). + WarnRefusalOnce(device, def, "write", $"unclassifiable reply {s}", AbLegacyStatusMapper.MapStatus(status)); + outcome.OpaqueMessage ??= $"unclassifiable reply {s} writing {def.Name}"; + } } // ---- ITagDiscovery ---- @@ -1120,7 +1202,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag // The probe's verdict feeds the data path's backoff (review 07 P7): an answer resets the window so a // recovered device is read again at once; a comms fault counts toward marking it down. if (success || IsDeviceRefusal((AbStatus)status)) state.RecordAnswered(); - else if ((AbStatus)status != AbStatus.Unsupported) state.RecordCommsFault(Clock()); + else if (IsNoReply((AbStatus)status)) state.RecordCommsFault(Clock()); // opaque / Unsupported: neutral } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) @@ -1129,7 +1211,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag probeRuntime = null; state.ProbeInitialized = false; if (ex is AbTransportException ate && IsDeviceRefusal(ate.Status)) state.RecordAnswered(); - else if (!IsLocalRefusal(ex)) state.RecordCommsFault(Clock()); + else if (ex is not AbTransportException { Status: var st } || IsNoReply(st)) state.RecordCommsFault(Clock()); } TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); @@ -1320,7 +1402,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag if (parsed.IndirectFileSource is { } fileSrc) { var r = await ReadIndexAsync(device, fileSrc, ct).ConfigureAwait(false); - if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault); + if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault, r.Opaque); if (r.Value is < 0 or > MaxIndirectFile) return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false); fileNumber = (int)r.Value!; @@ -1328,7 +1410,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag if (parsed.IndirectWordSource is { } wordSrc) { var r = await ReadIndexAsync(device, wordSrc, ct).ConfigureAwait(false); - if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault); + if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault, r.Opaque); if (r.Value < 0 || r.Value > maxElement) return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false); word = (int)r.Value!; @@ -1369,13 +1451,13 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag } /// Reads one direct index address as an integer through the shared parent-runtime cache. - private async Task<(long? Value, uint Status, bool TransportFault)> ReadIndexAsync( + private async Task<(long? Value, uint Status, bool TransportFault, bool Opaque)> ReadIndexAsync( DeviceState device, AbLegacyAddress inner, CancellationToken ct) { AbLegacyDataType type; if (inner.BitIndex is not null) type = AbLegacyDataType.Bit; else if (inner.FileLetter == "L") type = AbLegacyDataType.Long; - else if (inner.FileLetter is "F" or "ST") return (null, AbLegacyStatusMapper.BadOutOfRange, false); + else if (inner.FileLetter is "F" or "ST") return (null, AbLegacyStatusMapper.BadOutOfRange, false, false); else type = AbLegacyDataType.Int; // A bit-addressed index reads its parent word and decodes the bit, like the RMW path does. @@ -1394,40 +1476,49 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag catch (ConnectionThrottledException) { throw; } // the caller answers it as a fail-fast catch (AbTransportException ex) when (IsLocalRefusal(ex)) { - return (null, AbLegacyStatusMapper.BadNotSupported, false); + return (null, AbLegacyStatusMapper.BadNotSupported, false, false); } catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status)) { // The device answered: the index file/element does not exist. Per-tag, not a comms fault. - return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false); + return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false, false); + } + catch (AbTransportException ex) when (IsOpaque(ex.Status)) + { + return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false, true); } catch { EvictParentRuntime(device, runtimeName); - return (null, AbLegacyStatusMapper.BadCommunicationError, true); + return (null, AbLegacyStatusMapper.BadCommunicationError, true, false); } var status = runtime.GetStatus(); if (status != 0 && IsDeviceRefusal((AbStatus)status)) - return (null, AbLegacyStatusMapper.MapStatus(status), false); - if (status != 0) + return (null, AbLegacyStatusMapper.MapStatus(status), false, false); + if (status != 0 && IsOpaque((AbStatus)status)) + return (null, AbLegacyStatusMapper.MapStatus(status), false, true); + // Only a NO-REPLY status is a transport fault (review 07 r1 N4: Unsupported used to fall in here). + if (status != 0 && IsNoReply((AbStatus)status)) { EvictParentRuntime(device, runtimeName); - return (null, AbLegacyStatusMapper.MapStatus(status), true); + return (null, AbLegacyStatusMapper.MapStatus(status), true, false); } + if (status != 0) + return (null, AbLegacyStatusMapper.MapStatus(status), false, false); var raw = runtime.DecodeValue(type, inner.BitIndex); try { return raw switch { - bool b => (b ? 1L : 0L, 0u, false), - null => (null, AbLegacyStatusMapper.BadOutOfRange, false), - _ => (Convert.ToInt64(raw), 0u, false), + bool b => (b ? 1L : 0L, 0u, false, false), + null => (null, AbLegacyStatusMapper.BadOutOfRange, false, false), + _ => (Convert.ToInt64(raw), 0u, false, false), }; } catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { - return (null, AbLegacyStatusMapper.BadOutOfRange, false); + return (null, AbLegacyStatusMapper.BadOutOfRange, false, false); } } finally @@ -1444,7 +1535,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag } } - private sealed record IndirectResolution(AbLegacyTagDefinition? Def, uint Status, bool TransportFault); + private sealed record IndirectResolution(AbLegacyTagDefinition? Def, uint Status, bool TransportFault, bool Opaque = false); private async Task EnsureParentRuntimeAsync( AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct) @@ -1611,11 +1702,15 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// the wait may since have been evicted and disposed (a failed sibling call, or an indirect index sweep), and /// using it would fail spuriously as a comms fault. Returns the live cached handle, or connects a fresh one. /// - private async Task CurrentRuntimeUnderLockAsync( + private async Task CurrentRuntimeUnderLockAsync( DeviceState device, AbLegacyTagDefinition def, IAbLegacyTagRuntime fetched, CancellationToken ct) { if (device.Runtimes.TryGetValue(def.Name, out var live)) return live; + // A resolved INDIRECT key that is gone was swept because the index moved (review 07 r1 N3). Reconnecting it + // would resurrect a stale resolution under a gate the sweep already forgot (a later caller would get a fresh + // gate for the same handle). Null = the caller answers this one call neutrally; the next poll re-resolves. + if (def.Name.Contains("=>", StringComparison.Ordinal)) return null; // Evicted between the fetch and the lock: a native runtime refused at init is cached too, so absence means // it really was released. Connect again (single-flight per device). // A retired (torn-down) state never reconnects: the in-flight call finishes on what it has. @@ -1886,6 +1981,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// The number of per-key gates currently held (runtime + RMW). Tests only. internal int GateCount => _runtimeLocks.Count + _rmwLocks.Count; + /// Test seam: invoked with the runtime key just before an operation waits on its gate (after the + /// handle was fetched), so a test can act at exactly that point instead of sleeping. + internal Action? BeforeOperationGate { get; set; } + private volatile bool _retired; /// True once this state has been unpublished and released; nothing may be cached on it any more. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs index 281c4ebd..45855904 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs @@ -23,14 +23,15 @@ internal static class LibplctagLegacyStatusTranslation Status.ErrorPartial => AbStatus.Partial, Status.ErrorAbort => AbStatus.Abort, Status.ErrorBadReply => AbStatus.BadReply, - // The PLC (or a CIP hop) ANSWERED with an error: libplctag's PCCC layer turns a non-zero STS — a file or - // element that does not exist, a write the processor refuses — into PLCTAG_ERR_REMOTE_ERR (and a non-zero - // CIP general status into ERR_REMOTE_ERR / ERR_BAD_STATUS). AbStatus has no dedicated "remote error" value and - // Driver.Ab.Transport is not this driver's to extend (Lane K), so this maps to the existing value whose meaning - // is closest — "the device is reachable but refused the operation" — DeviceStateConflict (OPC UA - // BadDeviceFailure). It MUST NOT fall into the BadConnection default: that is a no-reply comms fault, which - // evicts handles, degrades health and counts toward marking the device down (review 07 r0 I1). - Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.DeviceStateConflict, + // A reply ARRIVED but carries an error libplctag does not break out: it folds EVERY non-zero PCCC STS and CIP + // general status into PLCTAG_ERR_REMOTE_ERR / ERR_BAD_STATUS. That covers the PLC refusing (element past the + // end of the file) AND a routing hop reporting the end device is gone (CIP 0x01/0x0204 unconnected send timed + // out, 0x07 connection lost, PCCC STS 0x30 remote node missing). So it is neither a no-reply comms fault + // (review 07 r0 I1 — it must not fall into the BadConnection default, which black-outs coalesced neighbours and + // trips fail-fast) nor proof of reachability (review 07 r1 N1). AbStatus has no dedicated "opaque remote error" + // value and Driver.Ab.Transport is Lane K's, so this maps to the existing BadReply — "a reply we cannot + // classify" — which the driver treats as OPAQUE (AbLegacyDriver.IsOpaque). OPC UA: BadCommunicationError. + Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.BadReply, _ => AbStatus.BadConnection, }; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs index e3aa58d2..8542eb22 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs @@ -101,9 +101,46 @@ public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour A"); r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour B"); r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}: the past-the-end tag"); - r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.BadCommunicationError, - $"poll {poll}: the PLC answered — not a comms fault (status 0x{r[2].StatusCode:X8})"); } + // The refusal (libplctag: an opaque ERR_REMOTE_ERR; native: a mapped STS) is never a comms fault: the + // answering neighbours keep the driver Healthy and the device is never failed fast. drv.GetHealth().State.ShouldBe(DriverState.Healthy); } + + /// + /// Review 07 r1 N1, on the wire: libplctag reports the past-the-end refusal as the OPAQUE ERR_REMOTE_ERR — + /// the same code a bridge returns for a powered-off PLC behind it — so a device whose ONLY traffic is that + /// reply must not look reachable: Degraded, LastSuccessfulRead frozen (the comms-loss watchdog's + /// evidence). The native transport maps the STS itself and is only checked to stay non-Good. + /// + /// The simulator profile. + /// The transport stack the driver runs on. + [AbLegacyTheory] + [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))] + public async Task An_opaque_only_device_never_proves_reachability(AbLegacyServerProfile profile, AbLegacyTransport transport) + { + if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip); + var ct = TestContext.Current.CancellationToken; + var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}"; + await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(uri, profile.Family)], + RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("PastEnd", uri, "N7:12", AbLegacyDataType.Int)), + Timeout = TimeSpan.FromSeconds(5), + Probe = new AbLegacyProbeOptions { Enabled = false }, + Transport = transport, + }, driverInstanceId: $"ablegacy-opaque-{profile.Family}-{transport}"); + await drv.InitializeAsync("{}", ct); + var initLastRead = drv.GetHealth().LastSuccessfulRead; + await Task.Delay(20, ct); + + for (var poll = 0; poll < 2; poll++) + (await drv.ReadAsync(["PastEnd"], ct))[0].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good); + + if (transport == AbLegacyTransport.Libplctag) + { + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + drv.GetHealth().LastSuccessfulRead.ShouldBe(initLastRead); + } + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs index 8cbaba2a..1b209131 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs @@ -7,18 +7,24 @@ using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// -/// Review 07 r0 I1: on the default libplctag transport a PLC's own refusal (a non-zero PCCC STS — e.g. an element -/// past the end of the file) arrives as ERR_REMOTE_ERR, which used to translate to BadConnection: a -/// no-reply comms fault. A coalesced block holding one such member then failed as a group every poll, and two -/// refused tags in a row tripped the device's fail-fast window. Statuses the device RETURNED are now answers; -/// only no-reply statuses are comms faults. +/// Pins BOTH sides of the reply-classification line (review 07 r0 I1 + r1 N1). +/// +/// No reply (Timeout / BadConnection / Abort / Pending / unknown) — comms fault: evict, Degraded, +/// counts toward fail-fast. +/// Answered (an allow-list: NotFound / NotAllowed / OutOfBounds / TooLarge / TooSmall / TypeMismatch / +/// DeviceStateConflict) — proves reachability: per-tag Bad, Healthy, LastSuccessfulRead advances. +/// Opaque (BadReply / Partial — incl. libplctag's ERR_REMOTE_ERR, which folds a PLC refusal +/// and a routing hop's "end device gone" together) — neither: per-tag Bad, never fail-fast, never blacks out +/// coalesced neighbours, but NEVER advances LastSuccessfulRead; an opaque-only device degrades so the +/// comms-loss watchdog can reach a dead PLC behind a bridge. +/// /// [Trait("Category", "Unit")] public sealed class AbLegacyAnsweredStatusTests { private const string Host = "ab://10.0.0.5/1,0"; - /// What the libplctag runtime's GetStatus() reports for a PLC refusal (through the real translation). + /// What the libplctag runtime's GetStatus() reports for ERR_REMOTE_ERR (through the real translation). private static readonly int RemoteErr = (int)LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorRemoteErr); private static AbLegacyDriver NewDriver(FakeAbLegacyTagFactory factory, bool coalesce, params AbLegacyTagDefinition[] tags) => @@ -30,37 +36,75 @@ public sealed class AbLegacyAnsweredStatusTests CoalesceReads = coalesce, }, "drv-i1", factory); - [Fact] - public void Remote_error_translates_to_an_answered_status_not_a_comms_fault() + // ---- the classification table ---- + + [Theory] + [InlineData(AbStatus.NotFound)] + [InlineData(AbStatus.NotAllowed)] + [InlineData(AbStatus.OutOfBounds)] + [InlineData(AbStatus.TooLarge)] + [InlineData(AbStatus.TooSmall)] + [InlineData(AbStatus.TypeMismatch)] + [InlineData(AbStatus.DeviceStateConflict)] + public void The_answered_class_is_an_allow_list(AbStatus status) { - var s = LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorRemoteErr); - AbLegacyDriver.IsNoReply(s).ShouldBeFalse(); - AbLegacyDriver.IsDeviceRefusal(s).ShouldBeTrue(); - AbLegacyDriver.IsDeviceRefusal(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorBadStatus)).ShouldBeTrue(); - // Positive control: real no-reply statuses still are. - AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorTimeout)).ShouldBeTrue(); - AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorBadConnection)).ShouldBeTrue(); + AbLegacyDriver.IsDeviceRefusal(status).ShouldBeTrue(); + AbLegacyDriver.IsOpaque(status).ShouldBeFalse(); + AbLegacyDriver.IsNoReply(status).ShouldBeFalse(); } [Theory] - [InlineData(AbStatus.BadReply)] // native: an STS the transport does not map + [InlineData(AbStatus.BadReply)] [InlineData(AbStatus.Partial)] - [InlineData(AbStatus.NotFound)] - [InlineData(AbStatus.DeviceStateConflict)] - public void Statuses_the_device_returned_are_answers(AbStatus status) => - AbLegacyDriver.IsDeviceRefusal(status).ShouldBeTrue(); + public void Unclassifiable_replies_are_opaque_not_answers(AbStatus status) + { + AbLegacyDriver.IsOpaque(status).ShouldBeTrue(); + AbLegacyDriver.IsDeviceRefusal(status).ShouldBeFalse(); + AbLegacyDriver.IsNoReply(status).ShouldBeFalse(); + } [Theory] [InlineData(AbStatus.Timeout)] [InlineData(AbStatus.BadConnection)] [InlineData(AbStatus.Abort)] + [InlineData(AbStatus.Pending)] [InlineData((AbStatus)(-99))] // a code this build does not know: conservatively no reply public void Only_no_reply_statuses_are_comms_faults(AbStatus status) { AbLegacyDriver.IsNoReply(status).ShouldBeTrue(); AbLegacyDriver.IsDeviceRefusal(status).ShouldBeFalse(); + AbLegacyDriver.IsOpaque(status).ShouldBeFalse(); } + [Fact] + public void Every_non_Ok_status_lands_in_at_most_one_class_and_Unsupported_in_none() + { + foreach (var s in Enum.GetValues().Where(s => s != AbStatus.Ok)) + { + var classes = new[] { AbLegacyDriver.IsNoReply(s), AbLegacyDriver.IsDeviceRefusal(s), AbLegacyDriver.IsOpaque(s) } + .Count(c => c); + if (s == AbStatus.Unsupported) classes.ShouldBe(0, "Unsupported is neutral"); + else classes.ShouldBe(1, s.ToString()); + } + } + + [Fact] + public void libplctag_remote_errors_translate_to_opaque_not_to_an_answer_or_a_fault() + { + foreach (var st in new[] { Status.ErrorRemoteErr, Status.ErrorBadStatus }) + { + var s = LibplctagLegacyStatusTranslation.ToAbStatus(st); + AbLegacyDriver.IsOpaque(s).ShouldBeTrue(st.ToString()); + AbLegacyDriver.IsDeviceRefusal(s).ShouldBeFalse(st.ToString()); + AbLegacyDriver.IsNoReply(s).ShouldBeFalse(st.ToString()); + } + // Positive controls on the other two sides. + AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorTimeout)).ShouldBeTrue(); + AbLegacyDriver.IsDeviceRefusal(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorNotFound)).ShouldBeTrue(); + } + + // ---- behaviour: the I1 side (an opaque member never blacks out its neighbours, never trips fail-fast) ---- + [Fact] public async Task A_remote_error_member_never_blacks_out_its_coalesced_neighbours() { @@ -82,15 +126,15 @@ public sealed class AbLegacyAnsweredStatusTests var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None); r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}"); r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}"); - r[2].StatusCode.ShouldBe(AbLegacyStatusMapper.BadDeviceFailure, $"poll {poll}"); + r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}"); } - drv.GetHealth().State.ShouldBe(DriverState.Healthy); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); // the neighbours answered drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse(); await drv.ShutdownAsync(CancellationToken.None); } [Fact] - public async Task Consecutive_refused_tags_do_not_trip_fail_fast_on_an_answering_device() + public async Task Consecutive_opaque_replies_do_not_trip_fail_fast_on_an_answering_device() { var factory = new FakeAbLegacyTagFactory { @@ -106,10 +150,82 @@ public sealed class AbLegacyAnsweredStatusTests for (var poll = 0; poll < 3; poll++) { var r = await drv.ReadAsync(["R1", "R2", "R3", "Good"], CancellationToken.None); - r[3].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: an answering device is never failed fast"); + r[3].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: never failed fast"); } factory.Tags["N7:9"].ReadCount.ShouldBe(3); - factory.Tags["N7:0"].InitializeCount.ShouldBe(1); // refused handles are kept, not churned + factory.Tags["N7:0"].InitializeCount.ShouldBe(1); // opaque handles are kept, not churned + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + await drv.ShutdownAsync(CancellationToken.None); + } + + // ---- behaviour: the N1 side (an opaque reply never proves reachability) ---- + + [Fact] + public async Task Remote_error_only_reads_never_advance_LastSuccessfulRead_and_degrade() + { + // A PLC behind a bridge is powered off: every request comes back fast with ERR_REMOTE_ERR. + var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } }; + var drv = NewDriver(factory, coalesce: false, + new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int), + new AbLegacyTagDefinition("Y", Host, "N7:5", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + (await drv.ReadAsync(["X", "Y"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); + var healthyAt = drv.GetHealth().LastSuccessfulRead; // positive control: Good reads DID advance it + healthyAt.ShouldNotBeNull(); + foreach (var t in factory.Tags.Values) t.Status = RemoteErr; + await Task.Delay(5); + + for (var poll = 0; poll < 3; poll++) + (await drv.ReadAsync(["X", "Y"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError); + + var h = drv.GetHealth(); + h.LastSuccessfulRead.ShouldBe(healthyAt); // frozen: the comms-loss watchdog can reach this device + h.State.ShouldBe(DriverState.Degraded); + drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse(); // but no fail-fast window + factory.Tags["N7:0"].ReadCount.ShouldBe(4); // every poll still asked + await drv.ShutdownAsync(CancellationToken.None); + } + + private sealed class ThrowingTag(AbLegacyTagCreateParams p, AbStatus status) : FakeAbLegacyTag(p) + { + public override Task ReadAsync(CancellationToken ct) => throw new AbTransportException(status, $"native {status}"); + } + + [Theory] + [InlineData(AbStatus.BadReply)] + [InlineData(AbStatus.Partial)] + public async Task A_native_unclassifiable_reply_is_not_counted_as_an_answer(AbStatus status) + { + var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } }; + var drv = NewDriver(factory, coalesce: false, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + var healthyAt = drv.GetHealth().LastSuccessfulRead; + drv.GetDeviceState(Host)!.Runtimes.TryRemove("X", out _); + factory.Customise = p => new ThrowingTag(p, status); + await Task.Delay(5); + + var r = await drv.ReadAsync(["X"], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(status)); + drv.GetHealth().LastSuccessfulRead.ShouldBe(healthyAt); + drv.GetHealth().State.ShouldBe(DriverState.Degraded); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Positive_control_a_real_device_refusal_alone_still_proves_reachability() + { + var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound } }; + var drv = NewDriver(factory, coalesce: false, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + var before = drv.GetHealth().LastSuccessfulRead; + await Task.Delay(5); + + (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown); + + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value); await drv.ShutdownAsync(CancellationToken.None); } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs index 20d9f203..f9a23fe3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs @@ -123,6 +123,21 @@ public sealed class AbLegacyArrayWriteTests .ShouldContain(w => w.Contains("element width")); // and the deploy gate names it } + /// Review 07 r1 N5: the file letter of an indirect address is static, so the deploy gate checks it too. + [Theory] + [InlineData("N7:[N7:0]", "Float", true)] + [InlineData("F[N7:0]:2", "Int", true)] + [InlineData("N7:[N7:0]", "Int", false)] + [InlineData("F[N7:0]:2", "Float", false)] + public void An_indirect_array_type_file_mismatch_is_reported_at_the_gate(string address, string dataType, bool mismatch) + { + var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}"""; + Enum.TryParse(dataType, out var type).ShouldBeTrue(); + + (AbLegacyTagDefinitionFactory.ArrayFileMismatch(type, address) is not null).ShouldBe(mismatch); + AbLegacyTagDefinitionFactory.Inspect(json).Any(w => w.Contains("element width")).ShouldBe(mismatch); + } + [Fact] public async Task Positive_control_a_Float_array_on_an_F_file_is_written() { diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs index c65004f0..e5e15fcf 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs @@ -243,8 +243,12 @@ public sealed class AbLegacyHealthEvidenceTests var gate = state.GetRuntimeLock("X"); await gate.WaitAsync(); - var read = drv.ReadAsync(["X"], CancellationToken.None); // fetches `first`, then parks on the gate - await Task.Delay(30); + // Signalled at exactly "handle fetched, about to wait on the gate" — no sleep, so the eviction below always + // lands between the reader's fetch and its use (review 07 r1 test nit: the sleep could pass vacuously). + var atGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + state.BeforeOperationGate = key => { if (key == "X") atGate.TrySetResult(); }; + var read = drv.ReadAsync(["X"], CancellationToken.None); + await atGate.Task.WaitAsync(TimeSpan.FromSeconds(5)); state.Runtimes.TryRemove("X", out var evicted).ShouldBeTrue(); evicted!.Dispose(); gate.Release(); @@ -255,4 +259,92 @@ public sealed class AbLegacyHealthEvidenceTests drv.GetHealth().State.ShouldBe(DriverState.Healthy); await drv.ShutdownAsync(CancellationToken.None); } + + /// + /// Review 07 r1 N3: a reader parked on a resolved INDIRECT key's gate while the index sweep evicted that key and + /// forgot its gate used to RECONNECT it under the lock — resurrecting a stale resolution under a gate nobody + /// else would get. It now answers that one call neutrally and leaves the key gone. + /// + [Fact] + public async Task A_swept_indirect_resolution_is_never_resurrected_under_a_forgotten_gate() + { + var creates = new System.Collections.Concurrent.ConcurrentQueue(); + var f = new SwitchableFactory + { + Make = p => + { + creates.Enqueue(p.TagName); + return new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? (short)5 : (short)42 }; + }, + }; + var drv = NewDriver(f, new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + (await drv.ReadAsync(["Ind"], CancellationToken.None))[0].Value.ShouldBe(42); // positive control + var state = drv.GetDeviceState(Host)!; + const string key = "Ind=>N7:5"; + state.Runtimes.ContainsKey(key).ShouldBeTrue(); + + var gate = state.GetRuntimeLock(key); + await gate.WaitAsync(); + var atGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + state.BeforeOperationGate = k => { if (k == key) atGate.TrySetResult(); }; + var read = drv.ReadAsync(["Ind"], CancellationToken.None); + await atGate.Task.WaitAsync(TimeSpan.FromSeconds(5)); + // What the sweep does when the index moves: evict the resolution, then forget its gate. + state.Runtimes.TryRemove(key, out var swept).ShouldBeTrue(); + swept!.Dispose(); + state.ForgetGates(key); + gate.Release(); + var r = await read; + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError); + state.Runtimes.ContainsKey(key).ShouldBeFalse(); // not resurrected + creates.Count(n => n == "N7:5").ShouldBe(1); // no reconnect of the stale resolution + drv.GetHealth().State.ShouldBe(DriverState.Healthy); // neutral: no health evidence either way + await drv.ShutdownAsync(CancellationToken.None); + } + + /// Review 07 r1 N4: an index read answered Unsupported is neutral, not a transport fault. + [Fact] + public async Task An_index_read_answered_Unsupported_is_neutral() + { + var f = new SwitchableFactory + { + Make = p => p.TagName == "N7:0" + ? new FakeAbLegacyTag(p) { Status = (int)AbStatus.Unsupported } + : new FakeAbLegacyTag(p) { Value = 1 }, + }; + var drv = NewDriver(f, + new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int), + new AbLegacyTagDefinition("Plain", Host, "N9:0", AbLegacyDataType.Int)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync(["Ind", "Plain"], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported); + r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + drv.GetDeviceState(Host)!.ParentRuntimes.ContainsKey("N7:0").ShouldBeTrue(); // handle kept, not evicted + await drv.ShutdownAsync(CancellationToken.None); + } + + /// Review 07 r1 N2: a persisted mismatched array READS BadTypeMismatch instead of garbage as Good. + [Fact] + public async Task A_mismatched_array_reads_BadTypeMismatch_not_garbage() + { + var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 1f, 2f } } }; + var drv = NewDriver(f, + new AbLegacyTagDefinition("Bad", Host, "F8:0", AbLegacyDataType.Int, ArrayLength: 2), + new AbLegacyTagDefinition("Good", Host, "F9:0", AbLegacyDataType.Float, ArrayLength: 2)); + await drv.InitializeAsync("{}", CancellationToken.None); + + var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None); + + r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch); + f.Tags.ContainsKey("F8:0").ShouldBeFalse(); // nothing read at the wrong width + r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control + r[1].Value.ShouldBe(new[] { 1f, 2f }); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + await drv.ShutdownAsync(CancellationToken.None); + } } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs index 79cd5d80..8338dc3b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs @@ -89,9 +89,6 @@ public sealed class AbLegacyHostAndStatusTests Status.ErrorNotAllowed => AbLegacyStatusMapper.BadNotWritable, Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbLegacyStatusMapper.BadOutOfRange, Status.ErrorUnsupported or Status.ErrorNotImplemented => AbLegacyStatusMapper.BadNotSupported, - // DELIBERATE change (review 07 r0 I1): a PLC's own refusal (PCCC STS / CIP status → ERR_REMOTE_ERR / - // ERR_BAD_STATUS) is "device answered", not a comms error. - Status.ErrorRemoteErr or Status.ErrorBadStatus => AbLegacyStatusMapper.BadDeviceFailure, _ => AbLegacyStatusMapper.BadCommunicationError, }; From ff4e210a278d5f5d1a9118fe80a7a12c356466da Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:46:29 -0400 Subject: [PATCH 66/67] fix(opcua): replay kept scripted conditions after a rebuild; fit window values onto re-shaped nodes (Lane B fix round 3) N3 (critical): the node manager carries only NATIVE conditions' state over a full rebuild; scripted conditions come back fresh and rely on the engine's re-assert, which since the off-thread plan is handled in the window and lands on the discarded instance. On a rebuild the condition replay scope is now AddedConditions plus every enabled scripted condition in the new composition (native kept conditions stay carried-over, not replayed). The test sink again models the real rebuild: it clears non-native condition state only. N4: a surgical in-place shape edit (DataType / IsArray / ArrayLength) resets the raw node and its UNS mirror to BadWaitingForInitialData without ensuring them, so the window's value was not replayed and the two NodeIds disagreed. The applier records re-shaped nodes (MaterialisationRecording.Reshaped); their deferred value replays only once fitted to the new shape (VariableShape.TryFit: as is, or converted invariantly; never an old-typed value - WriteValues assigns the value verbatim with no type check), otherwise it is dropped at Debug and the node keeps waiting. Status-only entries are not replayed onto a re-shaped node. Minors: m1/m2 documented on _replayScope (degraded-path only); m3 corrected the false carry-over premise in the scope doc (the ScriptedAlarmHostActor ordering remark is outside this lane - F8); m4 the replay Debug log reports alarm-state and alarm-quality counts again. Refs #732 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AddressSpaceApplier.cs | 145 ++++++++++-- .../OpcUa/OpcUaPublishActor.cs | 73 +++++- .../VariableShapeTests.cs | 64 +++++ .../OpcUaPublishActorDeferredWriteTests.cs | 221 ++++++++++++++++-- 4 files changed, 457 insertions(+), 46 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs index bb0f2752..961319f5 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs @@ -219,6 +219,9 @@ public sealed class AddressSpaceApplier ok = false; } if (!ok) { allApplied = false; break; } + RecordReshape(AddressSpaceRealm.Raw, d.Current.NodeId, + d.Previous.DataType, d.Previous.IsArray, d.Previous.ArrayLength, + d.Current.DataType, d.Current.IsArray, d.Current.ArrayLength); } // v3 UNS reference variables — TWO shapes, split on whether the delta moves the node's // IDENTITY (2026-08-15 review I2). @@ -270,6 +273,9 @@ public sealed class AddressSpaceApplier updated = false; } if (!updated) { allApplied = false; break; } + RecordReshape(AddressSpaceRealm.Uns, d.Current.NodeId, + d.Previous.DataType, d.Previous.BackingIsArray, d.Previous.BackingArrayLength, + d.Current.DataType, d.Current.BackingIsArray, d.Current.BackingArrayLength); continue; } @@ -1156,30 +1162,49 @@ public sealed class AddressSpaceApplier try { _sink.EnsureVariable(nodeId, parentNodeId, displayName, dataType, writable, realm, historianTagname, isArray, arrayLength, refuseArrayOnScalar); - _ensuredVariables?.Add((realm, nodeId)); + _recording?.Ensured.Add((realm, nodeId)); return true; } catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; } } - /// While non-null, every variable this applier ensures (realm, NodeId). See . - private HashSet<(AddressSpaceRealm, string)>? _ensuredVariables; - - /// - /// Starts recording every variable the following materialise passes ensure, until - /// . The publish actor scopes the replay of writes it deferred while a - /// plan was in flight to these (a surgical apply ensures only its delta), so a variable the apply kept is not - /// rewritten. Single-caller (the publish actor's thread), like the rest of the apply. - /// - /// The live set the recording fills. - public IReadOnlySet<(AddressSpaceRealm, string)> BeginRecordingEnsuredVariables() + /// Records an in-place UpdateTagAttributes that changed the node's SHAPE (DataType / array-ness / + /// array length): the node manager drops such a node's value to BadWaitingForInitialData. + private void RecordReshape( + AddressSpaceRealm realm, string nodeId, + string previousDataType, bool previousIsArray, uint? previousArrayLength, + string dataType, bool isArray, uint? arrayLength) { - _ensuredVariables = new HashSet<(AddressSpaceRealm, string)>(); - return _ensuredVariables; + if (_recording is null) return; + if (string.Equals(previousDataType, dataType, StringComparison.Ordinal) + && previousIsArray == isArray + && (!isArray || previousArrayLength == arrayLength)) + { + return; + } + + _recording.Reshaped[(realm, nodeId)] = new VariableShape(dataType, isArray, arrayLength); } - /// Stops the recording started (the returned set is kept). - public void EndRecordingEnsuredVariables() => _ensuredVariables = null; + /// The recording in progress, or null. See . + private MaterialisationRecording? _recording; + + /// + /// Starts recording what the following apply and materialise passes do to VARIABLES, until + /// : every variable ensured, and every variable an in-place attribute update + /// re-shaped (which resets its value). The publish actor scopes the replay of writes it deferred while a plan + /// was in flight to these — a variable the apply kept took the live write already, and rewriting it would + /// revert a client write made in the window. Single-caller (the publish actor's thread), like the apply. + /// + /// The live recording. + public MaterialisationRecording BeginRecording() + { + _recording = new MaterialisationRecording(); + return _recording; + } + + /// Stops the recording started (the returned recording is kept). + public void EndRecording() => _recording = null; // The tag/vtag surgical-eligibility predicates moved to AddressSpaceChangeClassifier so the // whole delta classification is one pure, table-testable unit; the applier references them there (and the @@ -1293,3 +1318,91 @@ public sealed record AddressSpaceApplyOutcome( bool RebuildCalled, bool RebuildFailed = false, int FailedNodes = 0); + +/// What one apply did to variables (see ). +public sealed class MaterialisationRecording +{ + /// Every variable an EnsureVariable was issued for, as (realm, NodeId). + public HashSet<(AddressSpaceRealm, string)> Ensured { get; } = new(); + + /// Every variable an in-place attribute update RE-SHAPED (its value reset), with its new shape. + public Dictionary<(AddressSpaceRealm, string), VariableShape> Reshaped { get; } = new(); +} + +/// A variable's value shape: its Tag.DataType name, and its array-ness / 1-D length. +/// The Tag.DataType name ("Int32", "Float", …; unknown ⇒ BaseDataType, any value fits). +/// Whether the node is a one-dimensional array. +/// The fixed array length, when known (null / 0 ⇒ any length). +public sealed record VariableShape(string DataType, bool IsArray, uint? ArrayLength) +{ + /// + /// Fits to this shape for a write onto a re-shaped node: unchanged when it already + /// fits, converted (invariant culture) when a lossless-enough conversion exists, otherwise refused — an + /// old-typed value must never be served under the new DataType (the reset exists to prevent exactly that). + /// A null value always fits (a status annotation carries none). + /// + /// The value to fit. + /// The fitted value, when this returns true. + /// True when the value fits (as is, or converted). + public bool TryFit(object? value, out object? fitted) + { + fitted = null; + if (value is null) return true; + var element = ElementType(DataType); + if (!IsArray) + { + if (value is Array) return false; + return TryConvert(value, element, out fitted); + } + + if (value is not Array array || value is string) return false; + if (ArrayLength is > 0 && array.Length != ArrayLength) return false; + if (element is null) { fitted = value; return true; } + var result = Array.CreateInstance(element, array.Length); + for (var i = 0; i < array.Length; i++) + { + if (!TryConvert(array.GetValue(i), element, out var item) || item is null) return false; + result.SetValue(item, i); + } + + fitted = result; + return true; + } + + private static bool TryConvert(object? value, Type? target, out object? converted) + { + converted = value; + if (value is null || target is null || target.IsInstanceOfType(value)) return true; + if (value is not IConvertible) return false; + try + { + converted = Convert.ChangeType(value, target, System.Globalization.CultureInfo.InvariantCulture); + return true; + } + catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException) + { + converted = null; + return false; + } + } + + /// The CLR type the node manager serves for a Tag.DataType name (its ResolveBuiltInDataType set); + /// null ⇒ BaseDataType, where any value fits. + private static Type? ElementType(string dataType) => dataType switch + { + "Boolean" => typeof(bool), + "SByte" => typeof(sbyte), + "Byte" => typeof(byte), + "Int16" => typeof(short), + "UInt16" => typeof(ushort), + "Int32" => typeof(int), + "UInt32" => typeof(uint), + "Int64" => typeof(long), + "UInt64" => typeof(ulong), + "Float" => typeof(float), + "Double" => typeof(double), + "String" => typeof(string), + "DateTime" => typeof(DateTime), + _ => null, + }; +} diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs index 94f2f24d..a1667540 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs @@ -187,8 +187,21 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers /// /// Variables: after a FULL rebuild every one (each was recreated, its live write landed on the /// discarded node); otherwise exactly those the apply asked the sink to ensure (the applier records them). - /// Conditions: those in the applied composition but not the previous one — on a rebuild too, because the - /// node manager carries a kept condition's state over to its re-materialised instance. + /// Variables an in-place attribute update RE-SHAPED (review N4): the node manager reset their value to + /// BadWaitingForInitialData, so the window's value is replayed — but only once fitted to the new shape + /// (); a value that does not fit is dropped and the node left waiting. + /// The raw node and its UNS mirror are re-shaped by the same edit, so both get the same treatment. + /// Conditions: those in the applied composition but not the previous one; after a full rebuild ALSO + /// every kept SCRIPTED condition (review N3) — the node manager carries only NATIVE conditions' state over + /// a rebuild, so a scripted one is recreated fresh and needs the window's state. A kept native condition + /// is restored from the carry-over (which already holds the live write and any SDK ack), so it is not + /// replayed. Accepted cost on the scripted path: an SDK ack on the discarded instance in the window is + /// lost with it; the engine's own ack emission (a scripted ack routes to the engine) restores it. + /// Known gaps (degraded path only, review m1 / m2): a degraded non-rebuild apply does not advance + /// _lastApplied, so the next apply counts its conditions as added again and may replay onto them; + /// and the empty-plan repair of a degraded composition scopes no condition (the composition is + /// unchanged), so a condition the repair newly places gets no window state — writes to it between the + /// two applies were already dropped before this replay existed. /// /// (Precise "did the live write land" would need the sink to report it; the production sink chain runs through /// Commons' DeferredAddressSpaceSink, outside this lane — follow-up F7.) @@ -198,10 +211,12 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private sealed record ReplayScope( bool AllVariables, IReadOnlySet<(AddressSpaceRealm, string)> Variables, + IReadOnlyDictionary<(AddressSpaceRealm, string), VariableShape> Reshaped, IReadOnlySet<(AddressSpaceRealm, string)> Conditions) { private static readonly IReadOnlySet<(AddressSpaceRealm, string)> None = new HashSet<(AddressSpaceRealm, string)>(); - public static readonly ReplayScope Nothing = new(false, None, None); + public static readonly ReplayScope Nothing = + new(false, None, new Dictionary<(AddressSpaceRealm, string), VariableShape>(), None); } /// True while a rebuild's parse / plan step is running off-thread. Rebuilds are applied strictly in @@ -851,8 +866,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private void ReplayDeferredWrites(ReplayScope scope) { if (_deferred.IsEmpty) return; - var (values, statuses) = _deferred.VariableWrites( - key => scope.AllVariables || scope.Variables.Contains(key)); + var (allValues, allStatuses) = _deferred.VariableWrites( + key => scope.AllVariables || scope.Variables.Contains(key) || scope.Reshaped.ContainsKey(key)); + var values = new List(allValues.Count); + var statuses = new List(allStatuses.Count); + var unfit = 0; + foreach (var v in allValues) + { + if (!scope.Reshaped.TryGetValue((v.Realm, v.NodeId), out var shape)) { values.Add(v); continue; } + if (shape.TryFit(v.Value, out var fitted)) { values.Add(v with { Value = fitted }); continue; } + unfit++; + _log.Debug( + "OpcUaPublish: a value deferred for re-shaped {Realm} node {Node} does not fit its new shape ({DataType}, array={IsArray}) — not replayed; the node waits for the driver's next value", + v.Realm, v.NodeId, shape.DataType, shape.IsArray); + } + + foreach (var st in allStatuses) + { + // A status-only annotation onto a re-shaped (value-less) node would serve a null value under it; the + // node's honest state is BadWaitingForInitialData until a value of the new shape arrives. + if (scope.Reshaped.ContainsKey((st.Realm, st.NodeId))) { unfit++; continue; } + statuses.Add(st); + } if (values.Count > 0) { try { _sink.WriteValues(values); } @@ -865,22 +900,27 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying {Count} deferred status writes threw", statuses.Count); } } + var alarms = 0; foreach (var alarm in _deferred.Alarms) { if (!scope.Conditions.Contains((alarm.Realm, alarm.AlarmNodeId))) continue; + alarms++; try { _sink.WriteAlarmCondition(alarm.AlarmNodeId, alarm.State, alarm.TimestampUtc, alarm.Realm); } catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm write threw for {Node}", alarm.AlarmNodeId); } } + var qualities = 0; foreach (var quality in _deferred.AlarmQualities) { if (!scope.Conditions.Contains((quality.Realm, quality.AlarmNodeId))) continue; + qualities++; try { _sink.WriteAlarmQuality(quality.AlarmNodeId, quality.StatusCode, quality.TimestampUtc, quality.Realm); } catch (Exception ex) { _log.Warning(ex, "OpcUaPublish: replaying a deferred alarm-quality write threw for {Node}", quality.AlarmNodeId); } } - _log.Debug("OpcUaPublish: replayed {Values} value and {Statuses} status writes (of {Held} held) deferred during a rebuild plan", - values.Count, statuses.Count, _deferred.Count); + _log.Debug( + "OpcUaPublish: replayed {Values} value, {Statuses} status, {Alarms} alarm-state and {Qualities} alarm-quality writes (of {Held} held; {Unfit} not fitting a re-shaped node) deferred during a rebuild plan", + values.Count, statuses.Count, alarms, qualities, _deferred.Count, unfit); } /// @@ -1019,17 +1059,28 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers private (bool Degraded, string? Reason) ApplyPlan(PlanReady ready) { var previous = _lastApplied; - var ensured = _applier!.BeginRecordingEnsuredVariables(); + var recording = _applier!.BeginRecording(); try { var result = ApplyPlanCore(ready, out var rebuilt); - // What this apply created (see _replayScope). A throwing pass still scopes to what it did create. - _replayScope = new ReplayScope(rebuilt, ensured, AddedConditions(previous, ready.Composition)); + // What this apply created or reset (see _replayScope). A throwing pass still scopes to what it did. + var conditions = AddedConditions(previous, ready.Composition); + if (rebuilt) + { + // Review N3: a full rebuild recreates every SCRIPTED condition fresh — the node manager carries only + // NATIVE conditions' state over (OtOpcUaNodeManager.RebuildAddressSpace). The engine's re-assert + // (or any transition) handled in the window landed on the discarded instance, so every kept scripted + // condition must be replayed onto its new one. Native kept conditions stay "added only". + foreach (var a in ready.Composition.EquipmentScriptedAlarms) + if (a.Enabled) conditions.Add((AddressSpaceRealm.Uns, a.ScriptedAlarmId)); + } + + _replayScope = new ReplayScope(rebuilt, recording.Ensured, recording.Reshaped, conditions); return result; } finally { - _applier.EndRecordingEnsuredVariables(); + _applier.EndRecording(); } } diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs new file mode 100644 index 00000000..52e6daff --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/VariableShapeTests.cs @@ -0,0 +1,64 @@ +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// +/// Lane B review N4 — decides whether a value deferred during a plan window may +/// be replayed onto a node an in-place edit re-shaped: never an old-typed value the new DataType contradicts. +/// +public sealed class VariableShapeTests +{ + [Fact] + public void A_value_of_the_new_type_fits_unchanged() + { + new VariableShape("Int32", false, null).TryFit(7, out var fitted).ShouldBeTrue(); + fitted.ShouldBe(7); + } + + [Fact] + public void A_convertible_old_typed_value_is_converted_to_the_new_type() + { + new VariableShape("Int32", false, null).TryFit(5.0f, out var fitted).ShouldBeTrue(); + fitted.ShouldBeOfType().ShouldBe(5); + new VariableShape("Double", false, null).TryFit((short)3, out var d).ShouldBeTrue(); + d.ShouldBeOfType().ShouldBe(3d); + } + + [Theory] + [InlineData("Int32", "fast")] // not a number + [InlineData("Byte", 300)] // overflow + [InlineData("DateTime", 12)] // no conversion + public void A_value_the_new_type_cannot_hold_does_not_fit(string dataType, object value) + { + new VariableShape(dataType, false, null).TryFit(value, out var fitted).ShouldBeFalse(); + fitted.ShouldBeNull(); + } + + [Fact] + public void Scalar_and_array_never_cross() + { + new VariableShape("Int32", false, null).TryFit(new[] { 1, 2 }, out _).ShouldBeFalse(); + new VariableShape("Int32", true, 2).TryFit(1, out _).ShouldBeFalse(); + new VariableShape("String", true, null).TryFit("ab", out _).ShouldBeFalse("a string is not an array of strings"); + } + + [Fact] + public void An_array_is_converted_element_wise_and_its_fixed_length_enforced() + { + new VariableShape("Int16", true, 3).TryFit(new[] { 1, 2, 3 }, out var fitted).ShouldBeTrue(); + fitted.ShouldBeOfType().ShouldBe(new short[] { 1, 2, 3 }); + new VariableShape("Int16", true, 4).TryFit(new[] { 1, 2, 3 }, out _).ShouldBeFalse("wrong length"); + new VariableShape("Int16", true, null).TryFit(new[] { 1, 2, 3 }, out _).ShouldBeTrue("no fixed length"); + new VariableShape("Byte", true, null).TryFit(new[] { 1, 999 }, out _).ShouldBeFalse("an element overflows"); + } + + [Fact] + public void Null_and_unknown_types_always_fit() + { + new VariableShape("Int32", false, null).TryFit(null, out var n).ShouldBeTrue(); + n.ShouldBeNull(); + new VariableShape("Structure", false, null).TryFit("anything", out var any).ShouldBeTrue(); + any.ShouldBe("anything"); + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs index 82bc6d8a..2ab304a8 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs @@ -277,13 +277,11 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase /// /// N2 — a condition the plan KEEPS took the live write already; replaying it would roll back an operator's - /// Acknowledge applied in the window (the SDK mutates the node directly) and fire spurious events. Driven over - /// both apply shapes: a surgical pure-add, and a full rebuild (which carries the condition's state over). + /// Acknowledge applied in the window (the SDK mutates the node directly) and fire spurious events. The surgical + /// apply shape; the rebuild shapes are the two tests below (scripted: replayed; native: carried over). /// - [Theory] - [InlineData(false)] - [InlineData(true)] - public void An_out_of_band_ack_on_a_kept_condition_is_not_reverted_by_the_replay(bool rebuild) + [Fact] + public void An_out_of_band_ack_on_a_kept_condition_is_not_reverted_by_the_replay() { var sink = new ExistenceSink(); using var gate = new ManualResetEventSlim(true); @@ -293,8 +291,8 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase sink.ConditionExists("al-1").ShouldBeTrue(); gate.Reset(); - // A surgical pure-add (new raw tags) or a full rebuild (an equipment rename) that keeps al-1. - actor.Tell(Rebuild(rebuild ? AlarmArtifact(equipmentName: "Eq-renamed") : AlarmArtifact(rawTags: 2))); + // A surgical pure-add (new raw tags) that keeps al-1. + actor.Tell(Rebuild(AlarmArtifact(rawTags: 2))); actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns)); AwaitAssert(() => sink.AlarmWritesTo("al-1").ShouldBe(1), PlanBudget); // the live write landed sink.AcknowledgeOutOfBand("al-1"); // an operator acks in the window @@ -302,7 +300,7 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase gate.Set(); ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); - sink.SawRebuild.ShouldBe(rebuild, "non-vacuity: the intended apply shape ran"); + sink.SawRebuild.ShouldBeFalse("non-vacuity: a surgical apply"); sink.AlarmStateOf("al-1")!.Acknowledged.ShouldBeTrue("the operator's ack must survive the apply"); sink.AlarmWritesTo("al-1").ShouldBe(1, "no replayed write (and so no extra Part 9 event) on a kept condition"); } @@ -422,6 +420,149 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase second.Degraded.ShouldBeTrue(); } + + /// + /// N3 (i) — a full rebuild recreates every KEPT SCRIPTED condition fresh (only native conditions are carried + /// over), so the engine's re-assert handled in the window — landing on the discarded instance — must be + /// replayed onto the new one, or the active alarm reads Normal until its next transition (#487). + /// + [Fact] + public void A_rebuild_replays_the_window_state_onto_every_kept_scripted_condition() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(AlarmArtifact())); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + gate.Reset(); + actor.Tell(Rebuild(AlarmArtifact(equipmentName: "Eq-renamed"))); // ChangedEquipment ⇒ full rebuild, al-1 kept + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate("al-1", ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Uns)); + AwaitAssert(() => sink.AlarmWritesTo("al-1").ShouldBe(1), PlanBudget); // landed live on the old instance + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.SawRebuild.ShouldBeTrue("non-vacuity: a full rebuild ran"); + sink.AlarmStateOf("al-1").ShouldNotBeNull("the re-assert must reach the recreated condition") + .Active.ShouldBeTrue(); + } + + /// + /// N3 (ii) — the native counterpart: a kept NATIVE condition is restored from the node manager's carry-over + /// (which already holds the live write and the operator's ack), so it is not replayed — the ack survives and no + /// extra write lands. + /// + [Fact] + public void A_rebuild_does_not_replay_onto_a_kept_native_condition() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(AlarmArtifact(nativeAlarm: true))); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + sink.ConditionExists(NativeAlarm).ShouldBeTrue(); + + gate.Reset(); + actor.Tell(Rebuild(AlarmArtifact(equipmentName: "Eq-renamed", nativeAlarm: true))); + actor.Tell(new OpcUaPublishActor.AlarmStateUpdate(NativeAlarm, ActiveUnacked(OpcUaStatus.Good), DateTime.UtcNow, AddressSpaceRealm.Raw)); + AwaitAssert(() => sink.AlarmWritesTo(NativeAlarm).ShouldBe(1), PlanBudget); + sink.AcknowledgeOutOfBand(NativeAlarm); + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.SawRebuild.ShouldBeTrue("non-vacuity: a full rebuild ran"); + sink.AlarmStateOf(NativeAlarm)!.Acknowledged.ShouldBeTrue("the carried-over ack survives"); + sink.AlarmWritesTo(NativeAlarm).ShouldBe(1, "no replayed write on a carried-over native condition"); + } + + /// + /// N4 — a surgical in-place DataType edit resets the raw node AND its UNS mirror (the node manager drops an + /// old-typed value), and neither is ensured by the apply. The window's value is replayed onto both, fitted to + /// the new type, so the two NodeIds of one value agree. + /// + [Fact] + public void A_surgical_shape_edit_replays_the_window_value_fitted_onto_the_raw_node_and_its_mirror() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(ShapeArtifact("Float"))); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + sink.Exists(RawSpeed).ShouldBeTrue(); + sink.Exists(UnsSpeed).ShouldBeTrue("non-vacuity: the mirror exists"); + + gate.Reset(); + actor.Tell(Rebuild(ShapeArtifact("Int32"))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate( + [ + new ValueWrite(RawSpeed, 5.0f, OpcUaStatus.Good, at, AddressSpaceRealm.Raw), + new ValueWrite(UnsSpeed, 5.0f, OpcUaStatus.Good, at, AddressSpaceRealm.Uns), + ])); + AwaitAssert(() => sink.ValueOf(RawSpeed)!.Value.Value.ShouldBe(5.0f), PlanBudget); + + gate.Set(); + ExpectMsg(PlanBudget).Degraded.ShouldBeFalse(); + + sink.SawRebuild.ShouldBeFalse("non-vacuity: a surgical in-place edit"); + sink.ReshapedNodes.ShouldContain(RawSpeed, "non-vacuity: the raw node was reset"); + sink.ReshapedNodes.ShouldContain(UnsSpeed, "non-vacuity: the mirror was reset"); + var raw = sink.ValueOf(RawSpeed).ShouldNotBeNull(); + raw.Value.ShouldBeOfType().ShouldBe(5); + sink.ValueOf(UnsSpeed).ShouldBe(raw, "the raw node and its UNS mirror agree"); + } + + /// N4 — a window value that cannot be fitted to the new shape is never written: both NodeIds stay + /// value-less (BadWaitingForInitialData) until the driver's next value. + [Fact] + public void A_surgical_shape_edit_never_replays_a_value_that_does_not_fit_the_new_shape() + { + var sink = new ExistenceSink(); + using var gate = new ManualResetEventSlim(true); + var actor = Spawn(sink, gate); + actor.Tell(Rebuild(ShapeArtifact("String"))); + ExpectMsg(PlanBudget); + + gate.Reset(); + actor.Tell(Rebuild(ShapeArtifact("Int32"))); + var at = DateTime.UtcNow; + actor.Tell(new OpcUaPublishActor.AttributeValueBatchUpdate( + [ + new ValueWrite(RawSpeed, "fast", OpcUaStatus.Good, at, AddressSpaceRealm.Raw), + new ValueWrite(UnsSpeed, "fast", OpcUaStatus.Good, at, AddressSpaceRealm.Uns), + ])); + AwaitAssert(() => sink.ValueOf(RawSpeed)!.Value.Value.ShouldBe("fast"), PlanBudget); + + gate.Set(); + ExpectMsg(PlanBudget); + + sink.ReshapedNodes.ShouldContain(RawSpeed, "non-vacuity: the raw node was reset"); + sink.ValueOf(RawSpeed).ShouldBeNull("\"fast\" is not an Int32 — the node keeps waiting"); + sink.ValueOf(UnsSpeed).ShouldBeNull(); + } + + private const string NativeAlarm = "Plant/Modbus/dev1/Hi"; + private const string RawSpeed = "Plant/Modbus/dev1/Speed"; + private const string UnsSpeed = "Area/Line/Eq/Speed"; + + /// One raw tag (Speed) of and one UNS reference to it under Area/Line/Eq. + private static byte[] ShapeArtifact(string dataType) => + JsonSerializer.SerializeToUtf8Bytes(new + { + UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } }, + UnsLines = new[] { new { UnsLineId = "line-1", UnsAreaId = "area-1", Name = "Line" } }, + Equipment = new[] { new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = "Eq", MachineCode = "EQ-1" } }, + RawFolders = new[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, + DriverInstances = new[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } }, + Devices = new[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, + TagGroups = Array.Empty(), + Tags = new[] { new { TagId = "t-speed", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Speed", DataType = dataType, AccessLevel = 0, TagConfig = "{}" } }, + UnsTagReferences = new[] { new { UnsTagReferenceId = "ref-1", EquipmentId = "eq-1", TagId = "t-speed", DisplayNameOverride = (string?)null } }, + ScriptedAlarms = Array.Empty(), + }); + private static AlarmConditionSnapshot ActiveUnacked(uint quality) => new(Active: true, Acknowledged: false, Confirmed: true, Enabled: true, AlarmShelvingKind.Unshelved, 500, "high", quality); @@ -454,7 +595,7 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase ScriptedAlarms = Array.Empty(), }); - private static byte[] AlarmArtifact(string equipmentName = "Eq", int rawTags = 0) => + private static byte[] AlarmArtifact(string equipmentName = "Eq", int rawTags = 0, bool nativeAlarm = false) => JsonSerializer.SerializeToUtf8Bytes(new { UnsAreas = new[] { new { UnsAreaId = "area-1", ClusterId = "c1", Name = "Area" } }, @@ -463,13 +604,13 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase { new { EquipmentId = "eq-1", DriverInstanceId = (string?)null, UnsLineId = "line-1", Name = equipmentName, MachineCode = "EQ-1" }, }, - RawFolders = rawTags == 0 + RawFolders = rawTags == 0 && !nativeAlarm ? Array.Empty() : new object[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } }, - DriverInstances = rawTags == 0 + DriverInstances = rawTags == 0 && !nativeAlarm ? Array.Empty() : new object[] { new { DriverInstanceId = "drv-1", DriverType = "Modbus", DriverConfig = "{}", Name = "Modbus", RawFolderId = "rf-1", ClusterId = "c1" } }, - Devices = rawTags == 0 + Devices = rawTags == 0 && !nativeAlarm ? Array.Empty() : new object[] { new { DeviceId = "dev-1", DriverInstanceId = "drv-1", Name = "dev1", DeviceConfig = "{}" } }, TagGroups = Array.Empty(), @@ -477,7 +618,16 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase { TagId = $"t-{i}", DeviceId = "dev-1", TagGroupId = (string?)null, Name = $"T{i}", DataType = "Float", AccessLevel = 0, TagConfig = "{}", - }).ToArray(), + }).Concat(nativeAlarm + ? new object[] + { + new + { + TagId = "t-hi", DeviceId = "dev-1", TagGroupId = (string?)null, Name = "Hi", DataType = "Boolean", + AccessLevel = 0, TagConfig = "{\"alarm\":{\"alarmType\":\"OffNormalAlarm\",\"severity\":700}}", + }, + } + : Array.Empty()).ToArray(), ScriptedAlarms = new[] { new @@ -615,9 +765,18 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase public void MaterialiseAlarmCondition(string alarmNodeId, string equipmentNodeId, string displayName, string alarmType, int severity, AddressSpaceRealm realm, bool isNative = false) { - lock (_gate) { _applyClosed = false; _conditions.Add(alarmNodeId); } + lock (_gate) + { + _applyClosed = false; + _conditions.Add(alarmNodeId); + if (isNative) _nativeConditions.Add(alarmNodeId); else _nativeConditions.Remove(alarmNodeId); + } } + private readonly HashSet _nativeConditions = new(); + private readonly Dictionary _shapes = new(); + public ConcurrentQueue ReshapedNodes { get; } = new(); + public void EnsureFolder(string folderNodeId, string? parentNodeId, string displayName, AddressSpaceRealm realm) { lock (_gate) _applyClosed = false; @@ -625,20 +784,28 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase public void EnsureVariable(string variableNodeId, string? parentFolderNodeId, string displayName, string dataType, bool writable, AddressSpaceRealm realm, string? historianTagname = null, bool isArray = false, uint? arrayLength = null, bool refuseArrayOnScalar = false) { - lock (_gate) { _applyClosed = false; _variables.Add(variableNodeId); } + lock (_gate) + { + _applyClosed = false; + if (_variables.Add(variableNodeId)) _shapes[variableNodeId] = (dataType, isArray, arrayLength); + } } public void RebuildAddressSpace() { lock (_gate) { - // Variables are recreated empty; a condition's state is captured and carried over to its - // re-materialised instance (the node manager's rebuild carry-over). + // OtOpcUaNodeManager.RebuildAddressSpace: variables are recreated empty; only NATIVE conditions' + // state is captured and carried over to the re-materialised instance — a scripted condition comes + // back fresh (its engine re-asserts it). _applyClosed = false; _sawRebuild = true; _variables.Clear(); + _shapes.Clear(); _conditions.Clear(); _values.Clear(); + foreach (var id in _alarmStates.Keys.Where(k => !_nativeConditions.Contains(k)).ToList()) _alarmStates.Remove(id); + foreach (var id in _alarmQualities.Keys.Where(k => !_nativeConditions.Contains(k)).ToList()) _alarmQualities.Remove(id); } } @@ -652,7 +819,23 @@ public sealed class OpcUaPublishActorDeferredWriteTests : RuntimeActorTestBase public void RaiseNodesAddedModelChange(string affectedNodeId, AddressSpaceRealm realm) { } public void WireAlarmNotifiers(string alarmNodeId, AddressSpaceRealm alarmRealm, IReadOnlyList notifierFolderNodeIds, AddressSpaceRealm notifierFolderRealm) { } public void AddReference(string sourceNodeId, AddressSpaceRealm sourceRealm, string targetNodeId, AddressSpaceRealm targetRealm, string referenceType = "Organizes") { } - public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) => true; + /// OtOpcUaNodeManager.UpdateTagAttributes: a SHAPE change (DataType / array-ness / length) drops the + /// node's value (BadWaitingForInitialData); any other attribute edit leaves it. + public bool UpdateTagAttributes(string variableNodeId, bool writable, string? historianTagname, string dataType, bool isArray, uint? arrayLength, AddressSpaceRealm realm) + { + lock (_gate) + { + if (!_variables.Contains(variableNodeId)) return false; + if (_shapes.GetValueOrDefault(variableNodeId) != (dataType, isArray, arrayLength)) + { + _shapes[variableNodeId] = (dataType, isArray, arrayLength); + _values.Remove(variableNodeId); + ReshapedNodes.Enqueue(variableNodeId); + } + + return true; + } + } public bool UpdateFolderDisplayName(string folderNodeId, string displayName, AddressSpaceRealm realm) => true; public bool RemoveVariableNode(string variableNodeId, AddressSpaceRealm realm) { lock (_gate) return _variables.Remove(variableNodeId); } public bool RemoveAlarmConditionNode(string alarmNodeId, AddressSpaceRealm realm) { lock (_gate) return _conditions.Remove(alarmNodeId); } From 081fa693af80ae7ae57d1b586f81dcc194beaf56 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:57:53 -0400 Subject: [PATCH 67/67] fix(s7): access-denied is an answer; review round-1 minors (R07:H4 follow-through) I-1: a PUT/GET-disabled CPU (every read denied) stayed Faulted with LastSuccessfulRead frozen, so the comms-loss watchdog reconnected and re-subscribed it every 90 s forever. Both access-denied arms now also record an answer: Faulted is kept (worst state wins), the stamp advances, and the sticky write fault still clears only on a real success. Pinned end to end in Runtime.Tests: the real S7Driver under the real DriverInstanceActor watchdog is not reconnected across 2x the threshold; the control (every read timing out) is. Minors: - m-1: injectable pass clock (UtcNow); "LastSuccessfulRead advanced" is now asserted strictly, including the write-refusal case. - m-2: the editor uses S7TagShapeRules.IsByteAnchored instead of its own list. - m-3: a release that races an init publish also cancels the probe loop the publish started after the release took its snapshot. - m-4: a retained poll failing during a lifecycle operation writes no health. - m-5: a multi-var chunk that fell back memoises its members refused alone. - m-7: access-denied needs S7.Net's exact wording and no transport fault in the chain. - m-8: sync teardown clears the write-side Degraded owner; a sync Dispose racing a running shutdown is tested. Refs #742 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- docs/drivers/S7.md | 8 +- .../ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs | 80 +++++++++-- .../Uns/TagEditors/S7TagConfigModel.cs | 5 +- .../S7_1500/S7_1500RefusalTests.cs | 4 +- .../S7LifecycleGateTests.cs | 63 ++++++++ .../S7RefusalHealthTests.cs | 134 +++++++++++++++--- .../S7SpanRefusalFallbackTests.cs | 52 +++++++ .../Drivers/S7AccessDeniedWatchdogTests.cs | 123 ++++++++++++++++ .../ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj | 3 + 9 files changed, 438 insertions(+), 34 deletions(-) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/S7AccessDeniedWatchdogTests.cs diff --git a/docs/drivers/S7.md b/docs/drivers/S7.md index 889d08be..39788f8c 100644 --- a/docs/drivers/S7.md +++ b/docs/drivers/S7.md @@ -314,7 +314,7 @@ Portal under *Protection & Security* for the CPU. | Tag not in config | `BadNodeIdUnknown` | unchanged | | Read-only tag written | `BadNotWritable` | unchanged | | Unimplemented data type | `BadNotSupported` | unchanged | -| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) | +| PUT/GET denied | `BadNotSupported` | `Faulted` (config alert) — also counts as *answered*, advances `LastSuccessfulRead` (no watchdog reconnect loop) | | CPU refusal of the item (object does not exist, address out of range, type inconsistent, hardware fault) | `BadDeviceFailure` | unchanged — counts as *answered*, advances `LastSuccessfulRead`; one Warning per tag | | Other device-layer fault (`PlcException` that is neither a refusal nor connection-fatal) | `BadDeviceFailure` | `Degraded` | | Socket / timeout | `BadCommunicationError` | `Degraded` | @@ -333,7 +333,11 @@ answers without a failure. **A refusal is an answer (R07:H4).** S7.Net's read paths raise the CPU's item error as a bare `Exception` ("Received error from PLC: Object does not exist." — live-probed on the snap7 fixture), its write paths as a `PlcException(WriteData)`; both are classified over the whole exception chain, and the -access-denied text inside either is the `Faulted` alert. A refusal is deterministic, so it neither +access-denied text inside either (S7.Net's exact "Accessing object not allowed", and never when the chain +also carries a transport fault) is the `Faulted` alert — which is ALSO an answer: it advances +`LastSuccessfulRead`, so a factory-default CPU with PUT/GET disabled stays `Faulted` instead of being +reconnected by the comms-loss watchdog every 90 s. A multi-var chunk that falls back per tag memoises the +members refused on their own, like a refused span. A refusal is deterministic, so it neither degrades health nor reopens the connection, and because it proves the CPU is reachable it advances `LastSuccessfulRead` and restores `Healthy` after an earlier comms fault. Before this, a driver whose every tag was refused (a wrong DB number) sat `Degraded` with a frozen timestamp and the comms-loss diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index 9f0e98ae..cbf94e6b 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs @@ -201,6 +201,12 @@ public sealed class S7Driver /// Clock the connect backoff is measured on. Internal so tests step it deterministically. internal Func BackoffClock { get; set; } = static () => DateTime.UtcNow; + /// + /// Clock the read/write passes and the init publish stamp timestamps and + /// LastSuccessfulRead with. Internal so a test can step it and assert a STRICT advance. + /// + internal Func UtcNow { get; set; } = static () => DateTime.UtcNow; + /// /// The sticky write-side config fault (access denied / PUT-GET disabled), or null. A read /// pass that finds everything readable must NOT erase it (reads can succeed while writes are @@ -364,7 +370,7 @@ public sealed class S7Driver _connectBackoff.RecordSuccess(); Volatile.Write(ref _negotiatedPduSize, Plc.MaxPduSize); _initialized = true; - TryWriteHealth(gen, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); + TryWriteHealth(gen, new DriverHealth(DriverState.Healthy, UtcNow(), null)); // Kick off the probe loop once the connection is up. Initial HostState stays Unknown // until the first probe tick succeeds — avoids broadcasting a premature Running @@ -509,9 +515,18 @@ public sealed class S7Driver if (ReferenceEquals(_probeCts, probeCts)) { probeCts?.Dispose(); - _probeCts = null; - _probeTask = null; } + else + { + // An initialize published (and started a probe loop) after this release took its + // snapshot — a teardown whose generation bump raced the publish block. Cancel that + // loop too; it exits on its next gate wait / delay. Not joined: it needs _gate, which + // we hold. Its CTS is left to the GC rather than disposed under a running loop. + try { _probeCts?.Cancel(); } catch { /* best-effort */ } + probeCts?.Dispose(); + } + _probeCts = null; + _probeTask = null; _initialized = false; _plcDead = false; try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ } @@ -693,7 +708,7 @@ public sealed class S7Driver // InvalidOperationException from the not-initialized check. ArgumentNullException.ThrowIfNull(fullReferences); RequireInitialized(); - var now = DateTime.UtcNow; + var now = UtcNow(); var results = new DataValueSnapshot[fullReferences.Count]; // Health is decided ONCE, at the end of the pass, from every outcome the pass observed — // never per item. See ReadPassOutcome. @@ -749,6 +764,8 @@ public sealed class S7Driver List? forcedPerTag = null; // Members of each span refused this pass, and which of them were then refused on their own. List>? refusedSpans = null; + // Members of each multi-var chunk that fell back per tag this pass (R07:P2's multi-var sibling). + List>? multiVarFallbacks = null; HashSet? refusedAlone = null; if (_options.SpanReadMaxGapBytes >= 0) @@ -820,7 +837,8 @@ public sealed class S7Driver if (_options.SpanReadMaxGapBytes >= 0) { perTag = await ReadLeftoversByMultiVarAsync( - plc, resolved, perTag, results, now, outcome, cancellationToken).ConfigureAwait(false); + plc, resolved, perTag, results, now, outcome, cancellationToken, + fallback => (multiVarFallbacks ??= []).Add(fallback)).ConfigureAwait(false); } if (forcedPerTag is not null) { @@ -856,13 +874,28 @@ public sealed class S7Driver } catch (Exception ex) { - if (refusedSpans is not null && IsRefusal(ex)) (refusedAlone ??= []).Add(i); + if ((refusedSpans is not null || multiVarFallbacks is not null) && IsRefusal(ex)) (refusedAlone ??= []).Add(i); results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome, [resolved[i]!.Name]), null, now); } } // Remember what must not be grouped again (R07:P2). Skipped when the handle died mid-pass: // the per-member reads then proved nothing about which member the CPU refuses. + // A multi-var chunk that fell back: memo only the members refused ON THEIR OWN — without one, + // the chunk's failure explains nothing about its members, and they keep batching. + if (multiVarFallbacks is not null && refusedAlone is not null && !_plcDead) + { + foreach (var members in multiVarFallbacks) + { + var bad = members.Where(refusedAlone.Contains).ToList(); + if (bad.Count == 0) continue; + foreach (var m in bad) _noGroup.Add(resolved[m]!.Name); + _logger.LogInformation( + "S7 CPU refused a multi-var read of {MemberCount} tag(s); reading {Tags} individually from now on. Driver={DriverInstanceId}", + members.Count, string.Join(", ", bad.Select(m => resolved[m]!.Name)), _driverInstanceId); + } + } + if (refusedSpans is not null && !_plcDead) { foreach (var members in refusedSpans) @@ -1092,6 +1125,8 @@ public sealed class S7Driver /// The batch timestamp, shared by every tag in the batch. /// The pass's outcome accumulator; every decoded item records into it. /// Cancellation token for the reads. + /// Receives the member indexes of each chunk that fell back per tag, so + /// the caller can memo the members then refused on their own (the multi-var sibling of R07:P2). /// The indexes still owed an individual read, ascending. private async Task> ReadLeftoversByMultiVarAsync( IS7Plc plc, @@ -1100,7 +1135,8 @@ public sealed class S7Driver DataValueSnapshot[] results, DateTime now, ReadPassOutcome outcome, - CancellationToken ct) + CancellationToken ct, + Action> onChunkFallback) { var stillPerTag = new List(); List<(int Index, S7TagDefinition Tag, S7ParsedAddress Addr)>? batchable = null; @@ -1180,6 +1216,7 @@ public sealed class S7Driver "S7 driver {DriverInstanceId}: multi-var read of {ItemCount} leftover tag(s) failed; " + "falling back to per-tag reads for that chunk", _driverInstanceId, chunk.Count); foreach (var (index, _, _) in chunk) stillPerTag.Add(index); + onChunkFallback([.. chunk.Select(c => c.Index)]); continue; } @@ -1429,6 +1466,11 @@ public sealed class S7Driver // BadNotSupported and flag the driver as a config alert (Faulted) rather than // Degraded — per driver-specs.md §5. Matched over the whole chain: S7.Net's READ // paths raise the CPU's item error as a bare Exception, not a PlcException. + // It is ALSO an answer from a reachable CPU: LastSuccessfulRead must advance, or a + // factory-default CPU (every read denied) trips the comms-loss watchdog into a + // reconnect + re-subscribe every 90 s that can never fix a PUT/GET setting. + // Faulted still wins the pass (worst state), so the config alert is kept. + outcome.RecordAnswered(); outcome.RecordFailure(DriverState.Faulted, "S7 access denied — enable PUT/GET communication in TIA Portal " + $"(Protection & Security) for this CPU. PLC reported: {ex.Message}"); @@ -2115,6 +2157,9 @@ public sealed class S7Driver // configuration fault as on read. BadNotSupported + // a config-alert health state, not a transient device failure. results[i] = new WriteResult(StatusBadNotSupported); + // An answer (see the read ladder): advances LastSuccessfulRead. RecordAnswered sets + // AnyAnswered only, so the sticky _writeFault still clears only on a real success. + outcome.RecordAnswered(); outcome.RecordFailure(DriverState.Faulted, "S7 access denied — enable PUT/GET communication in TIA Portal " + $"(Protection & Security) for this CPU. PLC reported: {ex.Message}"); @@ -2177,7 +2222,7 @@ public sealed class S7Driver else if (outcome.AnySuccess) _writeFault = null; // a write got through — the protection fault is gone - var answeredAt = outcome.AnyAnswered ? DateTime.UtcNow : (DateTime?)null; + var answeredAt = outcome.AnyAnswered ? UtcNow() : (DateTime?)null; if (worst == DriverState.Healthy) { // Clean batch (successes and/or refusals): the CPU answered, so LastSuccessfulRead @@ -2678,10 +2723,14 @@ public sealed class S7Driver internal static bool IsAccessDenied(Exception ex) { if (ex is not PlcException && !IsS7ItemError(ex)) return false; + // A transport fault wins over any wording in the chain: a socket error whose text happens to + // contain the phrase must reopen (Degraded), not latch a sticky config alert. + if (IsS7ConnectionFatal(ex) || HasCancellationInChain(ex)) return false; for (Exception? e = ex; e is not null; e = e.InnerException) { - if (e.Message.Contains("Accessing object not allowed", StringComparison.OrdinalIgnoreCase) - || e.Message.Contains("not allowed", StringComparison.OrdinalIgnoreCase)) + // S7.Net's exact wording for the S7 AccessingObjectNotAllowed status — not the loose "not + // allowed", which any unrelated exception text can contain. + if (e.Message.Contains("Accessing object not allowed", StringComparison.OrdinalIgnoreCase)) { return true; } @@ -2766,6 +2815,16 @@ public sealed class S7Driver /// The exception caught by the poll engine. private void HandlePollError(Exception ex) { + // A retained poll that ran into a teardown / re-initialize IN PROGRESS ("not initialized" while a + // lifecycle operation holds _lifecycleGate) belongs to no live generation: that operation owns + // health, and writing Degraded over its Initializing (or its final Unknown) would break the "old + // work never overwrites newer health" rule. A poll against a driver nobody is initializing (a + // subscribe before init, a failed init) still surfaces Degraded (Driver.S7-009). + if (!_initialized && _lifecycleGate.CurrentCount == 0) + { + _logger.LogDebug(ex, "S7 poll reader failed during a lifecycle operation; no health write. Driver={DriverInstanceId}", _driverInstanceId); + return; + } _logger.LogWarning(ex, "S7 poll reader failed. Driver={DriverInstanceId}", _driverInstanceId); TryUpdateHealth(CurrentGeneration, h => h.State == DriverState.Faulted ? null @@ -2957,6 +3016,7 @@ public sealed class S7Driver _initialized = false; _plcDead = false; _writeFault = null; + _writeOwnedError = null; _connectBackoff.RecordSuccess(); try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ } Plc = null; diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs index 723cd369..d02db4d9 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/S7TagConfigModel.cs @@ -120,10 +120,7 @@ public sealed class S7TagConfigModel if (TagConfigJson.GetBool(_bag, "isArray")) { var dt = DataType; - if (dt is S7DataType.Int64 or S7DataType.UInt64 or S7DataType.Float64 - or S7DataType.String or S7DataType.DateTime or S7DataType.WString or S7DataType.Char - or S7DataType.WChar or S7DataType.Dtl or S7DataType.Date or S7DataType.Tod - or S7DataType.Time or S7DataType.S5Time) + if (S7TagShapeRules.IsByteAnchored(dt)) { return $"S7 does not support {dt} arrays — use Bool (read-only), Byte, Int16, UInt16, Int32, " + "UInt32 or Float32 for an array tag, or a scalar tag."; diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500RefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500RefusalTests.cs index 709be4c2..79015983 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500RefusalTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500RefusalTests.cs @@ -34,6 +34,8 @@ public sealed class S7_1500RefusalTests(Snap7ServerFixture sim) await using var drv = new S7Driver(options, driverInstanceId: "s7-refusal-live"); await drv.InitializeAsync("{}", ct); var before = drv.GetHealth().LastSuccessfulRead!.Value; + // Let the wall clock move past the init stamp so "advanced" is asserted strictly (review m-1). + while (DateTime.UtcNow <= before) await Task.Yield(); // Read the refused tag ALONE first: an all-refused pass is exactly the case that used to // freeze LastSuccessfulRead and latch Degraded. @@ -42,7 +44,7 @@ public sealed class S7_1500RefusalTests(Snap7ServerFixture sim) refused[0].Value.ShouldBeNull(); var health = drv.GetHealth(); health.State.ShouldBe(DriverState.Healthy, "a CPU refusal is not a comms fault"); - health.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(before); + health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before, "the refusal is an answer: the stamp advances"); // Positive control: the same connection still serves the seeded neighbour. var good = await drv.ReadAsync([S7_1500Profile.ProbeTag], ct); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7LifecycleGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7LifecycleGateTests.cs index d856e307..1c99a61e 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7LifecycleGateTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7LifecycleGateTests.cs @@ -115,6 +115,69 @@ public sealed class S7LifecycleGateTests (await drv.ReadAsync(["W0"], ct))[0].StatusCode.ShouldBe(0u); } + /// + /// Review m-4: a retained poll that runs into a re-initialize in progress ("not initialized") + /// must not write Degraded over the new attempt's Initializing. Waits on the poll's own log line + /// (a counted signal), then asserts; the control is that a successful init then reports Healthy. + /// + [Fact] + public async Task A_retained_poll_does_not_overwrite_a_reinitialize_in_progress() + { + var ct = TestContext.Current.CancellationToken; + var factory = new ScriptedS7PlcFactory(); + var secondOpenEntered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseSecondOpen = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + factory.Open = async (index, token) => + { + if (index != 1) return; + secondOpenEntered.TrySetResult(); + await releaseSecondOpen.Task.WaitAsync(token); + }; + var log = new CapturingS7Logger(); + using var drv = new S7Driver(Options(), "s7-retained-poll", factory, log); + await drv.InitializeAsync("{}", ct); + await drv.SubscribeAsync(["W0"], TimeSpan.FromMilliseconds(100), ct); + + var reinit = drv.InitializeAsync("{}", ct); + await secondOpenEntered.Task.WaitAsync(Bound, ct); + + using (var cts = CancellationTokenSource.CreateLinkedTokenSource(ct)) + { + cts.CancelAfter(Bound); + while (log.Entries.Count(e => e.Message.Contains("during a lifecycle operation")) < 2) + await Task.Delay(20, cts.Token); // condition wait on the poll's counted log line + } + drv.GetHealth().State.ShouldBe(DriverState.Initializing, "a retained poll must not write over the new attempt"); + + releaseSecondOpen.SetResult(); + await reinit.WaitAsync(Bound, ct); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// + /// Review m-8: the SYNCHRONOUS Dispose() racing a still-running ShutdownAsync is safe too + /// — neither throws, the superseded init publishes nothing, and the driver ends torn down. + /// + [Fact] + public async Task Sync_dispose_while_a_shutdown_is_still_running_is_safe() + { + var ct = TestContext.Current.CancellationToken; + var f = new FirstOpenBlocks(); + var drv = new S7Driver(Options(), "s7-sync-dispose-race", f.Factory) { LifecycleGateTimeout = TimeSpan.FromSeconds(1) }; + + var init = drv.InitializeAsync("{}", ct); + await f.FirstOpenEntered.Task.WaitAsync(Bound, ct); + + var shutdown = drv.ShutdownAsync(ct); // waits on the gate the blocked init holds + Should.NotThrow(() => drv.Dispose()); + await shutdown.WaitAsync(Bound, ct); + + f.ReleaseFirstOpen.SetResult(); + await Should.ThrowAsync(() => init.WaitAsync(Bound, ct)); + f.Factory.Created.ShouldHaveSingleItem().Disposed.ShouldBeTrue(); + drv.GetHealth().State.ShouldBe(DriverState.Unknown); + } + /// /// Dispose racing a shutdown that is still running is safe: neither throws, and the driver ends /// torn down. The semaphores are no longer disposed, so the running shutdown cannot hit an diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RefusalHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RefusalHealthTests.cs index 06aa3277..8c7c5182 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RefusalHealthTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RefusalHealthTests.cs @@ -38,13 +38,24 @@ public sealed class S7RefusalHealthTests new S7TagDefinition("Good", "DB1.DBW0", S7DataType.UInt16)), }; - private static async Task<(S7Driver Driver, ScriptedS7PlcFactory Factory, CapturingS7Logger Log)> StartAsync() + /// A stepped pass clock, so "LastSuccessfulRead advanced" is asserted STRICTLY. + private sealed class PassClock + { + public DateTime Now { get; private set; } = new(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc); + + /// Moves the clock one second on and returns the new time. + public DateTime Step() => Now = Now.AddSeconds(1); + } + + private static async Task<(S7Driver Driver, ScriptedS7PlcFactory Factory, CapturingS7Logger Log, PassClock Clock)> StartAsync() { var factory = new ScriptedS7PlcFactory(); var log = new CapturingS7Logger(); - var drv = new S7Driver(Options(), "s7-refusal", factory, log); + var clock = new PassClock(); + var drv = new S7Driver(Options(), "s7-refusal", factory, log) { UtcNow = () => clock.Now }; await drv.InitializeAsync("{}", TestContext.Current.CancellationToken); - return (drv, factory, log); + drv.GetHealth().LastSuccessfulRead.ShouldBe(clock.Now, "init stamps the pass clock"); + return (drv, factory, log, clock); } /// A refused tag is Bad, health stays Healthy, LastSuccessfulRead advances, and it warns once. @@ -52,20 +63,19 @@ public sealed class S7RefusalHealthTests public async Task A_read_refusal_is_per_tag_bad_with_no_health_impact_and_one_warning() { var ct = TestContext.Current.CancellationToken; - var (drv, factory, log) = await StartAsync(); + var (drv, factory, log, clock) = await StartAsync(); using var _ = drv; factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult((ushort)7); - var before = drv.GetHealth().LastSuccessfulRead!.Value; for (var pass = 0; pass < 3; pass++) { + var at = clock.Step(); var r = await drv.ReadAsync(["Missing"], ct); r[0].StatusCode.ShouldBe(BadDeviceFailure); drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refusal is not a comms fault"); + drv.GetHealth().LastSuccessfulRead.ShouldBe(at, + "the CPU answered, so the all-refused pass advances LastSuccessfulRead to the pass time"); } - - drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(before, - "the CPU answered, so the all-refused pass still advances LastSuccessfulRead"); log.Warnings.Count(w => w.Contains("Missing")).ShouldBe(1, "one Warning per refused tag, not one per poll"); // Positive control: the good tag still reads, and nothing reopened the connection. @@ -82,21 +92,25 @@ public sealed class S7RefusalHealthTests public async Task A_refusal_after_a_comms_fault_restores_healthy() { var ct = TestContext.Current.CancellationToken; - var (drv, factory, _) = await StartAsync(); + var (drv, factory, _, clock) = await StartAsync(); using var __ = drv; - // Positive control: a genuine comms fault DOES degrade. + // Positive control: a genuine comms fault DOES degrade, and leaves LastSuccessfulRead frozen. + var initStamp = clock.Now; + clock.Step(); factory.Read = (_, _) => throw new TimeoutException("frozen peer"); await drv.ReadAsync(["Missing"], ct); var degraded = drv.GetHealth(); degraded.State.ShouldBe(DriverState.Degraded); + degraded.LastSuccessfulRead.ShouldBe(initStamp); + var at = clock.Step(); factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult((ushort)7); var r = await drv.ReadAsync(["Missing"], ct); r[0].StatusCode.ShouldBe(BadDeviceFailure); var after = drv.GetHealth(); after.State.ShouldBe(DriverState.Healthy); - after.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(degraded.LastSuccessfulRead!.Value); + after.LastSuccessfulRead.ShouldBe(at); } /// The ReadData-wrapped shape of a refusal is classified the same way. @@ -104,7 +118,7 @@ public sealed class S7RefusalHealthTests public async Task A_read_data_wrapped_refusal_is_also_a_refusal() { var ct = TestContext.Current.CancellationToken; - var (drv, factory, _) = await StartAsync(); + var (drv, factory, _, clock) = await StartAsync(); using var __ = drv; factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, ReadRefusal()); @@ -121,7 +135,7 @@ public sealed class S7RefusalHealthTests public async Task A_bare_access_denied_read_is_the_faulted_config_alert() { var ct = TestContext.Current.CancellationToken; - var (drv, factory, _) = await StartAsync(); + var (drv, factory, _, clock) = await StartAsync(); using var __ = drv; factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed."); @@ -130,12 +144,93 @@ public sealed class S7RefusalHealthTests drv.GetHealth().LastError!.ShouldContain("PUT/GET"); } + /// + /// Review I-1: an access-denied CPU (PUT/GET left at its factory default — EVERY read denied) is + /// reachable and answering, so each pass advances LastSuccessfulRead while staying Faulted. + /// Before, the pass stayed Faulted with the stamp frozen — the comms-loss watchdog's trip + /// condition, so the actor reconnected + re-subscribed every 90 s, forever, which can never fix + /// a PUT/GET setting. The control: a comms fault afterwards leaves the stamp frozen. + /// + [Fact] + public async Task An_all_access_denied_pass_stays_faulted_but_advances_last_successful_read() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory, _, clock) = await StartAsync(); + using var __ = drv; + factory.Read = (_, _) => throw new Exception("Received error from PLC: Accessing object not allowed."); + + DateTime last = default; + for (var pass = 0; pass < 2; pass++) + { + last = clock.Step(); + await drv.ReadAsync(["Good", "Missing"], ct); + var h = drv.GetHealth(); + h.State.ShouldBe(DriverState.Faulted, "the config alert is kept"); + h.LastSuccessfulRead.ShouldBe(last, "the CPU answered, so the stamp advances every pass"); + } + + // Positive control: a real comms loss does NOT advance it (and the sticky Faulted outranks it). + clock.Step(); + factory.Read = (_, _) => throw new TimeoutException("frozen peer"); + await drv.ReadAsync(["Good"], ct); + drv.GetHealth().LastSuccessfulRead.ShouldBe(last); + } + + /// The write-side access-denied is an answer too, and its sticky Faulted still clears only on a real success. + [Fact] + public async Task A_write_access_denied_advances_last_successful_read_and_stays_sticky() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory, _, clock) = await StartAsync(); + using var __ = drv; + factory.Write = (_, _, _) => throw new PlcException(ErrorCode.WriteData, + new Exception("Received error from PLC: Accessing object not allowed.")); + + var at = clock.Step(); + (await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct))[0].StatusCode.ShouldBe(0x803D0000u); + drv.GetHealth().State.ShouldBe(DriverState.Faulted); + drv.GetHealth().LastSuccessfulRead.ShouldBe(at); + + // A clean read leaves the write-side Faulted in place (sticky) … + clock.Step(); + await drv.ReadAsync(["Good"], ct); + drv.GetHealth().State.ShouldBe(DriverState.Faulted); + + // … and a real write success clears it. + factory.Write = (_, _, _) => Task.CompletedTask; + clock.Step(); + await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct); + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } + + /// + /// Review m-7: a transport fault whose text happens to contain "not allowed" reopens (Degraded), + /// and only S7.Net's exact "Accessing object not allowed" is the access-denied alert. + /// + [Fact] + public async Task Only_the_exact_access_denied_wording_without_a_transport_fault_is_access_denied() + { + var ct = TestContext.Current.CancellationToken; + var (drv, factory, _, clock) = await StartAsync(); + using var __ = drv; + + clock.Step(); + factory.Write = (_, _, _) => throw new PlcException(ErrorCode.WriteData, + new System.IO.IOException("Accessing object not allowed: socket closed by peer")); + (await drv.WriteAsync([new WriteRequest("Good", (ushort)1)], ct))[0].StatusCode + .ShouldNotBe(0x803D0000u, "not the access-denied BadNotSupported"); + drv.GetHealth().State.ShouldBe(DriverState.Degraded, "a transport fault is not a config alert"); + + S7Driver.IsAccessDenied(new PlcException(ErrorCode.WriteData, new Exception("operation not allowed"))).ShouldBeFalse(); + S7Driver.IsAccessDenied(new Exception("Received error from PLC: Accessing object not allowed.")).ShouldBeTrue(); + } + /// A ReadData wrapping a socket fault is still a comms fault (Degraded), not a refusal. [Fact] public async Task A_read_data_wrapping_a_socket_fault_still_degrades() { var ct = TestContext.Current.CancellationToken; - var (drv, factory, _) = await StartAsync(); + var (drv, factory, _, clock) = await StartAsync(); using var __ = drv; factory.Read = (_, _) => throw new PlcException(ErrorCode.ReadData, new System.IO.IOException("connection reset")); @@ -148,7 +243,7 @@ public sealed class S7RefusalHealthTests public async Task A_write_refusal_is_per_tag_bad_and_counts_as_answered() { var ct = TestContext.Current.CancellationToken; - var (drv, factory, log) = await StartAsync(); + var (drv, factory, log, clock) = await StartAsync(); using var _ = drv; factory.Write = (addr, _, _) => addr == "DB99.DBW0" ? throw WriteRefusal() : Task.CompletedTask; @@ -160,12 +255,17 @@ public sealed class S7RefusalHealthTests for (var i = 0; i < 2; i++) { + var at = clock.Step(); var w = await drv.WriteAsync([new WriteRequest("Missing", (ushort)1)], ct); w[0].StatusCode.ShouldBe(BadDeviceFailure); + var h = drv.GetHealth(); + h.LastSuccessfulRead.ShouldBe(at, "a write refusal is an answer: the stamp advances strictly"); + // The Degraded was written by the READ side (its own message), so it is the read pass's to clear. + h.State.ShouldBe(DriverState.Degraded); } var after = drv.GetHealth(); - after.LastSuccessfulRead!.Value.ShouldBeGreaterThanOrEqualTo(degraded.LastSuccessfulRead!.Value); + after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(degraded.LastSuccessfulRead!.Value); log.Warnings.Count(m => m.Contains("Missing")).ShouldBe(1); } @@ -177,7 +277,7 @@ public sealed class S7RefusalHealthTests public async Task A_good_write_clears_a_write_side_degraded() { var ct = TestContext.Current.CancellationToken; - var (drv, factory, _) = await StartAsync(); + var (drv, factory, _, clock) = await StartAsync(); using var _ = drv; factory.Write = (_, _, _) => throw new System.IO.IOException("connection reset"); diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanRefusalFallbackTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanRefusalFallbackTests.cs index ce6ff0ca..11b7358d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanRefusalFallbackTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanRefusalFallbackTests.cs @@ -139,6 +139,58 @@ public sealed class S7SpanRefusalFallbackTests drv.GetHealth().State.ShouldBe(DriverState.Degraded); } + /// + /// Review m-5, the multi-var sibling: a leftover chunk holding one refused scalar used to fall back + /// per tag on EVERY poll (1 + N requests). The member refused on its own is now memoised, so the + /// next pass batches the rest and reads only the bad one alone. + /// + [Fact] + public async Task A_refused_multi_var_chunk_memoises_only_the_member_refused_alone() + { + var ct = TestContext.Current.CancellationToken; + var factory = new ScriptedS7PlcFactory(); + var multiCalls = 0; + factory.ReadMulti = (items, _) => + { + Interlocked.Increment(ref multiCalls); + if (items.Any(i => i.DB == 3)) + // The live snap7 shape of a multi-var request with one refused item. + throw new global::S7.Net.PlcException(global::S7.Net.ErrorCode.ReadData, + new global::S7.Net.PlcException(global::S7.Net.ErrorCode.WrongNumberReceivedBytes)); + foreach (var i in items) i.Value = (ushort)5; + return Task.FromResult>(items); + }; + factory.Read = (addr, _) => addr.StartsWith("DB3.", StringComparison.Ordinal) + ? throw new Exception("Received error from PLC: Object does not exist.") + : Task.FromResult((ushort)5); + using var drv = new S7Driver(new S7DriverOptions + { + Host = "192.0.2.1", + Probe = new S7ProbeOptions { Enabled = false }, + RawTags = S7RawTags.Entries( + new S7TagDefinition("A", "DB1.DBW0", S7DataType.UInt16, Writable: false), + new S7TagDefinition("B", "DB2.DBW0", S7DataType.UInt16, Writable: false), + new S7TagDefinition("Bad", "DB3.DBW0", S7DataType.UInt16, Writable: false)), + }, "s7-mv-memo", factory); + await drv.InitializeAsync("{}", ct); + string[] refs = ["A", "B", "Bad"]; + + var before = factory.WireOps; + var r1 = await drv.ReadAsync(refs, ct); + r1[0].StatusCode.ShouldBe(0u); + r1[1].StatusCode.ShouldBe(0u); + r1[2].StatusCode.ShouldBe(BadDeviceFailure); + (factory.WireOps - before).ShouldBe(4, "pass 1: one refused multi-var, then one per-tag read each"); + + before = factory.WireOps; + var r2 = await drv.ReadAsync(refs, ct); + r2[0].StatusCode.ShouldBe(0u); + r2[1].StatusCode.ShouldBe(0u); + r2[2].StatusCode.ShouldBe(BadDeviceFailure); + (factory.WireOps - before).ShouldBe(2, "pass 2: A+B ride one multi-var, Bad reads alone"); + Volatile.Read(ref multiCalls).ShouldBe(2); + } + /// The memo is per generation: a re-initialize (e.g. a corrected DB) groups everything again. [Fact] public async Task Reinitialize_clears_the_do_not_group_memo() diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/S7AccessDeniedWatchdogTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/S7AccessDeniedWatchdogTests.cs new file mode 100644 index 00000000..b7ace6d4 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/S7AccessDeniedWatchdogTests.cs @@ -0,0 +1,123 @@ +using Akka.Actor; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.S7; +using ZB.MOM.WW.OtOpcUa.Runtime.Drivers; +using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness; +using S7DataItem = global::S7.Net.Types.DataItem; +using S7NetDataType = global::S7.Net.DataType; + +namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers; + +/// +/// Lane S7 review I-1, end to end: the REAL under the REAL +/// comms-loss watchdog. A CPU with PUT/GET disabled denies every +/// read but is reachable and answering; the driver reports Faulted (the config alert) and must +/// still advance LastSuccessfulRead, so the watchdog's "non-Healthy AND silent for the +/// threshold" conjunction is never met and the actor does NOT reconnect + re-subscribe. The +/// positive control is the same rig with a real comms loss, which DOES trip it. +/// +public sealed class S7AccessDeniedWatchdogTests : RuntimeActorTestBase +{ + private static readonly TimeSpan FastPoll = TimeSpan.FromMilliseconds(50); + + /// The watchdog threshold — an order of magnitude above the poll so the window is a decision, not a race. + private static readonly TimeSpan Threshold = TimeSpan.FromMilliseconds(1500); + + private sealed class ScriptedPlcFactory(Func readFault) : IS7PlcFactory + { + public IS7Plc Create(S7CpuType cpuType, string host, int port, short rack, short slot, TimeSpan timeout) + => new ScriptedPlc(readFault); + } + + private sealed class ScriptedPlc(Func readFault) : IS7Plc + { + public bool IsConnected { get; private set; } + public Task OpenAsync(CancellationToken cancellationToken) { IsConnected = true; return Task.CompletedTask; } + public void Close() => IsConnected = false; + public Task ReadAsync(string address, CancellationToken cancellationToken) => Task.FromException(readFault()); + public Task ReadBytesAsync(S7NetDataType area, int db, int startByteAdr, int count, CancellationToken cancellationToken) + => Task.FromException(readFault()); + public Task> ReadMultipleVarsAsync(List items, CancellationToken cancellationToken) + => Task.FromException>(readFault()); + public Task WriteAsync(string address, object value, CancellationToken cancellationToken) => Task.CompletedTask; + public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) => Task.CompletedTask; + public Task ReadStatusAsync(CancellationToken cancellationToken) => Task.CompletedTask; + public void Dispose() => IsConnected = false; + } + + private (S7Driver Driver, Akka.TestKit.TestProbe Parent) Connect(Func readFault) + { + var driver = new S7Driver(new S7DriverOptions + { + Host = "192.0.2.1", + Probe = new S7ProbeOptions { Enabled = false }, + SpanReadMaxGapBytes = -1, + RawTags = + [ + new RawTagEntry("W0", S7TagDefinitionFactory.ToTagConfig(new S7TagDefinition("W0", "DB1.DBW0", S7DataType.UInt16, Writable: false)), false), + new RawTagEntry("W2", S7TagDefinitionFactory.ToTagConfig(new S7TagDefinition("W2", "DB1.DBW2", S7DataType.UInt16, Writable: false)), false), + ], + }, "s7-watchdog", new ScriptedPlcFactory(readFault)); + + var parent = CreateTestProbe(); + var actor = parent.ChildActorOf(DriverInstanceActor.Props( + driver, + reconnectInterval: TimeSpan.FromSeconds(30), + healthPollInterval: FastPoll, + commsLossTimeout: Threshold)); + actor.Tell(new DriverInstanceActor.InitializeRequested("{}")); + parent.ExpectMsg(TimeSpan.FromSeconds(5)).Connected.ShouldBeTrue(); + return (driver, parent); + } + + /// Drives read passes (the poll the actor would schedule) until . + private static async Task<(int Passes, int FaultedPasses)> PollUntilAsync(S7Driver driver, DateTime until) + { + var passes = 0; + var faulted = 0; + while (DateTime.UtcNow < until) + { + try + { +#pragma warning disable OTOPCUA0001 // the watchdog's reading of driver health is under test, not resilience wrapping + await driver.ReadAsync(["W0", "W2"], CancellationToken.None); +#pragma warning restore OTOPCUA0001 + passes++; + if (driver.GetHealth().State == DriverState.Faulted) faulted++; + } + catch (InvalidOperationException) { /* the actor tore the driver down (control case) */ } + await Task.Delay(100); // pacing the simulated poll, not an assertion + } + return (passes, faulted); + } + + /// An all-access-denied CPU is left connected across more than twice the watchdog threshold. + [Fact] + public async Task An_access_denied_cpu_is_not_reconnected_by_the_watchdog() + { + var (driver, parent) = Connect(() => new Exception("Received error from PLC: Accessing object not allowed.")); + + var (passes, faulted) = await PollUntilAsync(driver, DateTime.UtcNow + Threshold + Threshold + TimeSpan.FromMilliseconds(500)); + + // Positive controls: the window really ran past the threshold with the driver Faulted throughout, + // so "no reconnect" below cannot pass merely because nothing happened. + passes.ShouldBeGreaterThanOrEqualTo(10); + faulted.ShouldBe(passes, "every pass reports the PUT/GET config alert"); + + parent.ExpectNoMsg(TimeSpan.FromMilliseconds(300)); + driver.GetHealth().State.ShouldBe(DriverState.Faulted); + } + + /// The control: a real comms loss (every read times out) on the same rig IS reconnected. + [Fact] + public async Task A_real_comms_loss_is_reconnected_by_the_watchdog() + { + var (driver, parent) = Connect(() => new TimeoutException("frozen peer")); + + var poll = PollUntilAsync(driver, DateTime.UtcNow + Threshold + Threshold + TimeSpan.FromMilliseconds(500)); + parent.ExpectMsg(PresenceBudget).Connected.ShouldBeFalse(); + await poll; + } +} diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj index 554d3e15..bc5bdb83 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/ZB.MOM.WW.OtOpcUa.Runtime.Tests.csproj @@ -39,6 +39,9 @@ re-statement end-to-end tests prove what reaches the host's /alerts rows and Part 9 conditions. --> + +