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()], + _ => null, + }; + + /// Encodes as elements. + /// The element type (must have an ). + /// The element values; their count is the element count. + /// The little-endian wire bytes, values.Count × ElementWidth(type) long. + /// The type is not encodable. + public static byte[] Encode(AbCipDataType type, IReadOnlyList values) + { + var width = ElementWidth(type) + ?? throw new NotSupportedException($"Array writes of {type} elements are not supported."); + var buffer = new byte[values.Count * width]; + for (var i = 0; i < values.Count; i++) + { + var v = values[i]; + var span = buffer.AsSpan(i * width, width); + switch (type) + { + case AbCipDataType.SInt: span[0] = unchecked((byte)Convert.ToSByte(v)); break; + case AbCipDataType.USInt: span[0] = Convert.ToByte(v); break; + case AbCipDataType.Int: BinaryPrimitives.WriteInt16LittleEndian(span, Convert.ToInt16(v)); break; + case AbCipDataType.UInt: BinaryPrimitives.WriteUInt16LittleEndian(span, Convert.ToUInt16(v)); break; + case AbCipDataType.DInt: + case AbCipDataType.Dt: BinaryPrimitives.WriteInt32LittleEndian(span, Convert.ToInt32(v)); break; + case AbCipDataType.UDInt: BinaryPrimitives.WriteUInt32LittleEndian(span, Convert.ToUInt32(v)); break; + case AbCipDataType.LInt: BinaryPrimitives.WriteInt64LittleEndian(span, Convert.ToInt64(v)); break; + case AbCipDataType.ULInt: BinaryPrimitives.WriteUInt64LittleEndian(span, Convert.ToUInt64(v)); break; + case AbCipDataType.Real: BinaryPrimitives.WriteSingleLittleEndian(span, Convert.ToSingle(v)); break; + case AbCipDataType.LReal: BinaryPrimitives.WriteDoubleLittleEndian(span, Convert.ToDouble(v)); break; + } + } + return buffer; + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs index 9f1d4fec..6be5b374 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriver.cs @@ -27,7 +27,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip; /// handles GC-collected tags. /// public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable, - IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable + IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IAsyncDisposable, 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 absent dataType (see ), a /// present-but-invalid dataType (the runtime enum read is STRICT, so a - /// typo rejects the tag), or an out-of-range stringLength on a String tag. Such a tag + /// typo rejects the tag), an out-of-range stringLength on a String tag, or a shape + /// refuses (an unparseable address, a wrong access width, a mis-typed + /// Timer/Counter, an unsupported array). Such a tag /// is skipped by the driver and reads BadNodeIdUnknown forever. Empty when the mapper accepts /// the blob, when it is blank, or when it is not an equipment-tag object. Never throws. /// @@ -184,7 +210,13 @@ public static class S7TagDefinitionFactory // The accept gate: whatever the driver reads draws nothing. The RAW reference is handed to the // mapper — the driver reads the stored blob, not a trimmed copy of it. - if (FromTagConfig(reference, InspectionRawPath, out _)) return warnings; + if (FromTagConfig(reference, InspectionRawPath, out _, out var shapeRejection)) return warnings; + + if (shapeRejection is not null) + { + warnings.Add(shapeRejection + " The driver will skip this tag and it will read BadNodeIdUnknown."); + return warnings; + } try { diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagShapeRules.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagShapeRules.cs new file mode 100644 index 00000000..17a9bf48 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7.Contracts/S7TagShapeRules.cs @@ -0,0 +1,149 @@ +namespace ZB.MOM.WW.OtOpcUa.Driver.S7; + +/// +/// The single authority on which S7 tag shapes the driver can serve. Consulted by +/// , so the AdminUI editor, the deploy gate +/// () and the driver's own init all refuse the same +/// tags with the same words. +/// +/// +/// +/// These rules used to be init-time guards in S7Driver that THREW. One mis-authored tag +/// (DB1.DBZ0, DB1.DBW0 typed Float64, T5 typed Int32) faulted the +/// whole driver, and the actor retried that same init every 10 s forever, darkening every tag on +/// the PLC. The editor and the deploy gate checked none of it, and a CSV import or API blob +/// bypasses the editor entirely. A rejected tag is now skipped on its own (it reads +/// BadNodeIdUnknown) and the deploy gate warns about it. +/// +/// +/// Every rule rejects a tag that could never be served correctly. None of them can newly break +/// a tag that used to work: each one either faulted init, or failed (or silently mis-decoded) +/// every read of that tag. +/// +/// +public static class S7TagShapeRules +{ + /// + /// S7 data types addressed by START BYTE (the B suffix) rather than by an access-width + /// suffix. The driver decodes them from a contiguous byte buffer, so a wide tag authored against a + /// non-byte suffix (DBW/DBD/...) would mis-frame its value. + /// + private static readonly HashSet ByteAnchoredDataTypes = + [ + S7DataType.Int64, + S7DataType.UInt64, + S7DataType.Float64, + S7DataType.String, + S7DataType.DateTime, + S7DataType.WString, + S7DataType.Char, + S7DataType.WChar, + S7DataType.Dtl, + S7DataType.Date, + S7DataType.Tod, + S7DataType.Time, + S7DataType.S5Time, + ]; + + /// True when is a wide/structured type read from a byte-anchored block. + /// The declared data type. + /// for the byte-anchored types. + public static bool IsByteAnchored(S7DataType dataType) => ByteAnchoredDataTypes.Contains(dataType); + + /// + /// Judges one tag definition. Returns when the driver can serve it, or + /// the operator-facing reason it cannot. + /// + /// The mapped tag definition. + /// The parsed address when this returns . + /// The rejection reason, or when the tag is servable. + public static string? Validate(S7TagDefinition def, out S7ParsedAddress parsed) + { + ArgumentNullException.ThrowIfNull(def); + parsed = default; + + S7ParsedAddress addr; + try + { + addr = S7AddressParser.Parse(def.Address); + } + catch (FormatException ex) + { + return $"S7 address '{def.Address}' does not parse: {ex.Message}"; + } + + var isWide = IsByteAnchored(def.DataType); + var isArray = def.ArrayCount is >= 1; + + // Wide-type arrays have no decoder. + if (isWide && isArray) + return $"S7 {def.DataType} arrays are not supported. Use a scalar {def.DataType} tag, or an " + + "Int16/UInt16/Int32/UInt32/Float32/Byte/Bool element type for an array."; + + // Timer / Counter type constraints: a timer decodes to Float64 seconds, a counter to an Int32 count. + if (addr.Area is S7Area.Timer && def.DataType != S7DataType.Float64) + return $"S7 Timer address '{def.Address}' is typed {def.DataType}. Timer tags must be " + + "DataType=Float64 (decoded to seconds, read-only)."; + if (addr.Area is S7Area.Counter && def.DataType != S7DataType.Int32) + return $"S7 Counter address '{def.Address}' is typed {def.DataType}. Counter tags must be " + + "DataType=Int32 (decoded to a count, read-only)."; + if (addr.Area is S7Area.Timer or S7Area.Counter) + { + if (isArray) + return $"S7 Timer/Counter address '{def.Address}' cannot be an array."; + if (def.Writable) + return $"S7 Timer/Counter address '{def.Address}' is declared writable. Timer/Counter tags " + + "are read-only; set Writable=false."; + parsed = addr; + return null; + } + + // Wide/structured scalars on DB/M/I/Q must be byte-anchored (DBB/MB/IB/QB). + if (isWide) + { + if (addr.Size != S7Size.Byte) + return $"S7 {def.DataType} at '{def.Address}' is not byte-addressed. Wide/structured types " + + "must be byte-addressed (DBB/MB/IB/QB), e.g. 'DB1.DBB0', 'MB4'."; + parsed = addr; + return null; + } + + // Narrow types: the declared type must match the address's access width, or every read fails + // (or decodes garbage) with a type mismatch. + var required = def.DataType switch + { + S7DataType.Bool => S7Size.Bit, + S7DataType.Byte => S7Size.Byte, + S7DataType.Int16 or S7DataType.UInt16 => S7Size.Word, + _ => S7Size.DWord, // Int32 / UInt32 / Float32 + }; + if (addr.Size != required) + return $"S7 {def.DataType} at '{def.Address}' has the wrong access width ({addr.Size}). " + + $"A {def.DataType} tag needs a {SuffixHint(required)} address."; + + if (isArray && def.DataType == S7DataType.Bool) + { + // Element i of a Bool array is bit 0 of byte (start + i): the array is byte-granular. A + // non-zero bit offset used to be silently ignored (DB1.DBX0.3 returned bit 0 of bytes 0..n). + if (addr.BitOffset != 0) + return $"S7 Bool array at '{def.Address}' starts at bit {addr.BitOffset}. A Bool array reads " + + "one byte per element (bit 0 of each byte), so it must start at bit 0 (e.g. 'DB1.DBX0.0')."; + // A byte-granular write would overwrite the 7 neighbouring bits of every byte it touches. + if (def.Writable) + return $"S7 Bool array at '{def.Address}' is declared writable. Bool array writes are not " + + "supported (a byte-granular write would clobber neighbouring bits); set Writable=false " + + "or write the bits as individual scalar Bool tags."; + } + + parsed = addr; + return null; + } + + private static string SuffixHint(S7Size size) => size switch + { + S7Size.Bit => "bit (DBX/M/I/Q n.b)", + S7Size.Byte => "byte (DBB/MB/IB/QB)", + S7Size.Word => "word (DBW/MW/IW/QW)", + _ => "double-word (DBD/MD/ID/QD)", + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.S7/S7Driver.cs index 8659565a..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.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor index 9db2085a..81ec9d9c 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor @@ -28,6 +28,13 @@
Which protocol stack talks to the controller. Default omits the setting (libplctag). Native is opt-in: wire-proven only for N-file reads/writes and F8 reads. Takes effect on the next deploy.
+
+
+ + +
+
Read nearby scalar tags on one N/B/F/L file as a single PCCC request. A block the PLC refuses falls back to per-tag reads automatically. Default on.
+
@@ -97,7 +104,7 @@ /// JSON paths this form edits; everything else in the stored config is preserved. internal static readonly string[] OwnedPaths = [ - "transport", "probeTimeoutSeconds", + "transport", "probeTimeoutSeconds", "coalesceReads", "probe.enabled", "probe.probeAddress", ]; @@ -163,6 +170,7 @@ // PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin // every edited driver to it. public AbLegacyTransport? Transport { get; set; } + public bool CoalesceReads { get; set; } = true; // Probe public bool ProbeEnabled { get; set; } = true; @@ -200,6 +208,7 @@ _loadedProbeTimeout = o.Probe.Timeout, TimeoutSeconds = (int)o.Timeout.TotalSeconds, Transport = o.Transport, + CoalesceReads = o.CoalesceReads ?? 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. --> + +