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/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
diff --git a/docs/ScriptEditor.md b/docs/ScriptEditor.md
index eb6985e2..e039653c 100644
--- a/docs/ScriptEditor.md
+++ b/docs/ScriptEditor.md
@@ -198,10 +198,32 @@ 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
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
@@ -243,7 +265,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/docs/drivers/S7.md b/docs/drivers/S7.md
index 02d27885..39788f8c 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
@@ -103,9 +106,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
@@ -277,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
@@ -302,8 +314,9 @@ 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) |
-| CPU / hardware fault | `BadDeviceFailure` | `Degraded` |
+| 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` |
| Reconnect suppressed (backoff window) | `BadCommunicationError` | `Degraded` |
| Array write of the wrong shape / length / element type | `BadTypeMismatch` | unchanged |
@@ -314,7 +327,36 @@ 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 (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
+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.
+
+**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
@@ -359,7 +401,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/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/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptContext.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Core.Scripting.Abstractions/ScriptContext.cs
index 6a73c622..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
@@ -80,6 +80,50 @@ 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, 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.
+ ///
+ ///
+ /// 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/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/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/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/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
///
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable,
- IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
+ IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IAsyncDisposable, IDisposable
{
/// 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,25 @@ 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.
+ /// 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 next;
+#pragma warning restore CS0420
+ }
+ }
/// Occurs when a subscribed tag's value changes.
public event EventHandler? OnDataChange;
@@ -240,6 +267,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);
@@ -270,9 +312,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 +406,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 +509,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 AbCipInitializationSupersededException(
+ $"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 +557,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.Retire();
+ if (ex is AbCipInitializationSupersededException)
+ {
+ _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();
+ state.Retire();
}
- _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 +645,23 @@ 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();
+ var generation = 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);
+ 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) ----
@@ -638,12 +719,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);
@@ -655,14 +736,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 (Exception ace) when (ace is AbCipTagConfigurationException or AbCipLocalRefusalException)
+ {
+ // 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;
+ hostState = null;
+ WarnProbeRefusedOnce(state, ace.Message);
+ }
catch (Exception ex)
{
// Wire / init error — tear down the probe runtime so the next tick re-creates it.
@@ -674,9 +792,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
@@ -706,7 +822,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; }
@@ -715,6 +831,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;
@@ -776,7 +902,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];
@@ -791,32 +917,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
@@ -825,11 +963,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);
@@ -858,10 +1002,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))
@@ -871,18 +1017,25 @@ 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;
+ }
- _health = new DriverHealth(
+ UpdateHealth(current => new DriverHealth(
worstState,
- anySuccess ? now : _health.LastSuccessfulRead,
- worstState == DriverState.Healthy ? null : worstError);
+ anySuccess || anyAnswered ? now : current.LastSuccessfulRead,
+ worstState == DriverState.Healthy ? null : worstError));
}
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))
{
@@ -908,87 +1061,112 @@ 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);
-
- var status = runtime.GetStatus();
- if (status == (int)AbStatus.TypeMismatch)
+ await opLock.WaitAsync(ct).ConfigureAwait(false);
+ try
{
- // 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.None;
- }
- 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}");
- }
+ await runtime.ReadAsync(ct).ConfigureAwait(false);
- var tagPath = AbCipTagPath.TryParse(def.TagPath);
- var bitIndex = tagPath?.BitIndex;
+ 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}");
+ }
- // 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.None;
+ 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;
+ }
+ // 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)
{
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);
- return ReadOutcome.None;
+ WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ate.Message);
+ return ReadOutcome.Refused;
}
catch (AbCipTagConfigurationException ace)
{
@@ -1004,6 +1182,22 @@ 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.Refused;
+ }
+ 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,
+ // 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) 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)
@@ -1029,7 +1223,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;
@@ -1043,57 +1237,79 @@ 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.None;
- }
- 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)
{
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)
{
@@ -1108,6 +1324,17 @@ 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.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) when (device.Retired)
+ {
+ StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
return ReadOutcome.None;
}
catch (Exception ex)
@@ -1170,6 +1397,44 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
continue;
}
+ 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);
@@ -1180,11 +1445,12 @@ 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));
if (results[i].StatusCode == AbCipStatusMapper.Good)
- _health = new DriverHealth(DriverState.Healthy, now, null);
+ RecordWriteAnswered(now);
continue;
}
@@ -1206,10 +1472,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();
}
@@ -1229,20 +1498,36 @@ 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)
{
// 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,
"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 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)
{
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",
@@ -1251,7 +1536,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)
@@ -1269,39 +1554,44 @@ 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 (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)
{
@@ -1314,17 +1604,33 @@ 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))
+ {
+ // 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)
+ {
+ // 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.
- 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);
+ RecordWriteCommsFault(ex.Message);
_logger.LogWarning(ex,
"AbCip write transport exception for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
@@ -1335,10 +1641,51 @@ 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.
+ /// 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)
+ {
+ 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 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());
+ 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;
+ }
+ }
+
+ ///
+ /// 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)
@@ -1353,19 +1700,56 @@ 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");
- 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
? AbCipStatusMapper.Good
- : AbCipStatusMapper.MapStatus(writeStatus);
+ : BitWriteStatusWord(device, parentName, writeStatus, "write");
}
finally
{
@@ -1373,6 +1757,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.
@@ -1381,6 +1787,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
@@ -1394,10 +1801,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.");
}
///
@@ -1434,6 +1856,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
@@ -1465,10 +1888,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;
}
@@ -1479,9 +1902,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);
@@ -1491,10 +1912,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
{
@@ -1517,6 +1948,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;
@@ -1744,6 +2184,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
@@ -1896,16 +2350,35 @@ 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. This is the dispose the runtime calls
+ /// (it prefers ).
+ ///
/// A value task representing the asynchronous disposal.
public async ValueTask DisposeAsync()
{
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.
@@ -1967,6 +2440,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
@@ -2053,19 +2529,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/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
index e9bedb8f..b927d758 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
@@ -198,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..ffa3c0b1
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs
@@ -0,0 +1,18 @@
+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.
+///
+///
+/// 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/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
new file mode 100644
index 00000000..334568a1
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs
@@ -0,0 +1,42 @@
+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.
+ /// 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/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/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/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/LibplctagStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs
index 37614e2b..7be295a6 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,21 @@ 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).
+ // 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,
@@ -70,9 +80,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 +102,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 +112,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 +120,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/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
index e2ff94c9..07ac008e 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),
@@ -221,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 af1ce7e6..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,6 +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 DWORD n / 32 when the
+ // array is packed. Null for every other tag.
+ private int? _boolElementBit;
+
+ // 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;
@@ -51,6 +79,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
private AbStatus _status = AbStatus.Pending;
private byte[]? _writeBuffer;
+ private ushort _writeElements = 1;
private string? _pendingString;
private bool _disposed;
@@ -94,16 +123,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)
{
@@ -116,7 +144,20 @@ 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))
+ {
+ _elementPath = CipPath.EncodeSymbolic(name);
+ _dwordPath = CipPath.EncodeSymbolic(dwordName);
+ _boolElementBit = bit;
+ _boolLayout = BoolLayout.Unsettled;
+ path = _dwordPath;
+ }
+ else
+ {
+ path = CipPath.EncodeSymbolic(name);
+ }
}
catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException)
{
@@ -137,20 +178,45 @@ 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))
{
- // The controller answered — the tag exists. _status already holds the refusal.
+ // The controller answered. _status already holds the refusal.
}
}
///
- 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 (_boolElementBit is not 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;
@@ -164,6 +230,13 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
}
}
+ /// Settles a BOOL-array element tag's layout and selects the matching request path.
+ private void SetBoolLayout(BoolLayout layout)
+ {
+ _boolLayout = layout;
+ _path = layout == BoolLayout.PerElement ? _elementPath : _dwordPath;
+ }
+
///
/// 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.
@@ -196,6 +269,14 @@ 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). 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();
+ }
if (_pendingString is { } text)
{
// A STRING writes as the type the controller reported — a Logix STRING is a structure
@@ -204,7 +285,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)
@@ -213,7 +294,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.
@@ -223,17 +304,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
{
@@ -276,7 +358,7 @@ 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) : 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)),
@@ -315,10 +397,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)
{
@@ -410,6 +504,47 @@ 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
+ /// 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/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..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
@@ -48,6 +48,16 @@ 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. (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).
+ ///
+ [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
/// 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.Contracts/AbLegacyTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs
index 8c0742f5..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
@@ -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);
@@ -173,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;
}
@@ -224,6 +231,78 @@ 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.";
+
+ ///
+ /// 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
+ // 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
+ {
+ 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)
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.Coalescing.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs
new file mode 100644
index 00000000..416d1b62
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs
@@ -0,0 +1,250 @@
+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)
+/// — 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.
+///
+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 == 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>();
+ 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)
+ {
+ // 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);
+ }
+
+ 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)
+ {
+ // 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}");
+ }
+ 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.BlockRefusals.TryRemove(block.Key, out _);
+ 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 (!IsNoReply(ex.Status))
+ {
+ return Refused(ex.Message);
+ }
+ catch (NotSupportedException nse)
+ {
+ 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 236720f9..2444f3c1 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, IBatchSubscribable,
IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// Authored tags this driver could not build into its poll table.
@@ -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).
@@ -50,6 +64,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
///
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.
///
@@ -85,6 +105,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
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,
@@ -121,9 +143,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,66 +170,78 @@ 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).States, "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))
{
- _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;
}
- _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 +250,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 +264,108 @@ 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, 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)
+ {
+ if (bumpEpoch)
+ {
+ _lifecycleEpoch++;
+ _tagsByRawPath = new Dictionary(StringComparer.Ordinal);
+ }
+ var retired = _devices.Values.ToList();
+ _devices = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ return (retired, _lifecycleEpoch);
+ }
+ }
+
+ ///
+ /// 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 +382,39 @@ 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, 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 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);
+ // A newer InitializeAsync that ran while this teardown was still joining owns health now.
+ lock (_lifecycleLock)
+ {
+ if (teardownEpoch == _lifecycleEpoch)
+ _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
+ }
}
///
@@ -364,123 +436,431 @@ 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;
+
+ ///
+ /// 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);
+
+ ///
+ /// 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).
+ ///
+ /// = 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
+ {
+ 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;
+ }
+ }
+
///
+ ///
+ /// 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)
{
ArgumentNullException.ThrowIfNull(fullReferences);
+ 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)
- _health = 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);
- _health = 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);
- _health = 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.
- 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);
- _health = 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;
+ }
+ 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 (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);
+ }
+ }
+
+ /// 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();
+ var work = new Queue(PlanReadUnits(device, items));
+ while (work.TryDequeue(out var unit))
+ {
+ var members = unit.Block?.Members ?? [unit.Single!];
+ if (device.IsThrottled(Clock()))
+ {
+ 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;
+ }
+
+ 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 (members.Any(m => results[m.Index].StatusCode == AbLegacyStatusMapper.Good)) outcome.AnyGood = true;
+ device.RecordAnswered();
+ break;
+ case OpVerdict.Fault:
+ outcome.FaultMessage ??= message;
+ device.RecordCommsFault(Clock());
+ break;
+ case OpVerdict.Throttled:
+ outcome.FaultMessage ??= message;
+ break;
+ case OpVerdict.Opaque:
+ outcome.OpaqueMessage ??= message;
+ break;
+ }
+ }
+ outcome.FoldOpaque();
+ 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}")
+ : 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;
+ 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);
+ device.BeforeOperationGate?.Invoke(def.Name);
+ await opLock.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ 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);
+ 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 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);
+ }
+ 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)
+ {
+ 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, 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, AbLegacyStatusMapper.MapStatus(ex.Status));
+ 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
+ // the handle (re-creating it only re-paid the init read), warn once, and NO health write.
+ WarnRefusalOnce(device, def, "read", nse.Message, AbLegacyStatusMapper.BadNotSupported);
+ 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 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.
+ /// 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 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).
+ ///
+ ///
+ /// ⚠️ 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
+ 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 ----
///
@@ -488,7 +868,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
IReadOnlyList writes, CancellationToken cancellationToken)
{
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
+ // 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++)
{
@@ -503,7 +888,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;
@@ -521,6 +906,15 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
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)
@@ -530,8 +924,12 @@ 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}");
+ {
+ 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;
@@ -543,12 +941,46 @@ 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 (_tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is { } refusal)
{
+ WarnRefusalOnce(device, def, "write", refusal);
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
continue;
}
+ // 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 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)
+ WarnRefusalOnce(device, def, "write", $"{def.DataType} array writes are not supported.");
+ results[i] = new WriteResult(conversion);
+ 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
@@ -557,10 +989,12 @@ 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)
+ 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;
}
@@ -573,7 +1007,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value);
+ 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);
status = runtime.GetStatus();
}
@@ -582,22 +1023,36 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
opLock.Release();
}
- if (status != 0)
- {
- // Evict the stale handle so the next call re-creates it (mirrors AbCip).
- EvictRuntime(device, def.Name);
- results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status));
- }
- else
- {
- results[i] = new WriteResult(AbLegacyStatusMapper.Good);
- }
+ 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 tex)
+ {
+ // 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();
+ outcome.AnyAnswered = true;
+ 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.
+ WarnRefusalOnce(device, def, "write", nse.Message);
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException)
{
@@ -611,20 +1066,52 @@ 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)
{
// Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip).
EvictRuntime(device, def.Name);
+ device.RecordCommsFault(Clock());
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
+ outcome.FaultMessage ??= ex.Message;
}
}
+ outcome.FoldOpaque();
+ 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}";
+ }
+ 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 ----
///
@@ -684,7 +1171,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 +1180,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)
@@ -710,19 +1197,26 @@ 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 if (IsNoReply((AbStatus)status)) state.RecordCommsFault(Clock()); // opaque / Unsupported: neutral
}
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 (ex is not AbTransportException { Status: var st } || IsNoReply(st)) state.RecordCommsFault(Clock());
}
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 +1262,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 ----
@@ -810,8 +1305,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// . 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
@@ -824,6 +1321,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
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
@@ -831,7 +1345,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
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
@@ -848,9 +1366,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
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
{
@@ -885,7 +1402,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
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!;
@@ -893,7 +1410,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
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!;
@@ -906,8 +1423,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))
@@ -915,18 +1432,32 @@ 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;
+ // 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);
+ }
}
return new IndirectResolution(def with { Name = resolvedName, Address = concreteText }, 0, false);
}
/// 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.
@@ -942,35 +1473,52 @@ 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);
+ 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, 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)
+ if (status != 0 && IsDeviceRefusal((AbStatus)status))
+ 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
@@ -987,37 +1535,61 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
}
}
- 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)
{
- // 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.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 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(Clock()))
+ 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();
}
}
@@ -1087,6 +1659,20 @@ 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,
+ 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 (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
/// the refusal is asked about exactly the runtime a write would otherwise create.
private AbLegacyTagCreateParams CreateParamsFor(DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed) =>
@@ -1101,30 +1687,35 @@ 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 createParams = CreateParamsFor(device, def, parsed);
+ return await CreateSingleFlightAsync(device, device.Runtimes, def.Name, () => _tagFactory.Create(createParams), ct)
+ .ConfigureAwait(false);
+ }
- 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.Runtimes[def.Name] = runtime, ct)
- .ConfigureAwait(false);
- return runtime;
- }
- finally
- {
- creationLock.Release();
- }
+ ///
+ /// 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;
+ // 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.
+ if (device.IsRetired) return fetched;
+ return await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
}
///
@@ -1158,6 +1749,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
///
public void Dispose()
{
+ 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
@@ -1165,20 +1757,17 @@ 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);
+ // A newer InitializeAsync that ran while this teardown was still joining owns health now.
+ lock (_lifecycleLock)
+ {
+ if (teardownEpoch == _lifecycleEpoch)
+ _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
+ }
}
///
@@ -1222,7 +1811,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; } =
@@ -1240,21 +1829,62 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
public System.Collections.Concurrent.ConcurrentDictionary IndirectLastName { get; } =
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 .
- ///
- private readonly System.Collections.Concurrent.ConcurrentDictionary _creationLocks = 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);
///
- /// Gets or creates the creation lock for the specified key.
+ /// 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).
///
- /// The lock key.
- /// The semaphore slim for the key.
- public SemaphoreSlim GetCreationLock(string key) =>
- _creationLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
+ public SemaphoreSlim ConnectGate { get; } = new(1, 1);
+
+ ///
+ /// 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 .
+ ///
+ 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();
@@ -1323,28 +1953,92 @@ 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);
+
+ /// 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);
+
/// Tags already warned about for a malformed address (once-per-tag log).
public System.Collections.Concurrent.ConcurrentDictionary MalformedAddressLogged { get; } = new();
///
- /// Disposes all cached tag runtimes.
+ /// 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.
///
- public void DisposeRuntimes()
+ /// The runtime key whose gates to drop.
+ public void ForgetGates(string runtimeKey)
{
- foreach (var r in Runtimes.Values) r.Dispose();
- Runtimes.Clear();
- foreach (var r in ParentRuntimes.Values) r.Dispose();
- ParentRuntimes.Clear();
+ _runtimeLocks.TryRemove(runtimeKey, out _);
+ }
+
+ /// 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.
+ public bool IsRetired => _retired;
+
+ ///
+ /// 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.
+ ///
+ /// The runtime table ( or ).
+ /// The runtime key.
+ /// The initialised runtime.
+ public void Cache(System.Collections.Concurrent.ConcurrentDictionary table,
+ string key, IAbLegacyTagRuntime runtime)
+ {
+ 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/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs
index 9dfd9707..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,6 +97,7 @@ public static class AbLegacyDriverFactoryExtensions
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
+ 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)
@@ -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/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/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/LibplctagLegacyStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs
index 3d762e84..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,6 +23,15 @@ internal static class LibplctagLegacyStatusTranslation
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
Status.ErrorBadReply => AbStatus.BadReply,
+ // 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/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/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation/CalculationDriverFactoryExtensions.cs
index 1a589d65..2c417de1 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,14 @@ 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, 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
{
@@ -89,9 +103,37 @@ public static class CalculationDriverFactoryExtensions
Scripting = scripting,
};
- return new CalculationDriver(
- options, driverInstanceId, scriptRoot,
- logger: loggerFactory?.CreateLogger());
+ return new CalculationDriver(options, driverInstanceId, scriptRoot, logger: logger);
+ }
+
+ ///
+ /// 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 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,
+ out IReadOnlyList adjustments)
+ {
+ adjustments = [];
+ 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.Sanitized(out adjustments);
}
/// The merged-config DTO the calc factory binds from. The Calculation driver has no
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 absentdataType (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..cbf94e6b 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;
@@ -208,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
@@ -215,6 +214,27 @@ 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);
+
+ ///
+ /// 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;
///
@@ -225,62 +245,84 @@ 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
{
- // 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);
+ await InitializeCoreAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
+ }
+ finally
+ {
+ _lifecycleGate.Release();
+ }
+ }
- // 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.
- _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)
+ /// 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
+ {
+ if (HasConfigBody(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
+ // 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).
+ // 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))
- _tagsByRawPath[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
+ if (S7TagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def, out var shapeRejection)
+ && S7AddressParser.TryParse(def.Address, out var parsedAddress))
+ {
+ tags[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent };
+ parsed[entry.RawPath] = parsedAddress;
+ }
else
{
- _skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
- _logger.LogWarning(
- "S7 tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
- _driverInstanceId, entry.RawPath);
+ skipped.Add((entry.RawPath, shapeRejection ?? "TagConfig did not map to a definition"));
}
}
- _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);
+ 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)
@@ -288,97 +330,160 @@ 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));
+ _refusalWarned.Clear();
+ _noGroup.Clear();
+ _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, 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);
+ }
}
-
- Plc = plc;
- _plcDead = false;
- _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)
+ finally
{
- _parsedByRawPath[t.Name] = S7AddressParser.Parse(t.Address); // throws FormatException
+ _gate.Release();
}
- _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;
+ _writeOwnedError = 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;
@@ -393,11 +498,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.
@@ -409,6 +509,24 @@ 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();
+ }
+ 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 */ }
@@ -421,6 +539,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;
@@ -437,13 +650,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 +680,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
@@ -639,15 +708,22 @@ 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.
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.
@@ -664,7 +740,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;
}
@@ -682,6 +758,15 @@ 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;
+ // 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)
{
@@ -689,6 +774,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
@@ -712,7 +802,19 @@ public sealed class S7Driver
perTag.Add(span.Members[0].OriginalIndex);
continue;
}
- await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false);
+ // 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;
+ }
+ if (await ReadSpanAsync(plc, span, results, now, outcome, cancellationToken).ConfigureAwait(false)
+ is { } refusedMembers)
+ {
+ (refusedSpans ??= []).Add(refusedMembers);
+ (forcedPerTag ??= []).AddRange(refusedMembers);
+ }
}
perTag.AddRange(plan.LeftoverIndexes);
}
@@ -735,11 +837,23 @@ 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)
+ {
+ perTag.AddRange(forcedPerTag);
+ perTag.Sort();
}
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);
@@ -760,7 +874,38 @@ public sealed class S7Driver
}
catch (Exception ex)
{
- results[i] = new DataValueSnapshot(null, RecordReadFailure(ex, outcome), null, now);
+ 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)
+ {
+ 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);
}
}
}
@@ -793,10 +938,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();
@@ -866,6 +1011,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.
///
@@ -973,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,
@@ -981,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;
@@ -1012,6 +1167,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
{
@@ -1037,12 +1199,24 @@ 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,
"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;
}
@@ -1061,7 +1235,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);
}
}
}
@@ -1134,8 +1308,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)
{
@@ -1156,12 +1334,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);
+ 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)
@@ -1177,9 +1367,10 @@ 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);
}
}
+ return null;
}
///
@@ -1226,7 +1417,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
@@ -1252,8 +1443,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)
{
@@ -1262,19 +1455,38 @@ 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.
+ // 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: {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
@@ -1295,10 +1507,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);
@@ -1378,7 +1590,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
@@ -1849,8 +2061,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;
@@ -1891,13 +2106,20 @@ 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))
{
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);
@@ -1929,15 +2151,26 @@ 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 +
// 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: {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)
{
@@ -1964,7 +2197,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;
@@ -1976,10 +2209,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;
@@ -1988,15 +2222,24 @@ public sealed class S7Driver
else if (outcome.AnySuccess)
_writeFault = null; // a write got through — the protection fault is gone
+ var answeredAt = outcome.AnyAnswered ? 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
- && Health.State == DriverState.Faulted && Health.LastError == owned.Message)
- Health = new DriverHealth(DriverState.Healthy, Health.LastSuccessfulRead, 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;
}
@@ -2008,7 +2251,8 @@ public sealed class S7Driver
state = wf.State;
error = wf.Message;
}
- Health = new DriverHealth(state, Health.LastSuccessfulRead, error);
+ if (state == DriverState.Degraded) _writeOwnedError = error;
+ TryUpdateHealth(gen, h => new DriverHealth(state, answeredAt ?? h.LastSuccessfulRead, error));
}
///
@@ -2026,10 +2270,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.
@@ -2039,10 +2291,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);
@@ -2312,6 +2564,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
@@ -2365,22 +2648,89 @@ 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;
+ // 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;
}
@@ -2401,7 +2751,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
@@ -2465,9 +2815,20 @@ 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);
- 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 ----
@@ -2490,66 +2851,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;
@@ -2576,7 +2967,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.
@@ -2585,9 +2979,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();
}
///
@@ -2597,6 +2992,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 */ }
@@ -2617,10 +3016,11 @@ 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;
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/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7DriverProbe.cs
index d936b98a..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);
@@ -128,15 +136,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)
{
@@ -146,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/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/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.
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 ?? true,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
@@ -220,6 +229,8 @@
},
Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout),
Transport = Transport,
+ // 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/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.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/ScriptAnalysis/ScriptAnalysisService.cs
index ba69b0da..23d610b7 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,16 @@ 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. 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));
+ 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/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..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
@@ -87,6 +87,16 @@ 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);
+
+ // 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/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..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
@@ -112,17 +112,15 @@ 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"))
{
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/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/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..c3c105ab 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 = 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);
+
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.OpcUaServer/AddressSpaceApplier.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
index bd7e8ba0..961319f5 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/AddressSpaceApplier.cs
@@ -93,37 +93,33 @@ 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++;
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;
@@ -145,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
@@ -156,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
@@ -176,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)
@@ -194,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
@@ -243,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).
@@ -294,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;
}
@@ -320,8 +302,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);
@@ -342,10 +324,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
@@ -363,25 +344,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)))
{
@@ -550,7 +512,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
@@ -1074,96 +1035,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
@@ -1285,10 +1159,53 @@ 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);
+ _recording?.Ensured.Add((realm, nodeId));
+ return true;
+ }
catch (Exception ex) { _logger.LogWarning(ex, "AddressSpaceApplier: EnsureVariable threw for {Node}", nodeId); return false; }
}
+ /// 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)
+ {
+ 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);
+ }
+
+ /// 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
// classifier's Classify names the routing). The `with`-expression whitelist technique is preserved there
@@ -1401,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.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.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 f8355b90..6b6f5970 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
@@ -110,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;
@@ -289,6 +297,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;
///
@@ -356,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, ).
@@ -382,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)
{
@@ -396,20 +481,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();
+ }
}
///
@@ -569,9 +657,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
@@ -705,9 +807,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 +824,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 +852,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 +898,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 +910,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 +923,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 +1048,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 +1072,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 +1080,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 +1096,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
@@ -1018,7 +1148,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);
@@ -1053,22 +1183,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);
}
}
@@ -1080,11 +1195,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);
}
@@ -1364,6 +1480,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.
@@ -1375,12 +1494,14 @@ 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
// 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
@@ -1449,7 +1570,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).
@@ -1511,7 +1632,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
@@ -1558,6 +1679,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.
@@ -1607,9 +1729,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,
@@ -1669,11 +1791,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
@@ -1714,7 +1837,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;
}
@@ -1751,13 +1875,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);
}
}
}
@@ -1923,7 +2047,7 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
}
if (alwaysReport || ReadConditionDelta(alarm) != before)
- ReportConditionEvent(alarm, DateTime.UtcNow);
+ ReportConditionEvent(alarm, UtcNow);
return result;
}
@@ -2193,20 +2317,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;
}
@@ -2617,13 +2781,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.
@@ -2680,7 +2845,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
@@ -2805,10 +2970,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
@@ -2820,12 +2993,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)
@@ -2886,6 +3057,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)
@@ -2900,9 +3072,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
@@ -2977,6 +3173,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
@@ -3013,6 +3210,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.
@@ -3472,15 +3670,10 @@ public sealed class OtOpcUaNodeManager : CustomNodeManager2
{
lock (Lock)
{
- 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();
+ // 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();
// 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
@@ -3492,38 +3685,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)
@@ -3583,6 +3794,8 @@ 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
// condition removal. AddReference wires the edge bidirectionally: forward on the UNS reference
@@ -3649,6 +3862,8 @@ 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
// this they would keep a dangling inverse-notifier entry to the removed condition.
@@ -3656,7 +3871,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);
@@ -3676,80 +3891,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));
-
- // Value variables (+ their historized-tagname registrations).
- foreach (var id in _variables.Keys.Where(InScope).ToList())
- {
- if (_variables.TryRemove(id, out var v))
- {
- // 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))
- {
- 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))
- {
- 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);
}
@@ -3757,6 +3907,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();
@@ -4061,8 +4289,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
@@ -4070,7 +4299,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);
@@ -4156,10 +4395,213 @@ 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);
+
+ // 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;
+ }
+ }
+
+ 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)
+ {
+ // 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 _);
+ }
+ }
+
+ 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)
+ {
+ if (MonitoredNodes.Count == 0 && Volatile.Read(ref _suppressedItems) == 0) return;
+ var pending = new Stack();
+ pending.Push(node);
+ var children = new List();
+ 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
+ /// 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);
+ // Event items hold no component-cache reference (see TombstoneMonitoredNode).
+ }
+ }
+
+ /// 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 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 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
+ {
+ 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.
@@ -4339,6 +4781,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
@@ -4775,7 +5231,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;
@@ -4798,7 +5254,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;
@@ -4813,8 +5288,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++;
}
@@ -4886,8 +5366,21 @@ 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);
+ continue; // an event item holds no component-cache reference (see TombstoneMonitoredNode)
+ }
+
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++;
@@ -6318,13 +6811,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));
@@ -6369,7 +6866,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,
@@ -6589,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).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
@@ -6642,7 +7143,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);
}
@@ -6961,28 +7464,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/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverHostActor.cs
index 76c82ebf..fd29c0b8 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
{
@@ -202,6 +202,20 @@ 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;
+
+ ///
+ /// 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();
@@ -264,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;
///
@@ -641,8 +660,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.
@@ -694,6 +711,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,
@@ -707,7 +726,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,
@@ -728,7 +746,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
@@ -737,11 +756,12 @@ 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,
- sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView));
+ sharesAlarmQueueWithLocal, applyOutcomeTimeout, captureWhilePrimaryUnreachable, historyUpdateRoleView,
+ scripting));
/// Initializes a new DriverHostActor with the specified dependencies.
/// Database context factory for configuration database access.
@@ -760,8 +780,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
@@ -830,6 +848,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,
@@ -842,7 +862,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,
@@ -863,8 +882,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;
@@ -1146,8 +1167,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()
{
@@ -1178,13 +1200,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);
+ 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");
}
@@ -1500,6 +1532,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Receive(HandleApplyWedged);
Receive(HandleGetDiagnostics);
Receive(ForwardBatchToMux);
+ Receive(OnRetiredChildTerminated);
+ Receive(_ => HandleScriptedAlarmHostRestarted());
Receive(ForwardNativeAlarm);
Receive(OnDriverConnectivityChanged);
Receive(HandleCommsRecoveryAudit);
@@ -1562,6 +1596,8 @@ public sealed class DriverHostActor : ReceiveActor, IWithTimers, IWithUnboundedS
Receive(HandleApplyWedged);
Receive(HandleGetDiagnostics);
Receive(ForwardBatchToMux);
+ Receive(OnRetiredChildTerminated);
+ Receive(_ => HandleScriptedAlarmHostRestarted());
Receive(ForwardNativeAlarm);
Receive(OnDriverConnectivityChanged);
Receive(HandleCommsRecoveryAudit);
@@ -1606,6 +1642,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);
@@ -2501,6 +2547,8 @@ 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(_ => HandleScriptedAlarmHostRestarted());
Receive(m =>
_log.Debug("DriverHost {Node}: dropping a native alarm transition ({Cond}) from {Id} while Stale",
_localNode, m.Args.ConditionId, m.DriverInstanceId));
@@ -3652,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)
{
@@ -3816,8 +3865,10 @@ 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);
// 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
@@ -3836,6 +3887,56 @@ 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.
+ ///
+ ///
+ /// 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);
+ }
+
+ ///
+ /// 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);
+
private static string ActorNameFor(string driverInstanceId, long generation)
{
// Akka actor names cannot contain '/', ':', or whitespace. Mangle defensively. The monotonic
@@ -3881,8 +3982,12 @@ 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
+ // 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/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/OpcUa/OpcUaPublishActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/OpcUa/OpcUaPublishActor.cs
index 68c23d4d..a1667540 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,110 @@ 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.
+ ///
+ /// 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(
+ 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(object Incarnation, RebuildAddressSpace Request, IActorRef Requester, Exception Error);
+
+ /// Test seams for the off-thread plan step. Production passes none.
+ 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).
+ 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.
+ internal 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;
+
+ ///
+ /// 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).
+ /// 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.)
+ ///
+ private ReplayScope _replayScope = ReplayScope.Nothing;
+
+ 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, new Dictionary<(AddressSpaceRealm, string), VariableShape>(), 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
+ /// are applied at once AND remembered in for replay after the apply.
+ 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 +281,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.
@@ -293,6 +390,28 @@ 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) =>
+ // 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)));
+
+ /// 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.
/// The service level publisher.
@@ -329,13 +448,41 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
bool dbLess = false,
IDataPlaneAuthoritySink? dataPlaneAuthority = 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;
_sink = sink;
_dataPlaneAuthority = dataPlaneAuthority;
_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 +497,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
Receive(HandleAlarmUpdate);
Receive(HandleAlarmQualityUpdate);
Receive(HandleRebuild);
+ Receive(HandlePlanReady);
+ Receive(HandlePlanFailed);
Receive(HandleServiceLevelChanged);
Receive(HandleRedundancyStateChanged);
Receive(HandleDbHealthStatus);
@@ -358,6 +507,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()
{
@@ -380,6 +547,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"));
@@ -401,6 +569,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);
@@ -431,6 +601,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);
@@ -454,6 +626,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);
@@ -468,6 +641,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);
@@ -480,114 +654,467 @@ 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
+ // 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)
{
- 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);
- }
+ _pendingRebuilds.Enqueue((msg, requester));
+ return;
}
- // 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)
{
+ // 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
{
_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 })
+ {
+ 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",
+ 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;
+ }
+
+ _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);
+ // 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(incarnation, msg, requester, artifact, authority, authorityError, composition, plan, inconsistencies);
+ })
+ .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
+ {
+ outcome = ApplyPlan(ready);
+ }
+ finally
+ {
+ _planInFlight = false;
+ }
+
+ FinishRebuild(ready.Request, ready.Requester, outcome);
+ 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}"));
+ 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()
+ {
+ 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(ReplayScope scope)
+ {
+ if (_deferred.IsEmpty) return;
+ 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); }
+ 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); }
+ }
+
+ 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, {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);
+ }
+
+ ///
+ /// 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 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;
+ }
+
+ /// 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(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));
+ }
+
+ 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;
+ }
+ }
+
+ ///
+ /// 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);
+ }
+
+ // 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(_replayScope);
+ _replayScope = ReplayScope.Nothing;
+
+ 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 previous = _lastApplied;
+ var recording = _applier!.BeginRecording();
+ try
+ {
+ var result = ApplyPlanCore(ready, out var rebuilt);
+ // 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.EndRecording();
+ }
+ }
+
+ /// 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());
+
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 +1122,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 +1134,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 +1156,8 @@ public sealed class OpcUaPublishActor : ReceiveActor, IWithTimers
return (false, null);
}
- var outcome = _applier.Apply(plan);
+ 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
@@ -725,8 +1250,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 +1334,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.
@@ -881,17 +1392,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
@@ -936,46 +1445,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/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/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/ServiceCollectionExtensions.cs
index e6e58419..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,
@@ -638,7 +637,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/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/VirtualTags/DependencyMuxActor.cs
index 40e9bf19..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
@@ -52,11 +50,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
@@ -154,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));
}
@@ -265,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/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/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/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();
+ }
+}
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();
+ }
+ }
+}
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/AbCipArrayWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs
new file mode 100644
index 00000000..5ebd5908
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipArrayWriteTests.cs
@@ -0,0 +1,179 @@
+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");
+ 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.
+ [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/AbCipBitInWordWidthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBitInWordWidthTests.cs
new file mode 100644
index 00000000..dce0c47d
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipBitInWordWidthTests.cs
@@ -0,0 +1,161 @@
+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();
+ }
+ }
+
+ ///
+ /// 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
new file mode 100644
index 00000000..e4e23d7c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipDeviceRefusalTests.cs
@@ -0,0 +1,298 @@
+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 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);
+ }
+
+ ///
+ /// 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);
+ }
+
+ ///
+ /// #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
new file mode 100644
index 00000000..6811afcc
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipLifecycleConcurrencyTests.cs
@@ -0,0 +1,239 @@
+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, int seconds = 5)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(seconds);
+ 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);
+ var superseded = await Should.ThrowAsync(() => init);
+ superseded.ShouldNotBeAssignableTo(); // M4: distinct from a caller cancellation
+
+ 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, 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_and_leak_no_runtime()
+ {
+ 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;
+ while (!stop.IsCancellationRequested)
+ {
+ _ = 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 < 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: DisposeAsync is the complete, idempotent teardown (the runtime prefers it).
+ [Fact]
+ public async Task DisposeAsync_is_a_complete_idempotent_teardown()
+ {
+ 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();
+ }
+
+ ///
+ /// 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.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(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.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/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();
+ }
+}
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();
+ }
+}
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/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/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();
+ }
}
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..db68d1f5
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests/AbCipWriteHealthTests.cs
@@ -0,0 +1,175 @@
+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();
+ }
+
+ /// 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();
+ }
+
+ ///
+ /// 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/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;
}
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 91a1a428..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
@@ -112,6 +112,163 @@ 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"));
+ }
+
+ ///
+ /// #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]
+ }
+
+ /// 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()
+ {
+ 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()
{
@@ -129,14 +286,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]
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..9a098667
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs
@@ -0,0 +1,95 @@
+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 (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)
+{
+ /// 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]
+ [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 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(
+ [.. 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)
+ {
+ // 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;
+ }
+
+ 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);
+
+ 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 });
+ }
+ 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");
+ }
+ }
+}
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..64eb19fb
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs
@@ -0,0 +1,69 @@
+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)];
+
+ // 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);
+ }
+
+ 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);
+ }
+}
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..8542eb22
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs
@@ -0,0 +1,146 @@
+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));
+ }
+
+ ///
+ /// 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");
+ }
+ // 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
new file mode 100644
index 00000000..1b209131
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs
@@ -0,0 +1,231 @@
+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;
+
+///
+/// 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 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) =>
+ new(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags = AbLegacyRawTags.Entries(tags),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ CoalesceReads = coalesce,
+ }, "drv-i1", factory);
+
+ // ---- 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)
+ {
+ AbLegacyDriver.IsDeviceRefusal(status).ShouldBeTrue();
+ AbLegacyDriver.IsOpaque(status).ShouldBeFalse();
+ AbLegacyDriver.IsNoReply(status).ShouldBeFalse();
+ }
+
+ [Theory]
+ [InlineData(AbStatus.BadReply)]
+ [InlineData(AbStatus.Partial)]
+ 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()
+ {
+ // 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.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}");
+ }
+ 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_opaque_replies_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}: never failed fast");
+ }
+ factory.Tags["N7:9"].ReadCount.ShouldBe(3);
+ 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/AbLegacyArrayWriteSupportParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs
new file mode 100644
index 00000000..3f5a50f6
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs
@@ -0,0 +1,75 @@
+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());
+ }
+
+ ///
+ /// 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)]
+ [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);
+}
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..f9a23fe3
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs
@@ -0,0 +1,260 @@
+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);
+ }
+
+ /// 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
+ }
+
+ /// 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()
+ {
+ 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()
+ {
+ 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/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);
+ }
+}
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/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/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..16064b8d
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs
@@ -0,0 +1,255 @@
+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 },
+ 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") =>
+ [.. 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 first = await drv.ReadAsync(refs, CancellationToken.None);
+
+ // 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);
+ 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);
+ var second = await drv.ReadAsync(refs, CancellationToken.None);
+ Volatile.Read(ref DeadTag.WireOps).ShouldBe(opsAfterFirst);
+ 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));
+ 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.
+ (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);
+ 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);
+ recovered[0].Value.ShouldBe(9);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+}
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()
{
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..e5e15fcf
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs
@@ -0,0 +1,350 @@
+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);
+ }
+
+ 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();
+ // 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();
+ 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);
+ }
+
+ ///
+ /// 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/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);
+ }
}
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/AbLegacyReadCoalescingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs
new file mode 100644
index 00000000..b1adfc1a
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs
@@ -0,0 +1,184 @@
+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_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(
+ 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));
+ }
+ // 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);
+ }
+
+ [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 != false).ShouldBe(expected);
+}
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)));
+}
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;
}
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..5d2a86b7
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.Calculation.Tests/CalculationScriptingOptionsResolutionTests.cs
@@ -0,0 +1,69 @@
+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, out _).ShouldBeNull();
+
+ [Fact]
+ public void No_driver_keys_inherits_every_node_knob()
+ {
+ var resolved = CalculationDriverFactoryExtensions.ResolveScripting(null, null, Node, out _).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, 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, out _).ShouldNotBeNull();
+
+ resolved.MaxAllocBytesPerRun.ShouldBe(0);
+ resolved.QuarantineAfterConsecutiveTimeouts.ShouldBe(ScriptingOptions.DefaultQuarantineAfterConsecutiveTimeouts);
+ }
+}
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.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..79015983
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.IntegrationTests/S7_1500/S7_1500RefusalTests.cs
@@ -0,0 +1,85 @@
+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;
+ // 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.
+ 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.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);
+ 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.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.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/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/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);
+ }
+}
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/S7LifecycleGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7LifecycleGateTests.cs
new file mode 100644
index 00000000..1c99a61e
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7LifecycleGateTests.cs
@@ -0,0 +1,206 @@
+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);
+ }
+
+ ///
+ /// 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
+ /// 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);
+ }
+}
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();
+ }
+}
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..8c7c5182
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7RefusalHealthTests.cs
@@ -0,0 +1,293 @@
+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)),
+ };
+
+ /// 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 clock = new PassClock();
+ var drv = new S7Driver(Options(), "s7-refusal", factory, log) { UtcNow = () => clock.Now };
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ 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.
+ [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, clock) = await StartAsync();
+ using var _ = drv;
+ factory.Read = (addr, _) => addr == "DB99.DBW0" ? throw ReadRefusal() : Task.FromResult((ushort)7);
+
+ 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");
+ }
+ 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, _, clock) = await StartAsync();
+ using var __ = drv;
+
+ // 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.ShouldBe(at);
+ }
+
+ /// 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, _, clock) = 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, _, clock) = 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");
+ }
+
+ ///
+ /// 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, _, clock) = 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, clock) = 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 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.ShouldBeGreaterThan(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, _, clock) = 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/S7SpanRefusalFallbackTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanRefusalFallbackTests.cs
new file mode 100644
index 00000000..11b7358d
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Tests/S7SpanRefusalFallbackTests.cs
@@ -0,0 +1,213 @@
+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);
+ }
+
+ ///
+ /// 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()
+ {
+ 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)");
+ }
+}
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/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()
{
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/AbLegacyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs
index f4297127..7dc4c263 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,27 @@ 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 != 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]
public void A_legacy_TimeSpan_string_key_is_loaded_then_migrated_to_ms_and_removed()
{
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,
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();
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..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
@@ -122,6 +122,38 @@ 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();
+ }
+
+ /// 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()
{
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());
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);
+ }
+}
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.IntegrationTests/StaleMonitoredNodeWireTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs
new file mode 100644
index 00000000..6893fc2e
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.IntegrationTests/StaleMonitoredNodeWireTests.cs
@@ -0,0 +1,287 @@
+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);
+
+ // 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");
+ 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}");
+
+ // 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
+ {
+ SafeDelete(pkiRoot);
+ }
+ }
+
+ /// 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);
+ 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.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 08076fb1..1282a731 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/AddressSpaceApplierFailureSurfaceTests.cs
@@ -45,45 +45,56 @@ 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 ----------------
- /// 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(
@@ -91,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) =>
@@ -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/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
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..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
@@ -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,53 @@ 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;
+
+ (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);
+ updater.UpdateCalls.ShouldBeEmpty();
+ }
+ 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);
+ 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(); }
}
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/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();
+ }
+}
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..4875ac61 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,83 @@ 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();
+ }
+
+ ///
+ /// 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
@@ -468,7 +545,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 +554,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(
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..3df484bc 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,10 +234,55 @@ 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.Good);
+ fake.EventsCalled.ShouldBeTrue();
+ fake.LastSourceName.ShouldBe(equipmentId);
+
+ 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);
- fake.LastSourceName.ShouldBeNull(); // source never reached
+ results[0].StatusCode.Code.ShouldBe(StatusCodes.BadHistoryOperationUnsupported, "set by the override's guard");
fake.EventsCalled.ShouldBeFalse();
await host.DisposeAsync();
@@ -257,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_yields_BadHistoryOperationUnsupported 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
new file mode 100644
index 00000000..7940bd09
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerLockTimeBenchmarkTests.cs
@@ -0,0 +1,196 @@
+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.
+/// 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
+/// machine-independent P3 visit counter is the strict pin.
+///
+[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();
+ }
+}
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() { }
+ }
+}
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/NodeManagerStaleMonitoredNodeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs
new file mode 100644
index 00000000..5ddd6b71
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/NodeManagerStaleMonitoredNodeTests.cs
@@ -0,0 +1,230 @@
+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(); }
+ }
+
+ ///
+ /// 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)
+ {
+ 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();
+ }
+}
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));
+ }
+}
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();
}
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/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();
+ }
+}
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..0e7e48ac
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverHostActorSourceRemovedTests.cs
@@ -0,0 +1,224 @@
+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;
+
+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. 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
+{
+ 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),
+ ];
+
+ 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 void ArrangeTwoDriversWithRetainedValues()
+ {
+ _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,
+ virtualTagEvaluator: NullVirtualTagEvaluator.Instance,
+ virtualTagHostOverride: CreateTestProbe().Ref));
+ 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("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);
+ }
+
+ /// 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 void AwaitSubscribed(string driverId, string path) =>
+ AwaitAssert(() => (Driver(driverId).LastSubscribedRefs ?? []).ShouldContain(path), PresenceBudget);
+
+ 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.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()
+ {
+ ArrangeTwoDriversWithRetainedValues();
+
+ // Positive control before the removal: both values replay.
+ 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);
+
+ AwaitAssert(
+ () => 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()
+ {
+ 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");
+
+ 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/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/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/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;
}
///
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/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/OpcUaPublishActorDeferredWriteTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs
new file mode 100644
index 00000000..2ab304a8
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/OpcUa/OpcUaPublishActorDeferredWriteTests.cs
@@ -0,0 +1,844 @@
+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();
+ }
+
+ // ───────────────────── 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. The surgical
+ /// apply shape; the rebuild shapes are the two tests below (scripted: replayed; native: carried over).
+ ///
+ [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);
+ 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) 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
+
+ gate.Set();
+ ExpectMsg(PlanBudget).Degraded.ShouldBeFalse();
+
+ 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");
+ }
+
+ /// 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();
+ }
+
+
+ ///
+ /// 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);
+
+ // ───────────────────────────── 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(string equipmentName = "Eq", int rawTags = 0, bool nativeAlarm = false) =>
+ 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 = equipmentName, MachineCode = "EQ-1" },
+ },
+ RawFolders = rawTags == 0 && !nativeAlarm
+ ? Array.Empty()
+ : new object[] { new { RawFolderId = "rf-1", ParentRawFolderId = (string?)null, Name = "Plant", ClusterId = "c1" } },
+ 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 && !nativeAlarm
+ ? 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 = "{}",
+ }).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
+ {
+ 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();
+ 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;
+
+ 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; }
+ _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;
+ }
+ }
+
+ 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);
+ 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;
+ }
+
+ 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;
+ if (_variables.Add(variableNodeId)) _shapes[variableNodeId] = (dataType, isArray, arrayLength);
+ }
+ }
+
+ public void RebuildAddressSpace()
+ {
+ lock (_gate)
+ {
+ // 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);
+ }
+ }
+
+ 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") { }
+ /// 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); }
+ public bool RemoveEquipmentSubtree(string equipmentNodeId, AddressSpaceRealm realm) => true;
+ }
+}
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
{
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()
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. -->
+
+