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/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.FOCAS.Cli/Commands/ProbeCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs
index d2d46458..9c31bffe 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ProbeCommand.cs
@@ -28,6 +28,7 @@ public sealed class ProbeCommand : FocasCommandBase
{
ConfigureLogging();
ValidateOptions();
+ ValidateDataType(Address, DataType);
var ct = console.RegisterCancellationHandler();
var probeTag = new FocasTagDefinition(
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs
index df042344..9218a2c9 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/ReadCommand.cs
@@ -19,7 +19,7 @@ public sealed class ReadCommand : FocasCommandBase
/// Gets the data type to interpret the address as.
[CommandOption("type", 't', Description =
- "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
+ "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16). String only on FIXED: paths.")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
///
@@ -28,6 +28,7 @@ public sealed class ReadCommand : FocasCommandBase
ConfigureLogging();
// Validate numeric option ranges before any driver work.
ValidateOptions();
+ ValidateDataType(Address, DataType);
var ct = console.RegisterCancellationHandler();
var tagName = SynthesiseTagName(Address, DataType);
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs
index 1a3cb5f4..ee126fc0 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/SubscribeCommand.cs
@@ -18,7 +18,7 @@ public sealed class SubscribeCommand : FocasCommandBase
/// Gets the data type of the address.
[CommandOption("type", 't', Description =
- "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
+ "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16). String only on FIXED: paths.")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
/// Gets the polling interval in milliseconds.
@@ -33,6 +33,7 @@ public sealed class SubscribeCommand : FocasCommandBase
// --interval-ms) before any driver work so a zero/negative interval surfaces as a
// clean CommandException rather than a tight-spinning poll loop.
ValidateOptions(IntervalMs);
+ ValidateDataType(Address, DataType);
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs
index bd383041..22cfb431 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/Commands/WriteCommand.cs
@@ -20,7 +20,7 @@ public sealed class WriteCommand : FocasCommandBase
/// Gets the data type of the value to write.
[CommandOption("type", 't', Description =
- "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16).")]
+ "Bit / Byte / Int16 / Int32 / Float32 / Float64 / String (default Int16). String only on FIXED: paths.")]
public FocasDataType DataType { get; init; } = FocasDataType.Int16;
/// Gets the value to write.
@@ -37,6 +37,7 @@ public sealed class WriteCommand : FocasCommandBase
// port/timeout surfaces as a clean CommandException rather than an opaque
// downstream exception.
ValidateOptions();
+ ValidateDataType(Address, DataType);
var ct = console.RegisterCancellationHandler();
var tagName = ReadCommand.SynthesiseTagName(Address, DataType);
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
index 62965d52..e57ce8bf 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli/FocasCommandBase.cs
@@ -89,4 +89,17 @@ public abstract class FocasCommandBase : DriverCommandBase
throw new CliFx.Exceptions.CommandException(
$"--interval-ms must be positive (got {iv}).");
}
+
+ ///
+ /// Refuses an --address / --type pair the driver cannot serve (String on a PMC / PARAM / MACRO
+ /// address — the driver skips such a tag, so the command would only ever print BadNodeIdUnknown). Same
+ /// rule as the driver and the AdminUI editor: .
+ ///
+ /// The FOCAS address.
+ /// The data type.
+ protected static void ValidateDataType(string address, FocasDataType type)
+ {
+ if (FocasTagDefinitionFactory.DescribeDataTypeMismatch(type, address) is { } error)
+ throw new CliFx.Exceptions.CommandException(error);
+ }
}
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs
index 96fe1d87..4603825d 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli/Commands/ReadCommand.cs
@@ -25,6 +25,11 @@ public sealed class ReadCommand : TwinCATTagCommandBase
"String / WString / Time / Date / DateTime / TimeOfDay (default DInt).")]
public TwinCATDataType DataType { get; init; } = TwinCATDataType.DInt;
+ /// Gets the element count for an array read; null reads a scalar.
+ [CommandOption("array-length", Description =
+ "Read the symbol as a 1-D array of this many --type elements (the leading sub-range when shorter).")]
+ public int? ArrayLength { get; init; }
+
///
public override async ValueTask ExecuteAsync(IConsole console)
{
@@ -38,7 +43,8 @@ public sealed class ReadCommand : TwinCATTagCommandBase
DeviceHostAddress: Gateway,
SymbolPath: SymbolPath,
DataType: DataType,
- Writable: false);
+ Writable: false,
+ ArrayLength: ArrayLength);
var options = BuildOptions([tag]);
await using var driver = new TwinCATDriver(options, DriverInstanceId);
@@ -47,6 +53,9 @@ public sealed class ReadCommand : TwinCATTagCommandBase
await driver.InitializeAsync("{}", ct);
var snapshot = await driver.ReadAsync([tagName], ct);
await console.Output.WriteLineAsync(SnapshotFormatter.Format(SymbolPath, snapshot[0]));
+ if (snapshot[0].Value is Array elements)
+ await console.Output.WriteLineAsync("Elements: [" + string.Join(", ",
+ elements.Cast
///
public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable,
- IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable
+ IHostConnectivityProbe, IPerCallHostResolver, IAlarmSource, ISkippedTagReporter, IAsyncDisposable, IDisposable
{
/// Authored tags this driver could not build into its poll table.
private readonly SkippedTagLedger _skippedTags = new();
@@ -98,12 +98,21 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly PollGroupEngine _poll;
// Devices keyed by host address — the primary registry (GetDeviceState, discovery, probe, diagnostics).
- private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase);
+ // BUILD-AND-SWAP, never mutated after publish: the retained poll engine, the alarm projection, discovery and
+ // the actor's GetHostStatuses pull all read it while a re-init / shutdown runs. Each teardown takes the table
+ // it replaces, so concurrent teardowns (ShutdownAsync racing DisposeAsync) own disjoint device states.
+ private volatile Dictionary _devices = new(StringComparer.OrdinalIgnoreCase);
// Tag-routing index: a tag's device routing key (DeviceName ?? HostAddress, plus the HostAddress alias)
// → its DeviceState. A tag resolves to its device by DeviceName-or-host so multi-device routing keys on
// the RawPath's device segment. The HostAddress alias is deliberate (Client CLI + tests route by host);
- // _options.Devices is already the merged per-device DeviceConfig.
- private readonly Dictionary _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase);
+ // _options.Devices is already the merged per-device DeviceConfig. Swapped with _devices, same rule.
+ private volatile Dictionary _devicesByRoutingKey = new(StringComparer.OrdinalIgnoreCase);
+
+ // Serialises table publish/retire. _lifecycleGeneration is bumped by every retire (an init's own release, a
+ // shutdown, a dispose); an init publishes only if no retire happened after its own, so an abandoned init that a
+ // shutdown overtook can never resurrect devices or probe loops after that shutdown returned.
+ private readonly object _lifecycleLock = new();
+ private int _lifecycleGeneration;
// v3 authored RawPath → definition table (parents + fanned-out UDT members), keyed case-sensitive
// ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
private volatile Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
@@ -133,7 +142,25 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// when a definition exists for .
internal bool TryGetTagDefinition(string rawPath, out AbCipTagDefinition definition) =>
_tagsByRawPath.TryGetValue(rawPath, out definition!);
- private DriverHealth _health = new(DriverState.Unknown, null, null);
+ // volatile: written from the read batch, the write path, the poll engine's error hook and the lifecycle, and
+ // read from the actor thread (GetHealth). Read-modify-write updates go through UpdateHealth (CAS) so a
+ // concurrent update is never lost.
+ private volatile DriverHealth _health = new(DriverState.Unknown, null, null);
+
+ /// Atomically replaces with applied to its current value.
+ /// Computes the new health from the current one.
+ /// The health instance this call installed.
+ private DriverHealth UpdateHealth(Func update)
+ {
+ while (true)
+ {
+ var current = _health;
+ var next = update(current);
+#pragma warning disable CS0420 // a volatile field passed by ref to Interlocked keeps its full-fence semantics
+ if (ReferenceEquals(Interlocked.CompareExchange(ref _health, next, current), current)) return next;
+#pragma warning restore CS0420
+ }
+ }
/// Occurs when a subscribed tag's value changes.
public event EventHandler? OnDataChange;
@@ -240,6 +267,21 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_logger.LogWarning(message, args);
}
+ // Tags whose device-refusal Warning has already been logged, per operation ("read"/"write"/…): a refused tag is
+ // polled (or retried) forever, so the Warning is once per tag per operation per driver instance.
+ private readonly System.Collections.Concurrent.ConcurrentDictionary _refusalWarned =
+ new(StringComparer.OrdinalIgnoreCase);
+
+ /// Logs a device-refusal Warning the first time is refused for .
+ private void WarnRefusalOnce(string tagName, string operation, string reference, string device, string reason)
+ {
+ if (_refusalWarned.TryAdd($"{operation}\0{tagName}", 0))
+ _logger.LogWarning(
+ "AbCip {Operation} of {Tag} on device {Device} refused by the controller: {Reason}; reporting it Bad " +
+ "for this tag only — the device and its other tags are unaffected (logged once per tag)",
+ operation, reference, device, reason);
+ }
+
// Tags whose malformed-name Warning has already been logged — polled forever, warned once.
private readonly System.Collections.Concurrent.ConcurrentDictionary _configErrorWarned =
new(StringComparer.OrdinalIgnoreCase);
@@ -270,9 +312,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
internal void HandlePollError(Exception ex)
{
_logger.LogWarning(ex, "AbCip poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
- var current = _health;
- if (current.State != DriverState.Faulted)
- _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message);
+ UpdateHealth(current => current.State == DriverState.Faulted
+ ? current
+ : new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message));
}
///
@@ -364,48 +406,58 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
- // allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + handles before
- // the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
- await ReleaseDeviceStatesAsync(cancellationToken).ConfigureAwait(false);
- _health = new DriverHealth(DriverState.Initializing, null, null);
+ // allows it, and a reconnect retry can overlap a running attempt). Retire the previous device states' probe
+ // loops + handles before building new ones. Deliberately NOT ShutdownAsync: that also drops _poll and the
+ // subscriptions. The generation this release produced is what the publish below is checked against.
+ var generation = await ReleaseDeviceStatesAsync().ConfigureAwait(false);
+ lock (_lifecycleLock)
+ {
+ if (generation == _lifecycleGeneration)
+ _health = new DriverHealth(DriverState.Initializing, null, null);
+ }
_logger.LogInformation(
"AbCip driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
+ Dictionary? builtDevices = null;
try
{
+ var options = _options;
+ var adoptedConfig = false;
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
var parsed = AbCipDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
if (parsed.Devices.Count > 0 || parsed.RawTags.Count > 0)
{
- _options = parsed;
- _alarmProjection = new AbCipAlarmProjection(this, _options.AlarmPollInterval, _logger);
+ options = parsed;
+ adoptedConfig = true;
}
}
- foreach (var device in _options.Devices)
+ builtDevices = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var builtRoutes = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ foreach (var device in options.Devices)
{
var addr = AbCipHostAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"AbCip device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
var profile = AbCipPlcFamilyProfile.ForFamily(device.PlcFamily);
var state = new DeviceState(addr, device, profile);
- _devices[device.HostAddress] = state;
+ builtDevices[device.HostAddress] = state;
// Route index: a tag reaches this device by matching its DeviceName routing key OR its
// host address. Devices with no explicit DeviceName are identified purely by host today.
- _devicesByRoutingKey[device.HostAddress] = state;
+ builtRoutes[device.HostAddress] = state;
if (!string.IsNullOrEmpty(device.DeviceName))
- _devicesByRoutingKey[device.DeviceName] = state;
+ builtRoutes[device.DeviceName] = state;
}
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig blob
// is mapped by the pure factory; the entry's DeviceName (routing key) + WriteIdempotent flag are
// threaded onto the def (they live on the RawTagEntry, not inside the blob). A mapper miss is
// skipped (logged), never thrown — a bad tag must not fail the whole driver init.
- _skippedTags.BeginRebuild(_options.RawTags.Count);
+ _skippedTags.BeginRebuild(options.RawTags.Count);
var builtTagsByRawPath = new Dictionary(StringComparer.Ordinal);
var builtDeclaredTags = new List();
- foreach (var entry in _options.RawTags)
+ foreach (var entry in options.RawTags)
{
if (!AbCipTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var mapped))
{
@@ -457,24 +509,43 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
}
- // Publish the finished tables by reference swap — readers see the old or the new, never a half-built one.
- _tagsByRawPath = builtTagsByRawPath;
- _declaredTags = builtDeclaredTags;
- _skippedTags.Commit();
- // Probe loops — one per device when enabled + a ProbeTagPath is configured.
- if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeTagPath))
+ // Publish everything under the lifecycle lock, and only if no shutdown / other init retired the tables
+ // after this init's own release — readers see the old tables or the new ones, never a half-built one,
+ // and an overtaken init leaves nothing running behind the shutdown that overtook it.
+ lock (_lifecycleLock)
{
- foreach (var state in _devices.Values)
+ if (generation != _lifecycleGeneration)
+ throw new AbCipInitializationSupersededException(
+ $"AbCip driver {_driverInstanceId} initialization was superseded by a shutdown or a newer initialization.");
+
+ _options = options;
+ if (adoptedConfig)
+ _alarmProjection = new AbCipAlarmProjection(this, options.AlarmPollInterval, _logger);
+ _devices = builtDevices;
+ _devicesByRoutingKey = builtRoutes;
+ _tagsByRawPath = builtTagsByRawPath;
+ _declaredTags = builtDeclaredTags;
+ _skippedTags.Commit();
+
+ // Probe loops — one per device when enabled + a ProbeTagPath is configured. Started inside the lock so
+ // a retire that follows this publish always sees (and joins) them.
+ if (options.Probe.Enabled && !string.IsNullOrWhiteSpace(options.Probe.ProbeTagPath))
{
- state.ProbeCts = new CancellationTokenSource();
- var ct = state.ProbeCts.Token;
- // Keep the loop Task so ShutdownAsync can await its clean exit before
- // disposing the CTS / handles the loop is still using.
- state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
+ foreach (var state in builtDevices.Values)
+ {
+ state.ProbeCts = new CancellationTokenSource();
+ var ct = state.ProbeCts.Token;
+ // Keep the loop Task so a teardown can await its clean exit before
+ // disposing the CTS / handles the loop is still using.
+ state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
+ }
}
+ _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
- else if (_options.Probe.Enabled && _devices.Count > 0)
+ builtDevices = null; // published: owned by the next retire now
+
+ if (options.Probe.Enabled && string.IsNullOrWhiteSpace(options.Probe.ProbeTagPath) && options.Devices.Count > 0)
{
// Probe is Enabled but no ProbeTagPath is configured. Without a
// tag path the loop has nothing to read, so HostState would stay Unknown forever
@@ -486,51 +557,84 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
"as Unknown until a ProbeTagPath is set or Probe.Enabled is set to false.",
_driverInstanceId);
}
- _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
{
- _health = new DriverHealth(DriverState.Faulted, null, ex.Message);
+ // Never-published states hold no probe loop and no runtime yet; dispose for symmetry.
+ if (builtDevices is not null)
+ foreach (var state in builtDevices.Values) state.Retire();
+ if (ex is AbCipInitializationSupersededException)
+ {
+ _logger.LogInformation("AbCip driver {DriverInstanceId} initialization abandoned: {Reason}",
+ _driverInstanceId, ex.Message);
+ throw;
+ }
+ lock (_lifecycleLock)
+ {
+ // A superseded init must not overwrite the health of whatever superseded it.
+ if (generation == _lifecycleGeneration)
+ _health = new DriverHealth(DriverState.Faulted, null, ex.Message);
+ }
_logger.LogError(ex, "AbCip driver {DriverInstanceId} failed to initialize", _driverInstanceId);
throw;
}
}
+ /// Bound on joining the retired probe loops — matches the fleet's "≤ 5 s join" teardown rule.
+ private static readonly TimeSpan ProbeJoinTimeout = TimeSpan.FromSeconds(5);
+
///
- /// Cancels, joins (bounded) and disposes every existing device state's probe loop + handles, then
- /// clears the device/route tables. Device part of only.
+ /// Retires the published device states: swaps empty device/route/tag tables in under the lifecycle lock
+ /// (bumping the generation), then cancels, joins (bounded, independent of any caller token — a cancelled
+ /// caller must not dispose handles under a probe loop that is still running) and disposes the states it took.
+ /// Concurrent callers each take a disjoint table, so every state is torn down exactly once. The device part of
+ /// and the first step of .
///
- private async Task ReleaseDeviceStatesAsync(CancellationToken cancellationToken)
+ /// The lifecycle generation this retire produced.
+ private async Task ReleaseDeviceStatesAsync()
{
- // The projection is replaced (not reused) when a config body is adopted; dispose the old one so its
- // poll loop does not outlive it and raise duplicate alarm events through this driver.
- await _alarmProjection.DisposeAsync().ConfigureAwait(false);
- foreach (var state in _devices.Values)
+ Dictionary retired;
+ AbCipAlarmProjection projection;
+ int generation;
+ lock (_lifecycleLock)
+ {
+ generation = ++_lifecycleGeneration;
+ retired = _devices;
+ _devices = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ _devicesByRoutingKey = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ // Rebuilt wholesale by Initialize; swap, never Clear(): the retained poll engine / discovery may be enumerating.
+ _tagsByRawPath = new(StringComparer.Ordinal);
+ _declaredTags = new();
+ projection = _alarmProjection;
+ }
+
+ // The projection is replaced (not reused) when a config body is adopted; dispose it so its poll loop does
+ // not outlive it and raise duplicate alarm events through this driver. Idempotent, and reusable afterwards.
+ await projection.DisposeAsync().ConfigureAwait(false);
+
+ foreach (var state in retired.Values)
{
try { state.ProbeCts?.Cancel(); } catch (ObjectDisposedException) { }
}
- foreach (var state in _devices.Values)
+ var loops = retired.Values.Select(s => s.ProbeTask).OfType().ToArray();
+ if (loops.Length > 0)
{
- var probeTask = state.ProbeTask;
- if (probeTask is null) continue;
- // Own bounded timeout, independent of the caller token: cancelling init must not abort the join
- // and dispose handles under a probe loop that is still running.
- try { await probeTask.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); }
- catch (TimeoutException) { }
- catch (OperationCanceledException) { }
+ try { await Task.WhenAll(loops).WaitAsync(ProbeJoinTimeout).ConfigureAwait(false); }
+ catch (TimeoutException)
+ {
+ _logger.LogWarning("AbCip driver {DriverInstanceId}: a probe loop did not exit within {Timeout}; disposing anyway",
+ _driverInstanceId, ProbeJoinTimeout);
+ }
+ catch (Exception) { /* a loop never faults by design; nothing to add here */ }
}
- foreach (var state in _devices.Values)
+ foreach (var state in retired.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
- state.DisposeHandles();
+ state.Retire();
}
- _devices.Clear();
- _devicesByRoutingKey.Clear();
- // Rebuilt wholesale by Initialize; stale entries would trip its member fan-out collision check.
- _tagsByRawPath = new(StringComparer.Ordinal); // swap, never Clear(): the retained poll engine / discovery may be enumerating
- _declaredTags = new();
+ return generation;
}
///
@@ -541,46 +645,23 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
///
+ ///
+ /// A complete teardown: the poll engine (and every subscription), the alarm projection, every probe loop
+ /// (joined ≤ 5 s) and every runtime handle. Safe to run concurrently with and with
+ /// an it overtakes (that init then publishes nothing).
+ ///
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
- await _alarmProjection.DisposeAsync().ConfigureAwait(false);
await _poll.DisposeAsync().ConfigureAwait(false);
-
- // Phase 1: signal every probe loop to stop.
- foreach (var state in _devices.Values)
- {
- try { state.ProbeCts?.Cancel(); } catch (ObjectDisposedException) { }
- }
-
- // Phase 2: wait for each probe loop to observe cancellation and exit. The loop never
- // throws on cancellation (it catches OperationCanceledException internally), but guard
- // anyway so one slow device can't wedge the whole shutdown.
- foreach (var state in _devices.Values)
- {
- var probeTask = state.ProbeTask;
- if (probeTask is null) continue;
- try
- {
- await probeTask.WaitAsync(TimeSpan.FromSeconds(10), cancellationToken).ConfigureAwait(false);
- }
- catch (TimeoutException) { }
- catch (OperationCanceledException) { }
- }
-
- // Phase 3: now the loops are gone, dispose the CTS + native handles with no live reader.
- foreach (var state in _devices.Values)
- {
- state.ProbeCts?.Dispose();
- state.ProbeCts = null;
- state.ProbeTask = null;
- state.DisposeHandles();
- }
- _devices.Clear();
- _devicesByRoutingKey.Clear();
- _tagsByRawPath = new(StringComparer.Ordinal);
- _declaredTags = new();
+ var generation = await ReleaseDeviceStatesAsync().ConfigureAwait(false);
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
- _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
+ lock (_lifecycleLock)
+ {
+ // M4: a newer InitializeAsync that published while this shutdown was still joining its probe loops owns the
+ // health now — stamping Unknown over its Healthy would misreport a live driver.
+ if (generation == _lifecycleGeneration)
+ UpdateHealth(h => new DriverHealth(DriverState.Unknown, h.LastSuccessfulRead, null));
+ }
}
// ---- ISubscribable (polling overlay via shared engine) ----
@@ -638,12 +719,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
IAbCipTagRuntime? probeRuntime = null;
while (!ct.IsCancellationRequested)
{
- var success = false;
- // The probe's backoff verdict for this tick: true = record success, false = record
- // failure, null = no signal (a read that returned a non-zero status without throwing
- // never recorded either, and still doesn't). Recorded AFTER the try/catch so the
- // per-device connect gate is taken outside a catch block — see below.
+ // The probe's verdicts for this tick, decided SEPARATELY (review 07 H10):
+ // backoffVerdict — true = record success, false = record failure, null = no signal;
+ // hostState — the host transition, null = no evidence either way (leave it).
+ // Recorded AFTER the try/catch so the per-device connect gate is taken outside a catch block.
bool? backoffVerdict = null;
+ HostState? hostState = HostState.Stopped;
try
{
probeRuntime ??= _tagFactory.Create(probeParams);
@@ -655,14 +736,51 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.ProbeInitialized = true;
}
await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
- success = probeRuntime.GetStatus() == 0;
- if (success)
+ var status = probeRuntime.GetStatus();
+ if (status == 0)
+ {
backoffVerdict = true; // Device reachable — reset the data-path window
+ hostState = HostState.Running;
+ }
+ else if (AbCipRefusals.IsDeviceRefusal(status))
+ {
+ // The controller answered, refusing the probe tag: reachable.
+ backoffVerdict = true;
+ hostState = HostState.Running;
+ WarnProbeRefusedOnce(state, ((AbStatus)status).ToString());
+ }
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
break;
}
+ catch (Exception ex) when (AbCipRefusals.IsDeviceRefusal(ex))
+ {
+ // H10: a missing / typo'd / download-deleted probe tag. The controller ANSWERED over a working session,
+ // so this is a config error on the probe tag — not a Forward-Open failure. Recording a failure here
+ // every tick saturated the backoff (5 s ticks vs a 30 s cap ⇒ ShouldAttempt never true) and blacked out
+ // every cold / evicted data tag on a reachable device, indefinitely.
+ backoffVerdict = true;
+ hostState = HostState.Running;
+ WarnProbeRefusedOnce(state, ex.Message);
+ // Keep an initialised runtime (its lease stays up — no Forward Open/Close per tick); one whose
+ // initialisation threw is re-created next tick, as before.
+ if (!state.ProbeInitialized)
+ {
+ try { probeRuntime?.Dispose(); } catch { }
+ probeRuntime = null;
+ }
+ }
+ catch (Exception ace) when (ace is AbCipTagConfigurationException or AbCipLocalRefusalException)
+ {
+ // The probe tag's own name cannot become a request (or the library refused it locally) — it never
+ // reached the device, so it is no evidence either way: no backoff signal, no host transition, one Warning.
+ try { probeRuntime?.Dispose(); } catch { }
+ probeRuntime = null;
+ state.ProbeInitialized = false;
+ hostState = null;
+ WarnProbeRefusedOnce(state, ace.Message);
+ }
catch (Exception ex)
{
// Wire / init error — tear down the probe runtime so the next tick re-creates it.
@@ -674,9 +792,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
try { probeRuntime?.Dispose(); } catch { }
probeRuntime = null;
state.ProbeInitialized = false;
- // Keep the data-path window open — unless the probe tag's own name is malformed
- // — it never reached the device, so it is no evidence either way.
- backoffVerdict = ex is AbCipTagConfigurationException ? null : false;
+ backoffVerdict = false; // the device did not answer — keep the data-path window open
}
// ConnectionBackoff is caller-serialized, and the data path now records under the
@@ -706,7 +822,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
- TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
+ if (hostState is { } host) TransitionDeviceState(state, host);
try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
@@ -715,6 +831,16 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
try { probeRuntime?.Dispose(); } catch { }
}
+ /// Logs, once per device state, that the controller answered but refused the configured probe tag.
+ private void WarnProbeRefusedOnce(DeviceState state, string reason)
+ {
+ if (Interlocked.Exchange(ref state.ProbeRefusalWarned, 1) != 0) return;
+ _logger.LogWarning(
+ "AbCip probe tag {ProbeTag} on device {Device} cannot be read: {Reason}. The controller answered, so the device " +
+ "is treated as reachable; fix Probe.ProbeTagPath for driver {DriverInstanceId} (logged once per device)",
+ _options.Probe.ProbeTagPath, state.Options.HostAddress, reason, _driverInstanceId);
+ }
+
private void TransitionDeviceState(DeviceState state, HostState newState)
{
HostState old;
@@ -776,7 +902,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// round trips overlap, which a strictly sequential await chain made impossible. Results are
// index-addressed, so completion order is irrelevant. Per-item health verdicts are COLLECTED
// and written once after the batch: an inline write to the shared _health field would race.
- var maxConcurrent = Math.Max(1, _options.MaxConcurrentReads);
+ var maxConcurrent = ReadWindow;
var outcomes = new ReadOutcome[itemCount];
using var window = new SemaphoreSlim(maxConcurrent, maxConcurrent);
var tasks = new Task[itemCount];
@@ -791,32 +917,44 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
ApplyBatchHealth(outcomes, now);
return results;
+ // The window slot is taken INSIDE each item, after its runtime exists (P7): a cold create queues on its
+ // device's single-flight connect gate, and holding a slot while queued there let one dead device's
+ // backoff-expiry attempt occupy the whole window for a full Timeout, stalling every other device's reads.
async Task RunItemAsync(int slot)
{
- // Acquire OUTSIDE the try: a wait that throws never acquired, so it must not release.
- await window.WaitAsync(cancellationToken).ConfigureAwait(false);
- try
+ if (slot < groups.Count)
{
- if (slot < groups.Count)
- {
- outcomes[slot] = await ReadGroupAsync(groups[slot], results, now, cancellationToken)
- .ConfigureAwait(false);
- }
- else
- {
- var fb = fallbacks[slot - groups.Count];
- outcomes[slot] = await ReadSingleAsync(
- fb, fullReferences[fb.OriginalIndex], results, now, cancellationToken)
- .ConfigureAwait(false);
- }
+ outcomes[slot] = await ReadGroupAsync(groups[slot], results, now, window, cancellationToken)
+ .ConfigureAwait(false);
}
- finally
+ else
{
- window.Release();
+ var fb = fallbacks[slot - groups.Count];
+ outcomes[slot] = await ReadSingleAsync(
+ fb, fullReferences[fb.OriginalIndex], results, now, window, cancellationToken)
+ .ConfigureAwait(false);
}
}
}
+ ///
+ /// Floor on the read window when the native transport serves this driver (P8). Its runtimes share one
+ /// request scheduler per device that packs whatever is queued when the pump wakes into one Multiple Service
+ /// Packet — bounded by the connection's request AND reply byte budget, so a wide window cannot over-pack. The
+ /// window is therefore the cap on services per round trip: at 8, a 1000-tag controller paid ~125 round trips
+ /// per poll instead of ~10.
+ ///
+ internal const int NativeMinReadWindow = 64;
+
+ ///
+ /// The effective bounded-parallel read window: (clamped
+ /// to ≥ 1), raised to at least when the native tag factory serves the reads.
+ /// libplctag overlaps per-handle round trips only, so it (and any injected factory) keeps the option as is.
+ ///
+ internal int ReadWindow => _tagFactory is NativeAbCipTagFactory
+ ? Math.Max(NativeMinReadWindow, _options.MaxConcurrentReads)
+ : Math.Max(1, _options.MaxConcurrentReads);
+
///
/// One read work item's health verdict. Returned by /
/// instead of written to the shared health field inline, because
@@ -825,11 +963,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// A null means "no health signal" — a config-level miss such as an
/// unknown reference or unknown device, which never touched the wire and never touched health.
///
- private readonly record struct ReadOutcome(DriverState? State, string? Error)
+ private readonly record struct ReadOutcome(DriverState? State, string? Error, bool Answered = false)
{
/// The item produced no health signal at all.
public static ReadOutcome None => new(null, null);
+ ///
+ /// The controller answered with a deterministic refusal: no health STATE signal (a refusal is not a comms
+ /// fault), but proof the device is reachable, so LastSuccessfulRead advances.
+ ///
+ public static ReadOutcome Refused => new(null, null, Answered: true);
+
/// The item read successfully.
public static ReadOutcome Success => new(DriverState.Healthy, null);
@@ -858,10 +1002,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private void ApplyBatchHealth(ReadOnlySpan outcomes, DateTime now)
{
var anySuccess = false;
+ var anyAnswered = false;
DriverState? worst = null;
string? worstError = null;
foreach (var outcome in outcomes)
{
+ if (outcome.Answered) anyAnswered = true;
if (outcome.State is not { } state) continue;
if (state == DriverState.Healthy) anySuccess = true;
if (worst is null || HealthRank(state) > HealthRank(worst.Value))
@@ -871,18 +1017,25 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
- // Nothing reached the wire (every reference was unknown / unroutable) — leave health alone,
- // exactly as the per-item writes did before.
- if (worst is not { } worstState) return;
+ if (worst is not { } worstState)
+ {
+ // No state signal. If a controller answered (a refusal), that still proves it reachable: advance
+ // LastSuccessfulRead and leave the state alone. Otherwise nothing reached the wire (every reference was
+ // unknown / unroutable / misconfigured) — leave health alone entirely.
+ if (anyAnswered)
+ UpdateHealth(current => current with { LastSuccessfulRead = now });
+ return;
+ }
- _health = new DriverHealth(
+ UpdateHealth(current => new DriverHealth(
worstState,
- anySuccess ? now : _health.LastSuccessfulRead,
- worstState == DriverState.Healthy ? null : worstError);
+ anySuccess || anyAnswered ? now : current.LastSuccessfulRead,
+ worstState == DriverState.Healthy ? null : worstError));
}
private async Task ReadSingleAsync(
- AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
+ AbCipUdtReadFallback fb, string reference, DataValueSnapshot[] results, DateTime now,
+ SemaphoreSlim window, CancellationToken ct)
{
if (!_resolver.TryResolve(reference, out var def))
{
@@ -908,87 +1061,112 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
+ // Window slot only now that the runtime exists (see ReadAsync); a wait that throws never acquired.
+ await window.WaitAsync(ct).ConfigureAwait(false);
// Serialise Read → GetStatus → Decode against the shared runtime — the cached libplctag
// Tag handle is not concurrency-safe and GetStatus returns the LAST op's status.
var opLock = device.GetRuntimeLock(def.Name);
- await opLock.WaitAsync(ct).ConfigureAwait(false);
try
{
- await runtime.ReadAsync(ct).ConfigureAwait(false);
-
- var status = runtime.GetStatus();
- if (status == (int)AbStatus.TypeMismatch)
+ await opLock.WaitAsync(ct).ConfigureAwait(false);
+ try
{
- // Final review I4: the controller ANSWERED with a type that disagrees with the authored
- // one — a deterministic config error, not a comms fault. Keep the handle (evicting would
- // re-create it — and, for a sole lease holder, re-Forward-Open — on every poll), leave
- // health alone, warn once per tag.
- results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
- WarnTypeMismatchOnce(def.Name,
- "AbCip read of {Tag} on device {Device}: the controller's type does not match the authored " +
- "{DataType}; reporting BadTypeMismatch until the tag's dataType is corrected (logged once per tag)",
- reference, def.DeviceHostAddress, def.DataType);
- return ReadOutcome.None;
- }
- if (status != 0)
- {
- // Evict the stale handle so the next call re-creates it.
- // A non-zero status can mean the controller dropped the connection or the tag
- // handle became permanently invalid (e.g. after a PLC download). Evicting
- // mirrors the probe loop's recreate-on-failure behaviour.
- EvictRuntime(device, def.Name);
- results[fb.OriginalIndex] = new DataValueSnapshot(null,
- AbCipStatusMapper.MapStatus(status), null, now);
- _logger.LogWarning(
- "AbCip read returned non-zero transport status {Status} for tag {Tag} on device {Device}; " +
- "evicting cached runtime so next call re-creates it",
- status, reference, def.DeviceHostAddress);
- return ReadOutcome.Degraded($"transport status {status} reading {reference}");
- }
+ await runtime.ReadAsync(ct).ConfigureAwait(false);
- var tagPath = AbCipTagPath.TryParse(def.TagPath);
- var bitIndex = tagPath?.BitIndex;
+ var status = runtime.GetStatus();
+ if (status == (int)AbStatus.TypeMismatch)
+ {
+ // Final review I4: the controller ANSWERED with a type that disagrees with the authored
+ // one — a deterministic config error, not a comms fault. Keep the handle (evicting would
+ // re-create it — and, for a sole lease holder, re-Forward-Open — on every poll), leave
+ // health alone, warn once per tag.
+ results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
+ WarnTypeMismatchOnce(def.Name,
+ "AbCip read of {Tag} on device {Device}: the controller's type does not match the authored " +
+ "{DataType}; reporting BadTypeMismatch until the tag's dataType is corrected (logged once per tag)",
+ reference, def.DeviceHostAddress, def.DataType);
+ return ReadOutcome.Refused;
+ }
+ if (AbCipRefusals.IsDeviceRefusal(status))
+ {
+ // The controller answered with a deterministic refusal: per-tag Bad, keep the handle.
+ results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.MapReadStatus((AbStatus)status), null, now);
+ WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ((AbStatus)status).ToString());
+ return ReadOutcome.Refused;
+ }
+ if (status != 0)
+ {
+ // Evict the stale handle so the next call re-creates it.
+ // A non-zero status can mean the controller dropped the connection or the tag
+ // handle became permanently invalid (e.g. after a PLC download). Evicting
+ // mirrors the probe loop's recreate-on-failure behaviour.
+ EvictRuntime(device, def.Name);
+ results[fb.OriginalIndex] = new DataValueSnapshot(null,
+ AbCipStatusMapper.MapStatus(status), null, now);
+ _logger.LogWarning(
+ "AbCip read returned non-zero transport status {Status} for tag {Tag} on device {Device}; " +
+ "evicting cached runtime so next call re-creates it",
+ status, reference, def.DeviceHostAddress);
+ return ReadOutcome.Degraded($"transport status {status} reading {reference}");
+ }
- // libplctag never checks the CIP type the controller returned; it decodes the bytes as
- // the authored type. A scalar whose buffer is not that type's width is mis-typed config
- // (seen live: a REAL authored as INT read Good -17346), so refuse it rather than publish
- // garbage as Good. Same-width mismatches (DINT vs REAL) cannot be seen this way.
- if (!IsArrayTag(def) && bitIndex is null
- && ExpectedScalarWidth(def.DataType) is { } width
- && runtime.GetSize() is var size && size >= 0 && size != width)
- {
- // Same class as the native TypeMismatch above (final review I4): deterministic, device
- // answered — no Degraded, warn once per tag.
- results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
- WarnTypeMismatchOnce(def.Name,
- "AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType (logged once per tag)",
- reference, def.DeviceHostAddress, size, def.DataType, width);
- return ReadOutcome.None;
+ var tagPath = AbCipTagPath.TryParse(def.TagPath);
+ var bitIndex = tagPath?.BitIndex;
+
+ // libplctag never checks the CIP type the controller returned; it decodes the bytes as
+ // the authored type. A scalar whose buffer is not that type's width is mis-typed config
+ // (seen live: a REAL authored as INT read Good -17346), so refuse it rather than publish
+ // garbage as Good. Same-width mismatches (DINT vs REAL) cannot be seen this way.
+ if (!IsArrayTag(def) && bitIndex is null
+ && ExpectedScalarWidth(def.DataType) is { } width
+ && runtime.GetSize() is var size && size >= 0 && size != width)
+ {
+ // Same class as the native TypeMismatch above (final review I4): deterministic, device
+ // answered — no Degraded, warn once per tag.
+ results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
+ WarnTypeMismatchOnce(def.Name,
+ "AbCip read of {Tag} on device {Device} returned {Size} bytes but its authored type {DataType} is {Width} bytes wide; check the tag's dataType (logged once per tag)",
+ reference, def.DeviceHostAddress, size, def.DataType, width);
+ return ReadOutcome.Refused;
+ }
+ // Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an
+ // element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array
+ // (ElementCount 1). Scalar tags keep the single-value path.
+ var value = IsArrayTag(def)
+ ? runtime.DecodeArray(def.DataType, Math.Max(1, def.ElementCount))
+ : runtime.DecodeValue(def.DataType, bitIndex);
+ results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
+ return ReadOutcome.Success;
+ }
+ finally
+ {
+ opLock.Release();
}
- // Review I-1 — an array tag (the EXPLICIT IsArray flag) decodes the whole buffer into an
- // element-typed CLR array (int[]/float[]/bool[]/string[]…), INCLUDING a 1-element array
- // (ElementCount 1). Scalar tags keep the single-value path.
- var value = IsArrayTag(def)
- ? runtime.DecodeArray(def.DataType, Math.Max(1, def.ElementCount))
- : runtime.DecodeValue(def.DataType, bitIndex);
- results[fb.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
- return ReadOutcome.Success;
}
finally
{
- opLock.Release();
+ window.Release();
}
}
catch (OperationCanceledException)
{
throw;
}
+ catch (AbCipLocalRefusalException local)
+ {
+ // #737 I2: refused by the library / driver itself — no I/O, so no proof the device answered: BadNotSupported,
+ // keep the handle, no health signal, LastSuccessfulRead untouched.
+ results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
+ WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, local.Message);
+ return ReadOutcome.None;
+ }
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
- // A deterministic local refusal — not a comms fault: BadNotSupported, keep the handle, no
- // health signal.
+ // The DEVICE refused (CIP 0x08, or a decode refusal of data it returned) — not a comms fault:
+ // BadNotSupported, keep the handle, no state change; it answered, so LastSuccessfulRead advances (M2).
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadNotSupported, null, now);
- return ReadOutcome.None;
+ WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ate.Message);
+ return ReadOutcome.Refused;
}
catch (AbCipTagConfigurationException ace)
{
@@ -1004,6 +1182,22 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadTypeMismatch, null, now);
WarnTypeMismatchOnce(def.Name, "AbCip read of {Tag} on device {Device} refused: {Reason} (logged once per tag)",
reference, def.DeviceHostAddress, ate.Message);
+ return ReadOutcome.Refused;
+ }
+ catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate))
+ {
+ // H1/L4: the controller answered "no" (renamed tag, Arr[999], read-protected …) — a per-tag config
+ // error, not a comms fault. Keep the handle (no evict → no lease / Forward-Open churn), no Degraded,
+ // no backoff (EnsureTagRuntimeAsync already recorded the answer as reachable), warn once per tag.
+ results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.MapReadStatus(ate.Status), null, now);
+ WarnRefusalOnce(def.Name, "read", reference, def.DeviceHostAddress, ate.Message);
+ return ReadOutcome.Refused;
+ }
+ catch (Exception) when (device.Retired)
+ {
+ // A re-init / shutdown retired this device under the read: its handles were disposed by the teardown, not
+ // lost on the wire — no evict (the teardown owns them), no health signal.
+ results[fb.OriginalIndex] = new DataValueSnapshot(null, AbCipStatusMapper.BadCommunicationError, null, now);
return ReadOutcome.None;
}
catch (Exception ex)
@@ -1029,7 +1223,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// per-tag fallback list are unaffected.
///
private async Task ReadGroupAsync(
- AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
+ AbCipUdtReadGroup group, DataValueSnapshot[] results, DateTime now, SemaphoreSlim window, CancellationToken ct)
{
var parent = group.ParentDefinition;
@@ -1043,57 +1237,79 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
var runtime = await EnsureTagRuntimeAsync(device, parent, ct).ConfigureAwait(false);
+ // Window slot only now that the runtime exists (see ReadAsync); a wait that throws never acquired.
+ await window.WaitAsync(ct).ConfigureAwait(false);
// Serialise the whole-UDT Read → GetStatus → per-member DecodeValueAt against the shared
// parent runtime — the members decode from that one handle's buffer.
var opLock = device.GetRuntimeLock(parent.Name);
- await opLock.WaitAsync(ct).ConfigureAwait(false);
try
{
- await runtime.ReadAsync(ct).ConfigureAwait(false);
+ await opLock.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ await runtime.ReadAsync(ct).ConfigureAwait(false);
- var status = runtime.GetStatus();
- if (status == (int)AbStatus.TypeMismatch)
- {
- // Final review I4: deterministic — keep the handle, no health signal, warn once.
- StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
- WarnTypeMismatchOnce(parent.Name,
- "AbCip whole-UDT read of {Parent} on device {Device}: the controller's type does not match " +
- "the authored type; {MemberCount} members report BadTypeMismatch (logged once per tag)",
- group.ParentName, parent.DeviceHostAddress, group.Members.Count);
- return ReadOutcome.None;
- }
- if (status != 0)
- {
- EvictRuntime(device, parent.Name);
- var mapped = AbCipStatusMapper.MapStatus(status);
- StampGroupStatus(group, results, now, mapped);
- _logger.LogWarning(
- "AbCip whole-UDT read returned non-zero transport status {Status} for parent {Parent} " +
- "on device {Device}; {MemberCount} member values stamped with mapped status",
- status, group.ParentName, parent.DeviceHostAddress, group.Members.Count);
- return ReadOutcome.Degraded($"transport status {status} reading UDT {group.ParentName}");
- }
+ var status = runtime.GetStatus();
+ if (status == (int)AbStatus.TypeMismatch)
+ {
+ // Final review I4: deterministic — keep the handle, no health signal, warn once.
+ StampGroupStatus(group, results, now, AbCipStatusMapper.BadTypeMismatch);
+ WarnTypeMismatchOnce(parent.Name,
+ "AbCip whole-UDT read of {Parent} on device {Device}: the controller's type does not match " +
+ "the authored type; {MemberCount} members report BadTypeMismatch (logged once per tag)",
+ group.ParentName, parent.DeviceHostAddress, group.Members.Count);
+ return ReadOutcome.Refused;
+ }
+ if (AbCipRefusals.IsDeviceRefusal(status))
+ {
+ StampGroupStatus(group, results, now, AbCipStatusMapper.MapReadStatus((AbStatus)status));
+ WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, ((AbStatus)status).ToString());
+ return ReadOutcome.Refused;
+ }
+ if (status != 0)
+ {
+ EvictRuntime(device, parent.Name);
+ var mapped = AbCipStatusMapper.MapStatus(status);
+ StampGroupStatus(group, results, now, mapped);
+ _logger.LogWarning(
+ "AbCip whole-UDT read returned non-zero transport status {Status} for parent {Parent} " +
+ "on device {Device}; {MemberCount} member values stamped with mapped status",
+ status, group.ParentName, parent.DeviceHostAddress, group.Members.Count);
+ return ReadOutcome.Degraded($"transport status {status} reading UDT {group.ParentName}");
+ }
- foreach (var member in group.Members)
- {
- var value = runtime.DecodeValueAt(member.Definition.DataType, member.Offset, bitIndex: null);
- results[member.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
+ foreach (var member in group.Members)
+ {
+ var value = runtime.DecodeValueAt(member.Definition.DataType, member.Offset, bitIndex: null);
+ results[member.OriginalIndex] = new DataValueSnapshot(value, AbCipStatusMapper.Good, now, now);
+ }
+ return ReadOutcome.Success;
+ }
+ finally
+ {
+ opLock.Release();
}
- return ReadOutcome.Success;
}
finally
{
- opLock.Release();
+ window.Release();
}
}
catch (OperationCanceledException)
{
throw;
}
+ catch (AbCipLocalRefusalException local)
+ {
+ StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
+ WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, local.Message);
+ return ReadOutcome.None;
+ }
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
StampGroupStatus(group, results, now, AbCipStatusMapper.BadNotSupported);
- return ReadOutcome.None;
+ WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, ate.Message);
+ return ReadOutcome.Refused;
}
catch (AbCipTagConfigurationException ace)
{
@@ -1108,6 +1324,17 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
WarnTypeMismatchOnce(parent.Name,
"AbCip whole-UDT read of {Parent} on device {Device} refused: {Reason} (logged once per tag)",
group.ParentName, parent.DeviceHostAddress, ate.Message);
+ return ReadOutcome.Refused;
+ }
+ catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate.Status))
+ {
+ StampGroupStatus(group, results, now, AbCipStatusMapper.MapReadStatus(ate.Status));
+ WarnRefusalOnce(parent.Name, "whole-UDT read", group.ParentName, parent.DeviceHostAddress, ate.Message);
+ return ReadOutcome.Refused;
+ }
+ catch (Exception) when (device.Retired)
+ {
+ StampGroupStatus(group, results, now, AbCipStatusMapper.BadCommunicationError);
return ReadOutcome.None;
}
catch (Exception ex)
@@ -1170,6 +1397,44 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
continue;
}
+ string? rmwParent = null;
+ // U10: an array tag writes the WHOLE array. Shape is checked before any runtime exists, so a refused
+ // write creates, reads and sends nothing; a refusal is not a fault — no health change.
+ object?[]? arrayElements = null;
+ if (IsArrayTag(def))
+ {
+ var count = Math.Max(1, def.ElementCount);
+ uint? shapeRefusal = null;
+ string reason = "";
+ if (AbCipArrayCodec.ElementWidth(def.DataType) is null)
+ {
+ shapeRefusal = AbCipStatusMapper.BadNotSupported;
+ reason = $"{def.DataType} arrays cannot be written element-wise (BOOL arrays are bit-packed DWORDs, STRING/structure elements are structures)";
+ }
+ else if (AbCipArrayCodec.Elements(w.Value) is not { } elements)
+ {
+ shapeRefusal = AbCipStatusMapper.BadTypeMismatch;
+ reason = $"the value is not an array ({w.Value?.GetType().Name ?? "null"})";
+ }
+ else if (elements.Length != count)
+ {
+ shapeRefusal = AbCipStatusMapper.BadOutOfRange;
+ reason = $"{elements.Length} element(s) written to a {count}-element array";
+ }
+ else
+ {
+ arrayElements = elements;
+ }
+ if (shapeRefusal is { } refusedShape)
+ {
+ results[i] = new WriteResult(refusedShape);
+ WarnWriteRefusedOnce(def.Name,
+ "AbCip array write to {Tag} on device {Device} refused: {Reason} (logged once per tag)",
+ w.FullReference, def.DeviceHostAddress, reason);
+ continue;
+ }
+ }
+
try
{
var parsedPath = AbCipTagPath.TryParse(def.TagPath);
@@ -1180,11 +1445,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// losing one another's update.
if (def.DataType == AbCipDataType.Bool && parsedPath?.BitIndex is int bit)
{
+ rmwParent = (parsedPath with { BitIndex = null }).ToLibplctagName();
results[i] = new WriteResult(
await WriteBitInDIntAsync(device, parsedPath, bit, w.Value, cancellationToken)
.ConfigureAwait(false));
if (results[i].StatusCode == AbCipStatusMapper.Good)
- _health = new DriverHealth(DriverState.Healthy, now, null);
+ RecordWriteAnswered(now);
continue;
}
@@ -1206,10 +1472,13 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// BOOL-array DWORD wrote all 32 bits. Discovered UDT members are where a mis-authored type
// is most likely (browse-commit fills dataType from the template, an operator may retype).
size = runtime.GetSize();
- refusal = WriteWidthRefusal(def, parsedPath?.BitIndex, size, runtimeTypesBool: runtime is NativeAbCipTagRuntime);
+ refusal = arrayElements is not null
+ ? ArrayWriteWidthRefusal(def, size)
+ : WriteWidthRefusal(def, parsedPath?.BitIndex, size, runtimeTypesBool: runtime is NativeAbCipTagRuntime);
if (refusal is null)
{
- runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
+ if (arrayElements is not null) runtime.EncodeArray(def.DataType, arrayElements);
+ else runtime.EncodeValue(def.DataType, parsedPath?.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
status = runtime.GetStatus();
}
@@ -1229,20 +1498,36 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[i] = new WriteResult(refused);
WarnTypeMismatchOnce(def.Name,
"AbCip write to {Tag} on device {Device} refused before sending: the controller holds {Size} byte(s) there but the authored {DataType} encodes {Width} (logged once per tag)",
- w.FullReference, def.DeviceHostAddress, size, def.DataType, ExpectedScalarWriteWidth(def.DataType));
+ w.FullReference, def.DeviceHostAddress, size, def.DataType,
+ arrayElements is not null
+ ? AbCipArrayCodec.ElementWidth(def.DataType) * arrayElements.Length
+ : ExpectedScalarWriteWidth(def.DataType));
}
else if (status == (int)AbStatus.TypeMismatch)
{
// Final review I4: deterministic — keep the handle, leave health, warn once per tag.
+ // Only libplctag reaches this status-word branch, where it is a real controller answer: the native
+ // runtime's pre-send type gate THROWS (caught below, no LastSuccessfulRead advance) and never
+ // returns here with the status merely set (m5).
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
+ RecordWriteAnswered(now);
WarnTypeMismatchOnce(def.Name,
"AbCip write to {Tag} on device {Device} refused: the controller's type does not match the authored {DataType} (logged once per tag)",
w.FullReference, def.DeviceHostAddress, def.DataType);
}
+ else if (AbCipRefusals.IsDeviceRefusal(status))
+ {
+ // The controller refused this write (read-only, out of bounds …): keep the handle, no state change;
+ // it answered, so LastSuccessfulRead advances (M2).
+ results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status));
+ RecordWriteAnswered(now);
+ WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ((AbStatus)status).ToString());
+ }
else if (status != 0)
{
EvictRuntime(device, def.Name);
results[i] = new WriteResult(AbCipStatusMapper.MapStatus(status));
+ RecordWriteCommsFault($"transport status {status} writing {w.FullReference}");
_logger.LogWarning(
"AbCip write returned non-zero transport status {Status} for tag {Tag} on device {Device}; " +
"evicting cached runtime so next call re-creates it",
@@ -1251,7 +1536,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
else
{
results[i] = new WriteResult(AbCipStatusMapper.Good);
- _health = new DriverHealth(DriverState.Healthy, now, null);
+ RecordWriteAnswered(now);
}
}
catch (OperationCanceledException)
@@ -1269,39 +1554,44 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
"AbCip write not supported for tag {Tag} on device {Device}: {Reason} (logged once per tag)",
w.FullReference, def.DeviceHostAddress, nse.Message);
}
+ // H19: the three client-value conversion failures below never touched the wire — they say nothing about
+ // the device, so they write no health (a Degraded here latched until the next good read, and AbLegacy
+ // already maps the same three with no health write). Not a transport fault either: no evict.
catch (FormatException fe)
{
- // Value conversion error — not a transport fault; don't evict.
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, fe.Message);
- _logger.LogWarning(fe,
- "AbCip write value-conversion error for tag {Tag} on device {Device}",
- w.FullReference, def.DeviceHostAddress);
+ WarnWriteRefusedOnce(def.Name,
+ "AbCip write to {Tag} on device {Device}: the value cannot be converted to {DataType}: {Reason} (logged once per tag)",
+ w.FullReference, def.DeviceHostAddress, def.DataType, fe.Message);
}
catch (InvalidCastException ice)
{
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ice.Message);
- _logger.LogWarning(ice,
- "AbCip write type-cast error for tag {Tag} on device {Device}",
- w.FullReference, def.DeviceHostAddress);
+ WarnWriteRefusedOnce(def.Name,
+ "AbCip write to {Tag} on device {Device}: the value cannot be converted to {DataType}: {Reason} (logged once per tag)",
+ w.FullReference, def.DeviceHostAddress, def.DataType, ice.Message);
}
catch (OverflowException oe)
{
results[i] = new WriteResult(AbCipStatusMapper.BadOutOfRange);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, oe.Message);
- _logger.LogWarning(oe,
- "AbCip write value out of range for tag {Tag} on device {Device}",
- w.FullReference, def.DeviceHostAddress);
+ WarnWriteRefusedOnce(def.Name,
+ "AbCip write to {Tag} on device {Device}: the value is out of range for {DataType}: {Reason} (logged once per tag)",
+ w.FullReference, def.DeviceHostAddress, def.DataType, oe.Message);
+ }
+ catch (AbCipLocalRefusalException local)
+ {
+ // Refused locally (e.g. the native runtime cannot type the write; libplctag NotImplemented) — nothing
+ // sent: BadNotSupported, keep the handle, leave health alone.
+ results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
+ WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, local.Message);
}
catch (AbTransportException ate) when (ate.Status == AbStatus.Unsupported)
{
- // A deterministic local refusal (e.g. the native runtime cannot type the write) — not a comms
- // fault: BadNotSupported, keep the handle, leave health alone.
+ // The device refused the write as unsupported — BadNotSupported, keep the handle, no state change;
+ // it answered, so LastSuccessfulRead advances (M2).
results[i] = new WriteResult(AbCipStatusMapper.BadNotSupported);
- _logger.LogWarning(ate,
- "AbCip write refused (not supported) for tag {Tag} on device {Device}",
- w.FullReference, def.DeviceHostAddress);
+ RecordWriteAnswered(now);
+ WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ate.Message);
}
catch (AbCipTagConfigurationException ace)
{
@@ -1314,17 +1604,33 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Final review I4: the controller's type disagrees with the authored one (native type gate, or
// CIP 0xFF/0x2107 from the controller) — deterministic: BadTypeMismatch, keep the handle, leave
// health alone, warn once per tag. Retrying cannot succeed until the config is corrected.
+ // No LastSuccessfulRead advance: the native type gate throws this BEFORE sending.
results[i] = new WriteResult(AbCipStatusMapper.BadTypeMismatch);
WarnTypeMismatchOnce(def.Name,
"AbCip write to {Tag} on device {Device} refused: {Reason} (logged once per tag)",
w.FullReference, def.DeviceHostAddress, ate.Message);
}
+ catch (AbTransportException ate) when (AbCipRefusals.IsDeviceRefusal(ate))
+ {
+ // H1: the controller answered "no" to this write — keep the handle, no state change, warn once; it
+ // answered, so LastSuccessfulRead advances (M2).
+ results[i] = new WriteResult(AbCipStatusMapper.MapStatus(ate.Status));
+ RecordWriteAnswered(now);
+ WarnRefusalOnce(def.Name, "write", w.FullReference, def.DeviceHostAddress, ate.Message);
+ }
+ catch (Exception) when (device.Retired)
+ {
+ // The device was retired under this write by a re-init / shutdown — not a comms fault.
+ results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
+ }
catch (Exception ex)
{
- // Transport / wire error — evict so the next write creates a fresh handle.
- EvictRuntime(device, def.Name);
+ // Transport / wire error — evict so the next write creates a fresh handle (for a bit write, the
+ // parent runtime the read-modify-write used; the bit tag itself holds none).
+ if (rmwParent is not null) EvictParentRuntime(device, rmwParent);
+ else EvictRuntime(device, def.Name);
results[i] = new WriteResult(AbCipStatusMapper.BadCommunicationError);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
+ RecordWriteCommsFault(ex.Message);
_logger.LogWarning(ex,
"AbCip write transport exception for tag {Tag} on device {Device}",
w.FullReference, def.DeviceHostAddress);
@@ -1335,10 +1641,51 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
///
- /// Read-modify-write one bit within a DINT parent. Creates / reuses a parallel
- /// parent-DINT runtime (distinct from the bit-selector handle) + serialises concurrent
- /// writers against the same parent via a per-parent .
- /// Matches the Modbus BitInRegister + FOCAS PMC Bit pattern.
+ /// A good write proves the device answered: advance LastSuccessfulRead but leave the STATE alone. Writing
+ /// Healthy here (H19) overwrote a Degraded the poll loop had just recorded for other tags.
+ ///
+ ///
+ /// M3: a Degraded that a WRITE comms fault recorded (still the current health — same error, no read has
+ /// replaced it since) is cleared back to Healthy: the device just accepted a write. A Degraded the read
+ /// path recorded is left for the read path to clear.
+ ///
+ private void RecordWriteAnswered(DateTime now)
+ {
+ lock (_writeHealthLock)
+ {
+ UpdateHealth(h => h.State == DriverState.Degraded && h.LastError is { } err && ReferenceEquals(err, _writeOwnedError)
+ ? new DriverHealth(DriverState.Healthy, now, null)
+ : h with { LastSuccessfulRead = now });
+ }
+ }
+
+ // The exact LastError string INSTANCE the most recent write-side comms fault installed (a fresh copy, so reference
+ // identity cannot alias a read-side error). A health still carrying it is write-owned: a refusal-only read batch
+ // keeps it (`with` copies the reference), while any read / poll fault installs a new string and takes ownership.
+ // Set only under _writeHealthLock TOGETHER with the CAS that installs it, so two concurrent failing writes can never
+ // leave the marker naming a different instance than the installed health (#737 m1).
+ private string? _writeOwnedError;
+ private readonly object _writeHealthLock = new();
+
+ /// A write-side comms fault: Degraded (never downgrading Faulted), LastSuccessfulRead preserved.
+ private void RecordWriteCommsFault(string error)
+ {
+ var owned = new string(error.AsSpan());
+ lock (_writeHealthLock)
+ {
+ var installed = UpdateHealth(h => h.State == DriverState.Faulted
+ ? h
+ : new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, owned));
+ if (ReferenceEquals(installed.LastError, owned)) _writeOwnedError = owned;
+ }
+ }
+
+ ///
+ /// Read-modify-write one bit within an integer parent (SINT / INT / DINT / LINT — Logix allows .n on
+ /// each). Creates / reuses a parallel parent runtime (distinct from the bit-selector handle) + serialises
+ /// concurrent writers against the same parent via a per-parent . The parent's
+ /// width comes from the size its read returned (H17 — a hard-coded DINT decode threw OutOfBounds on an INT
+ /// host and was reported as a comms fault). Matches the Modbus BitInRegister + FOCAS PMC Bit pattern.
///
private async Task WriteBitInDIntAsync(
DeviceState device, AbCipTagPath bitPath, int bit, object? value, CancellationToken ct)
@@ -1353,19 +1700,56 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
var readStatus = parentRuntime.GetStatus();
- if (readStatus != 0) return AbCipStatusMapper.MapStatus(readStatus);
+ if (readStatus != 0) return BitWriteStatusWord(device, parentName, readStatus, "read");
- var current = Convert.ToInt32(parentRuntime.DecodeValue(AbCipDataType.DInt, bitIndex: null) ?? 0);
- var updated = Convert.ToBoolean(value)
- ? current | (1 << bit)
- : current & ~(1 << bit);
+ // Unsigned at the narrow widths so a set top bit (SINT bit 7, INT bit 15) encodes without overflowing the
+ // signed type; DINT keeps its signed int arithmetic (1 << 31 is int.MinValue, which a DINT encodes as is).
+ var size = parentRuntime.GetSize();
+ AbCipDataType? hostType = size switch
+ {
+ 1 => AbCipDataType.USInt,
+ 2 => AbCipDataType.UInt,
+ 8 => AbCipDataType.LInt,
+ 4 or <= 0 => AbCipDataType.DInt, // <= 0: the runtime cannot say — the historical DINT assumption
+ _ => null,
+ };
+ if (hostType is not { } type)
+ {
+ WarnTypeMismatchOnce(parentName,
+ "AbCip bit write to {Parent}.{Bit} on device {Device} refused: the parent is {Size} byte(s), not an integer host (logged once per tag)",
+ parentName, bit, device.Options.HostAddress, size);
+ return AbCipStatusMapper.BadTypeMismatch;
+ }
+ var widthBits = type switch { AbCipDataType.USInt => 8, AbCipDataType.UInt => 16, AbCipDataType.LInt => 64, _ => 32 };
+ if (bit >= widthBits)
+ {
+ WarnTypeMismatchOnce(parentName,
+ "AbCip bit write to {Parent}.{Bit} on device {Device} refused: the parent holds only {Bits} bits (logged once per tag)",
+ parentName, bit, device.Options.HostAddress, widthBits);
+ return AbCipStatusMapper.BadOutOfRange;
+ }
- parentRuntime.EncodeValue(AbCipDataType.DInt, bitIndex: null, updated);
+ var set = Convert.ToBoolean(value);
+ var decoded = parentRuntime.DecodeValue(type, bitIndex: null) ?? 0;
+ object updated;
+ if (type == AbCipDataType.LInt)
+ {
+ var current = Convert.ToInt64(decoded);
+ updated = set ? current | (1L << bit) : current & ~(1L << bit);
+ }
+ else
+ {
+ var mask = type switch { AbCipDataType.USInt => 0xFF, AbCipDataType.UInt => 0xFFFF, _ => -1 };
+ var current = Convert.ToInt32(decoded) & mask;
+ updated = (set ? current | (1 << bit) : current & ~(1 << bit)) & mask;
+ }
+
+ parentRuntime.EncodeValue(type, bitIndex: null, updated);
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
var writeStatus = parentRuntime.GetStatus();
return writeStatus == 0
? AbCipStatusMapper.Good
- : AbCipStatusMapper.MapStatus(writeStatus);
+ : BitWriteStatusWord(device, parentName, writeStatus, "write");
}
finally
{
@@ -1373,6 +1757,28 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
+ ///
+ /// M1: a non-zero status word on the bit write's parent read / write follows the health contract — a device
+ /// refusal is per-tag Bad with one Warning and no state change (it answered: LastSuccessfulRead advances); any
+ /// other status is a comms fault: evict the PARENT runtime and write Degraded (LastSuccessfulRead preserved).
+ ///
+ private uint BitWriteStatusWord(DeviceState device, string parentName, int status, string phase)
+ {
+ var mapped = AbCipStatusMapper.MapStatus(status);
+ if (AbCipRefusals.IsDeviceRefusal(status))
+ {
+ RecordWriteAnswered(DateTime.UtcNow);
+ WarnRefusalOnce(parentName, $"bit-write parent {phase}", parentName, device.Options.HostAddress, ((AbStatus)status).ToString());
+ return mapped;
+ }
+ EvictParentRuntime(device, parentName);
+ RecordWriteCommsFault($"transport status {status} on the bit-write parent {phase} of {parentName}");
+ _logger.LogWarning(
+ "AbCip bit-write parent {Phase} of {Parent} on device {Device} returned transport status {Status}; evicting the parent runtime",
+ phase, parentName, device.Options.HostAddress, status);
+ return mapped;
+ }
+
///
/// Get or lazily create a parent-DINT runtime for a parent tag path, cached per-device
/// so repeated bit writes against the same DINT share one handle.
@@ -1381,6 +1787,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
DeviceState device, string parentTagName, CancellationToken ct)
{
if (device.ParentRuntimes.TryGetValue(parentTagName, out var existing)) return existing;
+ ThrowIfRetired(device);
var runtime = _tagFactory.Create(device.BuildCreateParams(parentTagName, _options.Timeout));
try
@@ -1394,10 +1801,25 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
// Two concurrent callers can both miss the cache + both initialize a runtime; only the
// first TryAdd wins. Dispose the loser so it doesn't leak a native tag handle.
- if (device.ParentRuntimes.TryAdd(parentTagName, runtime))
- return runtime;
- runtime.Dispose();
- return device.ParentRuntimes[parentTagName];
+ if (!device.ParentRuntimes.TryAdd(parentTagName, runtime))
+ {
+ runtime.Dispose();
+ return device.ParentRuntimes[parentTagName];
+ }
+ Interlocked.MemoryBarrier(); // pairs with DeviceState.Retire
+ if (device.Retired)
+ {
+ if (device.ParentRuntimes.TryRemove(parentTagName, out var orphan)) orphan.Dispose();
+ ThrowIfRetired(device);
+ }
+ return runtime;
+ }
+
+ /// Throws when a teardown has retired (a reader raced a re-init / shutdown).
+ private static void ThrowIfRetired(DeviceState device)
+ {
+ if (device.Retired)
+ throw new ObjectDisposedException(nameof(DeviceState), $"AbCip device {device.Options.HostAddress} was retired by a re-initialization or shutdown.");
}
///
@@ -1434,6 +1856,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Re-check under the gate: a sibling cold tag may have connected (or a concurrent write
// may have materialised this very handle) while this caller waited.
if (device.Runtimes.TryGetValue(def.Name, out var raced)) return raced;
+ ThrowIfRetired(device);
// Inside an open backoff window a data-path caller fails fast — no runtime
// create, no Forward-Open. The malformed-path config error above still surfaces (a permanent
@@ -1465,10 +1888,10 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (!device.Runtimes.TryAdd(def.Name, runtime)) runtime.Dispose();
throw;
}
- catch (AbCipTagConfigurationException)
+ catch (Exception ex) when (ex is AbCipTagConfigurationException or AbCipLocalRefusalException)
{
- // The tag's own name cannot become a request — no I/O happened, so it says nothing
- // about the device: leave the backoff window exactly as it was.
+ // The tag's own name cannot become a request, or the library refused the tag locally — no I/O happened,
+ // so it says nothing about the device: leave the backoff window exactly as it was.
runtime.Dispose();
throw;
}
@@ -1479,9 +1902,7 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// not answer. "Tag not found" means the controller replied over a working session; it
// is a config error on this one tag, and counting it as a failed Forward-Open throttled
// every sibling tag on the device (seen live: one typo blacked out the whole device).
- if (ex is AbTransportException ate
- && ate.Status != AbStatus.Ok
- && AbCipDriverProbe.IsReachableStatus(ate.Status))
+ if (AbCipRefusals.IsDeviceRefusal(ex))
device.Backoff.RecordSuccess();
else
device.Backoff.RecordFailure(DateTime.UtcNow);
@@ -1491,10 +1912,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// The gate makes a cold create single-flight, but a concurrent EvictRuntime (a failed read
// on this handle) can still land between the re-check and the add; only the first TryAdd
// wins. Dispose the loser so it doesn't leak a native tag handle.
- if (device.Runtimes.TryAdd(def.Name, runtime))
- return runtime;
- runtime.Dispose();
- return device.Runtimes[def.Name];
+ if (!device.Runtimes.TryAdd(def.Name, runtime))
+ {
+ runtime.Dispose();
+ return device.Runtimes[def.Name];
+ }
+ // A teardown that retired this state while we created would never see this handle: take it back out
+ // (TryRemove — exactly one of us disposes it) rather than leak it into a dead table.
+ Interlocked.MemoryBarrier(); // pairs with DeviceState.Retire
+ if (device.Retired)
+ {
+ if (device.Runtimes.TryRemove(def.Name, out var orphan)) orphan.Dispose();
+ ThrowIfRetired(device);
+ }
+ return runtime;
}
finally
{
@@ -1517,6 +1948,15 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
}
}
+ /// Evicts + disposes a bit-write parent runtime so the next read-modify-write re-creates it.
+ private static void EvictParentRuntime(DeviceState device, string parentName)
+ {
+ if (device.ParentRuntimes.TryRemove(parentName, out var stale))
+ {
+ try { stale.Dispose(); } catch { }
+ }
+ }
+
///
public DriverHealth GetHealth() => _health;
@@ -1744,6 +2184,20 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
: AbCipStatusMapper.BadTypeMismatch;
}
+ ///
+ /// The refusal for a whole-array write whose authored shape does not fit what the controller holds: the last
+ /// read returned bytes, which must be the element width × the element count. Unknown
+ /// size (<= 0) is not judged here (the native runtime re-checks against the controller's type code).
+ ///
+ /// The array tag being written.
+ /// The runtime's read-buffer size in bytes.
+ /// BadTypeMismatch, or null to write.
+ internal static uint? ArrayWriteWidthRefusal(AbCipTagDefinition def, int size)
+ {
+ if (size <= 0 || AbCipArrayCodec.ElementWidth(def.DataType) is not { } width) return null;
+ return size == width * Math.Max(1, def.ElementCount) ? null : AbCipStatusMapper.BadTypeMismatch;
+ }
+
///
/// Recursively fan a controller-discovered UDT shape out into Variable nodes — one per
/// ATOMIC leaf — under the folder produced by . Mirrors the
@@ -1896,16 +2350,35 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
internal DeviceState? GetDeviceState(string hostAddress) =>
_devices.TryGetValue(hostAddress, out var s) ? s : null;
- /// Releases all resources used by the driver.
- public void Dispose() => DisposeAsync().AsTask().GetAwaiter().GetResult();
-
- /// Asynchronously releases all resources used by the driver.
+ ///
+ /// Asynchronously releases all resources used by the driver — the same complete teardown as
+ /// . Idempotent, and safe CONCURRENTLY with a still-running
+ /// (the runtime's final stop abandons a shutdown that overruns its budget and
+ /// disposes anyway): each teardown retires a disjoint device table. This is the dispose the runtime calls
+ /// (it prefers ).
+ ///
/// A value task representing the asynchronous disposal.
public async ValueTask DisposeAsync()
{
await ShutdownAsync(CancellationToken.None).ConfigureAwait(false);
}
+ ///
+ /// Starts the same teardown as WITHOUT blocking the calling thread on it (review
+ /// 07 F10: the old DisposeAsync().AsTask().GetAwaiter().GetResult() parked the caller — a dispatcher
+ /// thread — for up to the probe join, with the usual sync-over-async deadlock exposure). The teardown runs to
+ /// completion in the background and any fault is logged. Callers that need completion use
+ /// ; kept only for synchronous using / Dispose() call sites.
+ ///
+ public void Dispose()
+ {
+ var teardown = DisposeAsync().AsTask();
+ if (teardown.IsCompleted) { teardown.GetAwaiter().GetResult(); return; }
+ _ = teardown.ContinueWith(
+ t => _logger.LogWarning(t.Exception, "AbCip driver {DriverInstanceId}: background dispose failed", _driverInstanceId),
+ CancellationToken.None, TaskContinuationOptions.OnlyOnFaulted, TaskScheduler.Default);
+ }
+
///
/// Per-device runtime state. Holds the parsed host address, family profile, and the
/// live libplctag.NET instances keyed by tag name.
@@ -1967,6 +2440,9 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// Gets or sets whether the probe has been initialized.
public bool ProbeInitialized { get; set; }
+ /// 1 once the probe-tag refusal Warning has been logged for this device state.
+ public int ProbeRefusalWarned;
+
///
/// The fire-and-forget probe loop's . Stored so
/// can await the loop's clean exit after
@@ -2053,19 +2529,38 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
AuthoredType: authoredType,
UseConnectedMessaging: Profile.SupportsConnectedMessaging);
- /// Disposes all runtime tag handles and clears the caches.
+ private volatile bool _retired;
+
+ ///
+ /// True once a teardown retired this state. A reader that resolved the device just before the swap can
+ /// still be running against it: its failures are the teardown's doing, not the device's (no Degraded), and a
+ /// runtime it cold-creates afterwards is disposed by the creator instead of leaking into a dead table.
+ ///
+ public bool Retired => _retired;
+
+ /// Marks the state retired, then disposes every runtime handle (see ).
+ public void Retire()
+ {
+ _retired = true;
+ // Dekker pairing with the creator's post-add check: the flag is globally visible before the tables are
+ // enumerated, and the creator fences between its add and its read of the flag — so at least one of the two
+ // sees the other's write and the handle is disposed.
+ Interlocked.MemoryBarrier();
+ DisposeHandles();
+ }
+
+ ///
+ /// Disposes every runtime tag handle, each exactly once (removed before it is disposed, so a racing
+ /// creator's own cleanup cannot dispose it a second time). The per-runtime / RMW gates are deliberately NOT
+ /// disposed, for the same reason as : an in-flight reader of a retired state may
+ /// still release one, and a only awaited asynchronously holds no wait handle.
+ ///
public void DisposeHandles()
{
- foreach (var r in Runtimes.Values) r.Dispose();
- Runtimes.Clear();
- foreach (var r in ParentRuntimes.Values) r.Dispose();
- ParentRuntimes.Clear();
- // Dispose + clear the per-runtime/RMW gates so ReinitializeAsync cycles don't orphan
- // their SemaphoreSlim instances (each leaks a wait handle once contended).
- foreach (var sem in _runtimeLocks.Values) sem.Dispose();
- _runtimeLocks.Clear();
- foreach (var sem in _rmwLocks.Values) sem.Dispose();
- _rmwLocks.Clear();
+ foreach (var key in Runtimes.Keys)
+ if (Runtimes.TryRemove(key, out var r)) r.Dispose();
+ foreach (var key in ParentRuntimes.Keys)
+ if (ParentRuntimes.TryRemove(key, out var r)) r.Dispose();
}
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
index e9bedb8f..b927d758 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipDriverProbe.cs
@@ -18,10 +18,11 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
/// non-CIP server) returns Ok = false with a "handshake failed" message instead of
/// a false-positive green tick.
///
-/// Status-code discrimination: AbStatus.Ok and AbStatus.NotFound mean the controller
-/// answered the CIP session (a non-existent probe tag name still proves CIP connectivity) and are
-/// treated as "reachable - Ok = true". Every other status (timeout, bad connection, bad reply,
-/// partial, abort, ...) is a transport or session failure: "handshake failed - Ok = false".
+/// Status-code discrimination: AbStatus.Ok and every device refusal (not found, out of bounds,
+/// not allowed, too large/small, type mismatch, unsupported) mean the controller answered the CIP session
+/// (a non-existent probe tag name still proves CIP connectivity) and are treated as "reachable - Ok = true".
+/// Every other status (timeout, bad connection, bad reply, partial, abort, ...) is a transport or session
+/// failure: "handshake failed - Ok = false".
///
///
public sealed class AbCipDriverProbe : IDriverProbe
@@ -147,7 +148,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
{
var msg = statusCode == AbStatus.Ok
? "CIP session OK"
- : "CIP session OK (controller reachable; probe tag not found)";
+ : RefusedProbeMessage(statusCode);
return new(true, msg, sw.Elapsed);
}
@@ -164,7 +165,7 @@ public sealed class AbCipDriverProbe : IDriverProbe
if (IsReachableException(ex))
{
sw.Stop();
- return new(true, "CIP session OK (controller reachable; probe tag not found)", sw.Elapsed);
+ return new(true, RefusedProbeMessage(ex.Status), sw.Elapsed);
}
return new(false, $"Reachable at {host}:{port} but CIP handshake failed: {ex.Message}", null);
@@ -180,13 +181,20 @@ public sealed class AbCipDriverProbe : IDriverProbe
}
///
- /// Returns true for transport statuses that indicate the CIP controller answered
- /// but the probe tag name was not found or did not match — sufficient to confirm CIP
- /// reachability. Returns true for Ok (tag was found).
+ /// Returns true for transport statuses that indicate the CIP controller answered — Ok, or any
+ /// device refusal (: not found, out of bounds, not
+ /// allowed, too large/small, type mismatch, unsupported) — sufficient to confirm CIP reachability.
///
/// The transport status to classify.
/// if the status confirms CIP reachability.
- internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
+ internal static bool IsReachableStatus(AbStatus s) => s == AbStatus.Ok || AbCipRefusals.IsDeviceRefusal(s);
+
+ /// The Test-Connect message for a controller that answered but refused the probe tag.
+ /// The refusal.
+ /// The message.
+ private static string RefusedProbeMessage(AbStatus status) => status == AbStatus.NotFound
+ ? "CIP session OK (controller reachable; probe tag not found)"
+ : $"CIP session OK (controller reachable; probe tag refused: {status})";
///
/// Decides whether an is a "controller answered CIP but the
@@ -198,7 +206,8 @@ public sealed class AbCipDriverProbe : IDriverProbe
internal static bool IsReachableException(AbTransportException ex)
{
ArgumentNullException.ThrowIfNull(ex);
- return IsReachableStatus(ex.Status);
+ // A local refusal (the library / driver refused without any I/O) proves nothing about the controller.
+ return ex is not AbCipLocalRefusalException && IsReachableStatus(ex.Status);
}
private static (AbCipHostAddress? parsed, AbCipDeviceOptions? device) ExtractTarget(AbCipDriverOptions opts)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs
new file mode 100644
index 00000000..ffa3c0b1
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipInitializationSupersededException.cs
@@ -0,0 +1,18 @@
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// Thrown by when a shutdown, dispose or newer initialization retired the
+/// driver's tables while this initialization was building, so it published nothing. Deliberately NOT an
+/// : nobody cancelled this call, and a caller that treats cancellation as
+/// "my own token fired" must not mistake a superseded attempt for one. The superseding operation owns the driver's
+/// state; the caller (normally the runtime, which has already abandoned this attempt) can ignore the result.
+///
+///
+/// Resilience accounting (#737 m2): not being an OCE, it passes the Initialize pipeline's breaker
+/// ShouldHandle (DriverResiliencePipelineBuilder) and, if its generation still matched, the actor's
+/// RecordFault. That is practically unreachable — DriverInstanceActor runs initialisation single-flight
+/// and drops generation-mismatched results — and a superseded attempt is at worst one counted failure. Recognising
+/// it there is a runtime-side (Lane A) change if it is ever wanted.
+///
+/// The detail.
+public sealed class AbCipInitializationSupersededException(string message) : InvalidOperationException(message);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipLocalRefusalException.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipLocalRefusalException.cs
new file mode 100644
index 00000000..baa350e1
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipLocalRefusalException.cs
@@ -0,0 +1,15 @@
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// A refusal raised LOCALLY — by the driver's own runtime or by libplctag (ErrorNotImplemented) — before or
+/// without any reply from the controller. It carries (so it maps to
+/// BadNotSupported) but, unlike a device refusal, proves nothing about the device: it keeps the runtime and
+/// writes no health, does NOT advance LastSuccessfulRead, records nothing in the connect backoff, and is not
+/// "reachable" for the probe or Test Connect (review 07 / Gitea #737 I2).
+///
+/// The refusal detail.
+/// The underlying exception, if any.
+public sealed class AbCipLocalRefusalException(string message, Exception? inner = null)
+ : AbTransportException(AbStatus.Unsupported, message, inner);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs
new file mode 100644
index 00000000..334568a1
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipRefusals.cs
@@ -0,0 +1,42 @@
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
+
+///
+/// The ONE classification of a transport status into "the controller answered" vs "the wire failed", shared by
+/// the data path, the probe loop, the Test-Connect probe and the native runtime's initialisation.
+///
+///
+/// A device refusal is deterministic: the session works and the controller said no to this one tag (a renamed
+/// tag, Arr[999], a read-protected tag, a type the authored config does not match). It is per-tag Bad,
+/// keeps the tag runtime (evicting re-creates it — and, for a sole lease holder, re-Forward-Opens — on every poll),
+/// never writes Degraded, never opens the device-wide connect backoff, and warns once per tag. Everything
+/// else (timeout, bad connection, a reply that failed to parse, partial, abort, …) is a comms fault.
+///
+/// The native transport currently folds CIP general statuses 0x1C, 0x20 and 0xFF/0x2104 into
+/// (AbStatusCodes.FromCip, owned by Driver.Ab.Transport), which
+/// this classification cannot tell from a garbled reply; until the transport maps them explicitly they stay
+/// comms faults.
+///
+///
+internal static class AbCipRefusals
+{
+ /// True when proves the controller answered but refused this tag.
+ /// The transport status.
+ /// for a deterministic device refusal.
+ public static bool IsDeviceRefusal(AbStatus status) => status is AbStatus.NotFound or AbStatus.OutOfBounds
+ or AbStatus.NotAllowed or AbStatus.TooLarge or AbStatus.TooSmall or AbStatus.TypeMismatch
+ or AbStatus.Unsupported;
+
+ /// True when (an value) is a device refusal.
+ /// The runtime status word.
+ /// for a deterministic device refusal.
+ public static bool IsDeviceRefusal(int status) => IsDeviceRefusal((AbStatus)status);
+
+ /// True when is an carrying a device refusal.
+ /// The exception.
+ /// for a deterministic device refusal.
+ /// An is never a device refusal: nothing reached the device.
+ public static bool IsDeviceRefusal(Exception ex) =>
+ ex is AbTransportException ate and not AbCipLocalRefusalException && IsDeviceRefusal(ate.Status);
+}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs
index 18296c88..2988a685 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipStatusMapper.cs
@@ -15,6 +15,7 @@ public static class AbCipStatusMapper
public const uint GoodMoreData = 0x00A60000u;
public const uint BadInternalError = 0x80020000u;
public const uint BadNodeIdUnknown = 0x80340000u;
+ public const uint BadNotReadable = 0x803A0000u;
public const uint BadNotWritable = 0x803B0000u;
public const uint BadOutOfRange = 0x803C0000u;
public const uint BadNotSupported = 0x803D0000u;
@@ -51,4 +52,14 @@ public static class AbCipStatusMapper
/// The OPC UA status code.
public static uint MapException(Exception ex) =>
AbOpcUaStatusMap.MapException(ex);
+
+ ///
+ /// Status for a READ the controller refused: as , except that a
+ /// answer to a read is (the shared table's
+ /// BadNotWritable describes a refused write).
+ ///
+ /// The refusal status.
+ /// The OPC UA status code.
+ public static uint MapReadStatus(AbStatus status) =>
+ status == AbStatus.NotAllowed ? BadNotReadable : MapStatus(status);
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs
index b4b271f3..3c44f80f 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtMemberLayout.cs
@@ -25,6 +25,30 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip;
///
public static class AbCipUdtMemberLayout
{
+ /// Layouts already computed, keyed by the parent definition's member list (reference identity).
+ private static readonly System.Runtime.CompilerServices.ConditionalWeakTable, LayoutBox> Cache = new();
+
+ /// Holds a computed layout, including a "not groupable" (null) verdict.
+ private sealed class LayoutBox(IReadOnlyDictionary? offsets)
+ {
+ public IReadOnlyDictionary? Offsets { get; } = offsets;
+ }
+
+ ///
+ /// , computed once per member list (review 07 P11: the read planner rebuilt the layout —
+ /// a dictionary allocation and an O(members) walk — for every member reference on every poll, O(members²) per
+ /// parent per tick). A parent definition's member list is immutable for the life of the tag table that holds
+ /// it, so caching by list identity is exact; a re-init builds new lists and the old entries are collected with
+ /// them ().
+ ///
+ /// The parent definition's declared members.
+ /// The member offsets, or null when the members cannot be laid out by declaration.
+ public static IReadOnlyDictionary? GetOrBuild(IReadOnlyList members)
+ {
+ ArgumentNullException.ThrowIfNull(members);
+ return Cache.GetValue(members, m => new LayoutBox(TryBuild(m))).Offsets;
+ }
+
///
/// Try to compute member offsets for the supplied declared members. Returns null
/// if any member type is unsupported for declaration-only layout.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs
index 28bc181f..338d905e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/AbCipUdtReadPlanner.cs
@@ -67,7 +67,7 @@ public static class AbCipUdtReadPlanner
continue;
}
- var offsets = AbCipUdtMemberLayout.TryBuild(parent.Members);
+ var offsets = AbCipUdtMemberLayout.GetOrBuild(parent.Members);
if (offsets is null || !offsets.TryGetValue(memberName, out var offset))
{
fallback.Add(new AbCipUdtReadFallback(i, name));
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
index 1cf3c2e5..ad8b00a2 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/IAbCipTagRuntime.cs
@@ -85,6 +85,17 @@ public interface IAbCipTagRuntime : IDisposable
/// Bit index for BOOL-within-DINT insertion, or null.
/// Value to encode.
void EncodeValue(AbCipDataType type, int? bitIndex, object? value);
+
+ ///
+ /// Encode a WHOLE-array write of (values.Count elements of
+ /// , at the element width — see ) into the local buffer.
+ /// Callers pair this with . A runtime that cannot write arrays throws
+ /// .
+ ///
+ /// The array element type.
+ /// The element values; already length-checked against the tag's element count.
+ void EncodeArray(AbCipDataType type, IReadOnlyList values) =>
+ throw new NotSupportedException($"{GetType().Name} does not support array writes.");
}
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs
index 37614e2b..7be295a6 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagStatusTranslation.cs
@@ -14,11 +14,21 @@ internal static class LibplctagStatusTranslation
Status.Ok => AbStatus.Ok,
Status.Pending => AbStatus.Pending,
Status.ErrorTimeout => AbStatus.Timeout,
- Status.ErrorNotFound or Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.NotFound,
+ // #737 I2: only ErrorNotFound is the controller saying "no such tag". ErrorNoMatch / ErrorBadDevice describe a
+ // session or device-level failure (no proof the controller answered this tag), so they are comms-class: with
+ // the widened refusal classification, folding them into NotFound made a broken session read as "reachable".
+ Status.ErrorNotFound => AbStatus.NotFound,
+ Status.ErrorNoMatch or Status.ErrorBadDevice => AbStatus.BadConnection,
Status.ErrorNotAllowed => AbStatus.NotAllowed,
Status.ErrorOutOfBounds => AbStatus.OutOfBounds,
Status.ErrorTooLarge => AbStatus.TooLarge,
Status.ErrorTooSmall => AbStatus.TooSmall,
+ // ErrorNotImplemented is the LIBRARY refusing (no I/O); the exception paths raise it as an
+ // AbCipLocalRefusalException so it never counts as "the device answered" (see FromException).
+ // ASSUMPTION (#737 m3): libplctag reports a failed operation by THROWING, so a NotImplemented reaches the driver
+ // only through FromException. AbStatus has no "local" member (it is Driver.Ab.Transport's, Lane K), so a
+ // NotImplemented observed only as a STATUS WORD (GetStatus after a non-throwing op) would still classify as a
+ // device Unsupported refusal; that combination is not produced by libplctag.NET 1.5.2.
Status.ErrorUnsupported or Status.ErrorNotImplemented => AbStatus.Unsupported,
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
@@ -70,9 +80,21 @@ internal static class LibplctagStatusTranslation
public static async Task Wrap(Func op)
{
try { await op().ConfigureAwait(false); }
- catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
+ catch (LibPlcTagException ex) { throw FromException(ex); }
}
+ ///
+ /// The transport exception for a libplctag failure: ErrorNotImplemented becomes an
+ /// (a library refusal, no I/O); everything else an
+ /// carrying the translated status.
+ ///
+ /// The libplctag exception.
+ /// The exception to throw.
+ public static AbTransportException FromException(LibPlcTagException ex) =>
+ Enum.TryParse((ex.Message ?? string.Empty).Trim(), ignoreCase: true, out var s) && s == Status.ErrorNotImplemented
+ ? new AbCipLocalRefusalException($"libplctag does not implement this operation: {ex.Message}", ex)
+ : new AbTransportException(ToAbStatus(ex), ex.Message ?? string.Empty, ex);
+
/// Runs , rethrowing any as .
/// The result type.
/// The libplctag operation.
@@ -80,7 +102,7 @@ internal static class LibplctagStatusTranslation
public static async Task Wrap(Func> op)
{
try { return await op().ConfigureAwait(false); }
- catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
+ catch (LibPlcTagException ex) { throw FromException(ex); }
}
/// Runs a synchronous libplctag call, rethrowing as .
@@ -90,7 +112,7 @@ internal static class LibplctagStatusTranslation
public static T WrapSync(Func op)
{
try { return op(); }
- catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
+ catch (LibPlcTagException ex) { throw FromException(ex); }
}
/// Runs a synchronous libplctag call, rethrowing as .
@@ -98,6 +120,6 @@ internal static class LibplctagStatusTranslation
public static void WrapSync(Action op)
{
try { op(); }
- catch (LibPlcTagException ex) { throw new AbTransportException(ToAbStatus(ex), ex.Message, ex); }
+ catch (LibPlcTagException ex) { throw FromException(ex); }
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
index e2ff94c9..07ac008e 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/LibplctagTagRuntime.cs
@@ -29,8 +29,19 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
public LibplctagTagRuntime(AbCipTagCreateParams p)
{
_tag = BuildTag(p);
+ _boolArrayElement = !p.IsArray && p.AuthoredType == AbCipDataType.Bool
+ && NativeAbCipTagRuntime.TrySplitBoolArrayElement(p.TagName, out _, out _);
}
+ ///
+ /// A scalar BOOL authored over a BOOL-array element (Bits[n]). libplctag reads it as whatever the
+ /// controller returns for that element segment — a packed DWORD — and exposes no type code, so which DWORD
+ /// came back (and so which bit is element n) cannot be checked here. Review 07 U11: such a read used to decode
+ /// "low byte != 0" as Good; it is refused (BadNotSupported) when a DWORD came back. The native transport
+ /// (the default) addresses the right DWORD and decodes the bit.
+ ///
+ private readonly bool _boolArrayElement;
+
/// Builds the (uninitialized) libplctag tag for .
/// The tag creation parameters.
/// The new, uninitialised libplctag tag.
@@ -120,7 +131,10 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
{
AbCipDataType.Bool => bitIndex is int bit
? _tag.GetBit(bit)
- : _tag.GetInt8(offset) != 0,
+ : _boolArrayElement && offset == 0 && _tag.GetSize() == 4
+ ? throw new AbTransportException(AbStatus.Unsupported,
+ $"'{_tag.Name}' is a BOOL-array element read as a packed DWORD; libplctag cannot say which DWORD it is — use the native transport.")
+ : _tag.GetInt8(offset) != 0,
AbCipDataType.SInt => (int)(sbyte)_tag.GetInt8(offset),
AbCipDataType.USInt => (int)_tag.GetUInt8(offset),
AbCipDataType.Int => (int)_tag.GetInt16(offset),
@@ -221,6 +235,23 @@ internal sealed class LibplctagTagRuntime : IAbCipTagRuntime
_ => 4,
};
+ ///
+ ///
+ /// The codec's bytes are copied into the buffer the last read sized (elem_count elements), so the write
+ /// sends exactly the new array — never a stale tail. A buffer smaller than the encoded array is refused
+ /// () before anything is changed.
+ ///
+ public void EncodeArray(AbCipDataType type, IReadOnlyList values) =>
+ LibplctagStatusTranslation.WrapSync(() =>
+ {
+ var bytes = AbCipArrayCodec.Encode(type, values);
+ var size = _tag.GetSize();
+ if (size < bytes.Length)
+ throw new AbTransportException(AbStatus.TooSmall,
+ $"Array write to '{_tag.Name}': {bytes.Length} byte(s) encoded but the tag buffer holds {size}.");
+ for (var i = 0; i < bytes.Length; i++) _tag.SetUInt8(i, bytes[i]);
+ });
+
///
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value) =>
LibplctagStatusTranslation.WrapSync(() => EncodeValueCore(type, bitIndex, value));
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
index af1ce7e6..a666239c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbCip/NativeAbCipTagRuntime.cs
@@ -41,6 +41,34 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
private int? _implicitBit;
private byte[]? _path;
+ // U11: a scalar BOOL authored over a BOOL-array element "Bits[n]" — the bit (n % 32) of DWORD n / 32 when the
+ // array is packed. Null for every other tag.
+ private int? _boolElementBit;
+
+ // Both addressings of such a tag: "Bits[n / 32]" (packed: a Logix BOOL array is stored as 0xD3 DWORDs) and the
+ // original "Bits[n]" (per-element: a family that answers 0xC1). _path is whichever _boolLayout selects.
+ private byte[]? _dwordPath;
+ private byte[]? _elementPath;
+
+ /// How a BOOL-array element tag's array is laid out, as the controller's REPLY TYPE last showed it.
+ private enum BoolLayout
+ {
+ /// No reply type seen yet (or only refusals / comms faults): request DWORD-addressed, keep asking.
+ Unsettled,
+
+ /// The controller answered 0xD3: address DWORD n / 32, decode bit n % 32.
+ Packed,
+
+ /// The controller answered 0xC1: address element n, decode its byte.
+ PerElement,
+ }
+
+ // #737 fix round 2 (N1): ONLY a reply type settles (or re-settles) the layout — 0xD3 ⇒ Packed, 0xC1 ⇒ PerElement.
+ // A refusal or comms fault says nothing about the layout and leaves it as it was, so a tag deployed before the
+ // online edit that creates its array starts reading correctly once the array exists. A reply whose type disagrees
+ // with the addressing it was asked under is DISCARDED (never decoded) and re-read under the right addressing.
+ private BoolLayout _boolLayout;
+
private ConnectionLease? _lease;
private RequestScheduler? _privateScheduler; // ForTest only — production always uses the lease's
private LogixTagClient? _client;
@@ -51,6 +79,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
private AbStatus _status = AbStatus.Pending;
private byte[]? _writeBuffer;
+ private ushort _writeElements = 1;
private string? _pendingString;
private bool _disposed;
@@ -94,16 +123,15 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
/// The trailing bit selector split off the tag name (Tag.5), if any.
internal int? ImplicitBit => _implicitBit;
- // Performs one read to prove the tag exists: libplctag's InitializeAsync errors for an unknown
- // tag, and the driver's backoff classification depends on create throwing AbStatus.NotFound.
+ // Performs one read to prove the session works and learn the tag's type.
//
// A name the encoder refuses throws AbCipTagConfigurationException before any lease: it is this
// tag's configuration, not the device, and must not black out the device's other tags. A read the
- // controller answers with a deterministic refusal (AbStatus.TypeMismatch / AbStatus.Unsupported)
- // proves the tag exists, so it completes the initialization with that status rather than throwing:
- // a throw here makes the driver dispose the runtime and re-create it - and, for a sole lease
- // holder, re-Forward-Open - on every poll (mistype churn). The driver's own read reports the
- // refusal.
+ // controller answers with a deterministic refusal (AbCipRefusals.IsDeviceRefusal: NotFound, OutOfBounds,
+ // NotAllowed, TooLarge/TooSmall, TypeMismatch, Unsupported) proves the session works, so it completes the
+ // initialization with that status rather than throwing: a throw here makes the driver dispose the runtime
+ // and re-create it - and, for a sole lease holder, re-Forward-Open - on every poll (a renamed tag or a
+ // mistype churned the lease). The driver's own read reports the refusal, per tag, on the kept runtime.
///
public async Task InitializeAsync(CancellationToken cancellationToken)
{
@@ -116,7 +144,20 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
try
{
split = CipPath.SplitBitSelector(_p.TagName);
- path = CipPath.EncodeSymbolic(split.Name);
+ var name = split.Name;
+ if (split.Bit is null && !_p.IsArray && _p.AuthoredType == AbCipDataType.Bool
+ && TrySplitBoolArrayElement(name, out var dwordName, out var bit))
+ {
+ _elementPath = CipPath.EncodeSymbolic(name);
+ _dwordPath = CipPath.EncodeSymbolic(dwordName);
+ _boolElementBit = bit;
+ _boolLayout = BoolLayout.Unsettled;
+ path = _dwordPath;
+ }
+ else
+ {
+ path = CipPath.EncodeSymbolic(name);
+ }
}
catch (Exception ex) when (ex is ArgumentException or FormatException or OverflowException)
{
@@ -137,20 +178,45 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
{
await ReadAsync(cancellationToken).ConfigureAwait(false);
}
- catch (AbTransportException ex) when (ex.Status is AbStatus.TypeMismatch or AbStatus.Unsupported)
+ catch (AbTransportException ex) when (AbCipRefusals.IsDeviceRefusal(ex))
{
- // The controller answered — the tag exists. _status already holds the refusal.
+ // The controller answered. _status already holds the refusal.
}
}
///
- public async Task ReadAsync(CancellationToken cancellationToken)
+ public Task ReadAsync(CancellationToken cancellationToken) => ReadCoreAsync(cancellationToken, mayRelayout: true);
+
+ private async Task ReadCoreAsync(CancellationToken cancellationToken, bool mayRelayout)
{
var client = Client();
var path = Path();
try
{
var (type, handle, data) = await client.ReadAsync(path, _elements, cancellationToken, ExpectedElementBytes(_p.AuthoredType)).ConfigureAwait(false);
+ if (_boolElementBit is not null)
+ {
+ BoolLayout? shown = type == LogixTypeCode.BoolArray ? BoolLayout.Packed
+ : type == LogixTypeCode.Bool ? BoolLayout.PerElement
+ : null; // any other type (a mistyped tag): no layout evidence; the type check below reports it
+ if (shown is { } layout)
+ {
+ var askedPacked = ReferenceEquals(path, _dwordPath);
+ SetBoolLayout(layout);
+ if ((layout == BoolLayout.Packed) != askedPacked)
+ {
+ // The reply is for the OTHER addressing (element n / 32 of a per-element array, or DWORD n of a
+ // packed one) — not this element. Discard it and ask again under the layout it just showed.
+ if (mayRelayout)
+ {
+ await ReadCoreAsync(cancellationToken, mayRelayout: false).ConfigureAwait(false);
+ return;
+ }
+ throw new AbTransportException(AbStatus.TypeMismatch,
+ $"'{_p.TagName}': the controller's BOOL-array layout changed between two reads; retrying next read.");
+ }
+ }
+ }
_data = data;
_typeCode = type;
_structHandle = handle;
@@ -164,6 +230,13 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
}
}
+ /// Settles a BOOL-array element tag's layout and selects the matching request path.
+ private void SetBoolLayout(BoolLayout layout)
+ {
+ _boolLayout = layout;
+ _path = layout == BoolLayout.PerElement ? _elementPath : _dwordPath;
+ }
+
///
/// Upper bound on one element's wire bytes for the scheduler's reply budget; 0 when the
/// authored type is unknown or a structure, which sends the read alone rather than guess.
@@ -196,6 +269,14 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
try
{
+ // An unsettled BOOL-array element layout is settled by a read first, so a write never goes to the
+ // DWORD-addressed element of an element-addressed array (#737 I1). A packed layout's write is refused below
+ // (0xD3 DWORD — it would clobber 31 neighbours); a per-element one writes "Bits[n]" as a BOOL.
+ if (_boolElementBit is not null && _boolLayout == BoolLayout.Unsettled)
+ {
+ await ReadAsync(cancellationToken).ConfigureAwait(false);
+ path = Path();
+ }
if (_pendingString is { } text)
{
// A STRING writes as the type the controller reported — a Logix STRING is a structure
@@ -204,7 +285,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
if (_typeCode is null || (_typeCode == LogixTypeCode.Struct && _structHandle is null))
await ReadAsync(cancellationToken).ConfigureAwait(false);
if (_typeCode == LogixTypeCode.Struct && _structHandle is null)
- throw new AbTransportException(AbStatus.Unsupported,
+ throw new AbCipLocalRefusalException(
$"STRING write to '{_p.TagName}': the controller returned no structure handle.");
var payload = EncodeStringPayload(text);
await client.WriteAsync(path, _typeCode!.Value, _structHandle, 1, payload, cancellationToken)
@@ -213,7 +294,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
else if (_writeBuffer is { } buffer)
{
var type = _typeCode ?? AtomicCode(_p.AuthoredType)
- ?? throw new AbTransportException(AbStatus.Unsupported,
+ ?? throw new AbCipLocalRefusalException(
$"Write to '{_p.TagName}': type unknown — no prior read and no authored atomic type.");
// The read rule decides first: a same-width mismatch (REAL under a DINT-authored tag)
// would otherwise send the int bit pattern as a REAL and report Good.
@@ -223,17 +304,18 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
// Width is checked only for codes with a known width: DATE / DATE_AND_TIME (0xCD/0xCF) and
// Micro800 BYTE/WORD/LWORD (0xD1/0xD2/0xD4) are outside LogixTypeCode's table and write as today.
var width = LogixTypeCode.ByteWidth(type);
- if (width > 0 && width != buffer.Length)
+ if (width > 0 && width * _writeElements != buffer.Length)
{
// An authored BOOL over a packed BOOL-array DWORD: writing the DWORD would clobber the
// 31 sibling bools (libplctag does exactly that), so refuse it as a config error.
- if (type == LogixTypeCode.BoolArray)
+ if (type == LogixTypeCode.BoolArray && _writeElements == 1)
throw new NotSupportedException(
$"Write to '{_p.TagName}': a BOOL-array element (type 0xD3 DWORD) cannot be written as a single BOOL.");
throw new AbTransportException(AbStatus.TypeMismatch,
- $"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) but the controller's type 0x{type:X4} is {width} byte(s) wide.");
+ $"Write to '{_p.TagName}': encoded {buffer.Length} byte(s) for {_writeElements} element(s) but the controller's type 0x{type:X4} is {width} byte(s) wide.");
}
- await client.WriteAsync(path, type, null, 1, buffer, cancellationToken).ConfigureAwait(false);
+ // U10: an array write is ONE Write Tag of _writeElements elements (the client fragments it if large).
+ await client.WriteAsync(path, type, null, _writeElements, buffer, cancellationToken).ConfigureAwait(false);
}
else
{
@@ -276,7 +358,7 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
return type switch
{
- AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : Span(offset, 1)[0] != 0,
+ AbCipDataType.Bool => bitIndex is int bit ? GetBit(bit) : DecodeScalarBool(offset),
AbCipDataType.SInt => (int)(sbyte)Span(offset, 1)[0],
AbCipDataType.USInt => (int)Span(offset, 1)[0],
AbCipDataType.Int => (int)BinaryPrimitives.ReadInt16LittleEndian(Span(offset, 2)),
@@ -315,10 +397,22 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
};
}
+ ///
+ public void EncodeArray(AbCipDataType type, IReadOnlyList values)
+ {
+ ArgumentNullException.ThrowIfNull(values);
+ _writeBuffer = null;
+ _pendingString = null;
+ var buffer = AbCipArrayCodec.Encode(type, values); // throws (nothing staged) before any state changes
+ _writeElements = (ushort)Math.Clamp(values.Count, 1, ushort.MaxValue);
+ _writeBuffer = buffer;
+ }
+
///
public void EncodeValue(AbCipDataType type, int? bitIndex, object? value)
{
_writeBuffer = null;
+ _writeElements = 1;
_pendingString = null;
switch (type)
{
@@ -410,6 +504,47 @@ internal sealed class NativeAbCipTagRuntime : IAbCipTagRuntime
catch (Exception) { /* ForTest only; Dispose must not throw */ }
}
+ ///
+ /// A scalar BOOL. A packed (0xD3) reply is decoded ONLY as the element's own bit of the DWORD the packed layout
+ /// addressed; any other 0xD3 reply (no element bit, or not settled packed) is refused rather than decoded as
+ /// "byte 0 != 0" — that value would be some other bit, published as Good (#737 N1).
+ ///
+ private bool DecodeScalarBool(int offset)
+ {
+ if (offset == 0 && _typeCode == LogixTypeCode.BoolArray)
+ {
+ if (_boolElementBit is int element && _boolLayout == BoolLayout.Packed) return GetBit(element);
+ throw new AbTransportException(AbStatus.TypeMismatch,
+ $"'{_p.TagName}' is a scalar BOOL but the controller answered a packed BOOL-array DWORD (0xD3) with no element bit to select.");
+ }
+ return Span(offset, 1)[0] != 0;
+ }
+
+ ///
+ /// Splits a BOOL-array element reference Name[n] (one non-negative subscript, last segment) into the
+ /// DWORD it lives in (Name[n / 32]) and its bit within that DWORD (n % 32). A Logix BOOL array
+ /// is stored as DWORDs (type 0xD3) and its element segment addresses those DWORDs.
+ ///
+ /// The tag name, bit selector already split off.
+ /// The name addressing the containing DWORD.
+ /// The element's bit within that DWORD.
+ /// when ends in a single subscript.
+ internal static bool TrySplitBoolArrayElement(string name, out string dwordName, out int bit)
+ {
+ dwordName = name;
+ bit = 0;
+ if (!name.EndsWith(']')) return false;
+ var open = name.LastIndexOf('[');
+ if (open <= 0) return false;
+ var inner = name[(open + 1)..^1];
+ if (inner.Length == 0 || !inner.All(char.IsAsciiDigit)
+ || !int.TryParse(inner, System.Globalization.NumberStyles.None, System.Globalization.CultureInfo.InvariantCulture, out var n))
+ return false;
+ dwordName = $"{name[..open]}[{n / 32}]";
+ bit = n % 32;
+ return true;
+ }
+
private byte[] Path() =>
_path ?? throw new InvalidOperationException("InitializeAsync has not been called.");
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.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.FOCAS.Contracts/FocasTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs
index d495dad1..b65150f6 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Contracts/FocasTagDefinitionFactory.cs
@@ -53,6 +53,9 @@ public static class FocasTagDefinitionFactory
// Strict enum read: a typo'd dataType rejects the tag (→ BadNodeIdUnknown)
// instead of silently defaulting to a wrong-width Good.
if (!TagConfigJson.TryReadEnumStrict(root, "dataType", FocasDataType.Int32, out var dataType)) return false;
+ // A data type no read of this address can produce (String on PMC / PARAM / MACRO) rejects the tag:
+ // served, it published a boxed number at Good into a String node.
+ if (DescribeDataTypeMismatch(dataType, address) is not null) return false;
// FOCAS authored tags carry no `writable` key (writability lives on the Tag entity / node ACL):
// absent ⇒ read-only. An explicit writable:true is honoured for the driver's internal write gate
// (default-false, so a missing key stays read-only — the note is preserved).
@@ -73,6 +76,26 @@ public static class FocasTagDefinitionFactory
catch (InvalidOperationException) { return false; }
}
+ ///
+ /// The ONE address/data-type compatibility rule, shared by the mapper (and so the driver's init and the
+ /// deploy inspector), the AdminUI tag editor and the CLI. is only
+ /// servable from the fixed tree (Identity / Program name / mode text …): a PMC, parameter or macro read
+ /// returns a number, which a String node would receive at Good. An ALARM: tag has no value, so its
+ /// data type is irrelevant; an unparseable address is left to the address check, which names it better.
+ ///
+ /// The authored data type.
+ /// The authored address.
+ /// The reason the pair cannot be served, or when it can.
+ public static string? DescribeDataTypeMismatch(FocasDataType dataType, string? address)
+ {
+ if (dataType != FocasDataType.String) return null;
+ var parsed = FocasAddress.TryParse(address);
+ if (parsed is null || parsed.Kind is FocasAreaKind.Fixed or FocasAreaKind.Alarm) return null;
+ return $"FOCAS data type 'String' is not readable at '{address!.Trim()}' — PMC, PARAM: and MACRO: reads " +
+ "return numbers (String is only valid on FIXED: paths such as FIXED:Identity/SeriesNumber). " +
+ "Choose Bit / Byte / Int16 / Int32 / Float32 / Float64.";
+ }
+
///
/// The tag's cross-driver alarm object, or when it carries none.
/// Lenient like the host's TagConfigIntent: a non-integer severity is simply absent.
@@ -236,6 +259,10 @@ public static class FocasTagDefinitionFactory
}
if (TagConfigJson.DescribeInvalidEnum(root, "dataType") is { } w) rejections.Add(w);
+ else if (addr.ValueKind == JsonValueKind.String
+ && TagConfigJson.TryReadEnumStrict(root, "dataType", FocasDataType.Int32, out var dataType)
+ && DescribeDataTypeMismatch(dataType, addr.GetString()) is { } mismatch)
+ rejections.Add(mismatch + " The driver will skip this tag and it will read BadNodeIdUnknown.");
}
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
index cecc6299..bebe023a 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/FocasDriver.cs
@@ -43,7 +43,41 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
private readonly Func? _rebind;
private readonly PollGroupEngine _poll;
private readonly ILogger _logger;
- private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase);
+ ///
+ /// Host address → device state. Built whole in and SWAPPED in (and swapped
+ /// out whole by a release), never cleared or refilled in place: the retained poll engine, the fixed-tree
+ /// and alarm loops and the actor's health tick all read it concurrently with a re-initialise, and a
+ /// mutated under an enumerator throws "Collection was modified".
+ /// Readers take one Volatile.Read snapshot and use it for the whole operation.
+ ///
+ private IReadOnlyDictionary _devices = EmptyDevices;
+
+ private static readonly IReadOnlyDictionary EmptyDevices =
+ new Dictionary(StringComparer.OrdinalIgnoreCase);
+
+ /// The live device table (one consistent snapshot).
+ private IReadOnlyDictionary Devices => Volatile.Read(ref _devices);
+
+ ///
+ /// Guards the publish/retire of the device table against an overlapping lifecycle call, and makes each
+ /// generation-checked health transition atomic (). Held only for synchronous swaps
+ /// and single health transitions — never across an await, and nothing is called under it.
+ ///
+ private readonly object _lifecycleLock = new();
+
+ ///
+ /// Bumped by every and . An initialise that finds
+ /// the counter moved on by the time it would publish has been superseded (the runtime abandoned it and
+ /// tore the driver down) and must start nothing.
+ ///
+ private int _lifecycleGeneration;
+
+ ///
+ /// Completes when every device table released so far has finished its teardown. A shutdown that overlaps
+ /// another (Lane A's dispose beside a still-running shutdown) awaits it, so it never returns while the
+ /// first one is still joining loops and disposing clients.
+ ///
+ private Task _releaseTail = Task.CompletedTask;
// v3: RawPath → definition, case-sensitive ordinal. Built in InitializeAsync from _options.RawTags
// via the pure FocasTagDefinitionFactory mapper (the reference is always a RawPath).
// Built whole in InitializeAsync and SWAPPED in: the retained poll engine keeps calling
@@ -167,10 +201,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
internal void HandlePollError(Exception ex)
{
_logger.LogWarning(ex, "FOCAS poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
- var current = Volatile.Read(ref _health);
- if (current.State != DriverState.Faulted)
- Volatile.Write(ref _health,
- new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message));
+ // The generation the failed batch started under, when ReadAsync stamped it; otherwise the live one.
+ var generation = ex.Data.Contains(BatchGenerationKey) && ex.Data[BatchGenerationKey] is int g ? g : CurrentGeneration;
+ DegradeHealth(generation, ex.Message);
}
///
@@ -196,9 +229,16 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// release the previous device states' loops + clients BEFORE they are overwritten (a live driver can be
// re-initialised without a teardown first). Not ShutdownAsync: that also drops _poll
// and subscriptions, which are owned by the shutdown path (the actor re-subscribes after every successful init).
+ //
+ // The generation is taken FIRST: an initialise the runtime abandons (it timed out and moved on to a
+ // shutdown) is superseded by that shutdown's bump, and checks for it before publishing anything.
+ var generation = Interlocked.Increment(ref _lifecycleGeneration);
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
- ResetAlarmHealth();
- Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
+ WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Initializing, null, null));
+ var devices = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ // Set once this attempt's table is live: from then on it is the NEXT lifecycle call's to release, never
+ // this attempt's (review minor 1 — a reference compare could release a table twice).
+ var published = false;
try
{
// adopt a changed config. Options and the backend client factory are re-derived by ONE
@@ -222,7 +262,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
var addr = FocasHostAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"FOCAS device has invalid HostAddress '{device.HostAddress}' — expected 'focas://{{ip}}[:{{port}}]'.");
- _devices[device.HostAddress] = new DeviceState(addr, device);
+ devices[device.HostAddress] = new DeviceState(addr, device);
}
// 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 + DeviceName are
@@ -256,7 +296,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
throw new InvalidOperationException(
$"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " +
$"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname).");
- if (!_devices.TryGetValue(deviceHost, out var device))
+ if (!devices.TryGetValue(deviceHost, out var device))
throw new InvalidOperationException(
$"FOCAS tag '{entry.RawPath}' references device '{entry.DeviceName}' " +
$"which is not in the Devices list. Check for a typo (e.g. wrong port or hostname).");
@@ -309,75 +349,141 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// above; reusing the parsed record avoids per-tick allocs on subscription pollers.
parsedAddresses[entry.RawPath] = parsed;
}
- // Publish the skip tally only once the whole table is built. An unknown-device throw above
- // leaves the previous snapshot standing rather than a truncated one that would read as
- // "we now skip fewer tags".
- _skippedTags.Commit();
- Volatile.Write(ref _parsedAddressesByTagName, parsedAddresses);
- Volatile.Write(ref _tagsByRawPath, tagsByRawPath);
- Volatile.Write(ref _alarmTags, alarmTags);
-
- if (_options.Probe.Enabled)
+ // Publish everything at once, and only if no later lifecycle call has superseded this one. The check
+ // and the publish share the lock with the shutdown's swap-out, so either the shutdown sees (and
+ // releases) this table, or this initialise sees the shutdown and publishes nothing.
+ lock (_lifecycleLock)
{
- foreach (var state in _devices.Values)
- {
- state.ProbeCts = new CancellationTokenSource();
- var ct = state.ProbeCts.Token;
- state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
- }
- }
+ if (generation != Volatile.Read(ref _lifecycleGeneration))
+ throw new OperationCanceledException(
+ $"FOCAS initialise of {_driverInstanceId} was superseded by a later shutdown/initialise; nothing was started.");
- if (_options.HandleRecycle.Enabled)
- {
- foreach (var state in _devices.Values)
- {
- state.RecycleCts = new CancellationTokenSource();
- var ct = state.RecycleCts.Token;
- state.RecycleTask = Task.Run(() => RecycleLoopAsync(state, ct), ct);
- }
- }
-
- // See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the
- // config flag defaults off and the operator who committed those tags has already said
- // what they want.
- _fixedTreeActive = _options.FixedTree.Enabled
- || _parsedAddressesByTagName.Values.Any(a => a.Kind == FocasAreaKind.Fixed);
- if (_fixedTreeActive)
- {
- foreach (var state in _devices.Values)
- {
- state.FixedTreeCts = new CancellationTokenSource();
- var ct = state.FixedTreeCts.Token;
- state.FixedTreeTask = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
- }
+ // Publish the skip tally only once the whole table is built. An unknown-device throw above
+ // leaves the previous snapshot standing rather than a truncated one that would read as
+ // "we now skip fewer tags".
+ _skippedTags.Commit();
+ // The per-path health of the previous table, reset only now that this attempt is known to be the
+ // live one (review minor 5): a superseded attempt must not wipe the health of the call that
+ // superseded it.
+ ResetAlarmHealth();
+ _refusalWarned.Clear();
+ Volatile.Write(ref _parsedAddressesByTagName, parsedAddresses);
+ Volatile.Write(ref _tagsByRawPath, tagsByRawPath);
+ Volatile.Write(ref _alarmTags, alarmTags);
+ // See _fixedTreeActive: an authored FIXED: tag arms the loop on its own, because the
+ // config flag defaults off and the operator who committed those tags has already said
+ // what they want.
+ _fixedTreeActive = _options.FixedTree.Enabled
+ || parsedAddresses.Values.Any(a => a.Kind == FocasAreaKind.Fixed);
+ Volatile.Write(ref _devices, devices);
+ published = true;
+ StartDeviceLoops(devices.Values);
}
// Restart the alarm poll a re-initialise paused, against the tables just rebuilt. A
// driver with no subscription yet starts nothing — the actor subscribes once it is Connected.
await _alarmProjection.ResumeAsync().ConfigureAwait(false);
+ // A shutdown that landed while the resume ran owns the projection now: put it back the way it left it.
+ if (generation != Volatile.Read(ref _lifecycleGeneration))
+ {
+ await _alarmProjection.PauseAsync().ConfigureAwait(false);
+ throw new OperationCanceledException(
+ $"FOCAS initialise of {_driverInstanceId} was superseded while resuming the alarm poll.");
+ }
- Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
+ WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null));
+ }
+ catch (OperationCanceledException) when (generation != Volatile.Read(ref _lifecycleGeneration))
+ {
+ // Superseded: whatever this attempt built but never published is released here (a published table
+ // is the superseding call's to release); health belongs to that call too.
+ if (!published) await ReleaseTableAsync(devices.Values).ConfigureAwait(false);
+ throw;
}
catch (Exception ex)
{
- Volatile.Write(ref _health, new DriverHealth(DriverState.Faulted, null, ex.Message));
+ WriteHealthIfCurrent(generation, new DriverHealth(DriverState.Faulted, null, ex.Message));
throw;
}
}
+ /// Writes unless a later lifecycle call has taken over.
+ private void WriteHealthIfCurrent(int generation, DriverHealth health)
+ {
+ lock (_lifecycleLock)
+ {
+ if (generation == Volatile.Read(ref _lifecycleGeneration)) Volatile.Write(ref _health, health);
+ }
+ }
+
+ /// Starts the probe / recycle / fixed-tree loops for a freshly built table. Caller holds the lifecycle lock.
+ private void StartDeviceLoops(IEnumerable states)
+ {
+ foreach (var state in states)
+ {
+ if (_options.Probe.Enabled)
+ {
+ state.ProbeCts = new CancellationTokenSource();
+ var ct = state.ProbeCts.Token;
+ state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
+ }
+ if (_options.HandleRecycle.Enabled)
+ {
+ state.RecycleCts = new CancellationTokenSource();
+ var ct = state.RecycleCts.Token;
+ state.RecycleTask = Task.Run(() => RecycleLoopAsync(state, ct), ct);
+ }
+ if (_fixedTreeActive)
+ {
+ state.FixedTreeCts = new CancellationTokenSource();
+ var ct = state.FixedTreeCts.Token;
+ state.FixedTreeTask = Task.Run(() => FixedTreeLoopAsync(state, ct), ct);
+ }
+ }
+ }
+
///
- /// Cancels, bounded-joins and disposes every existing device state (probe / recycle / fixed-tree
- /// loops + client) and the alarm projection, then clears the table. Device part of
- /// only (no poll / subscription teardown).
+ /// Swaps the live device table out (to empty) and releases it: cancels, bounded-joins and disposes every
+ /// device state's loops and client, after pausing the alarm projection. Device part of
+ /// only (no poll / subscription teardown). Also waits for any release an
+ /// overlapping lifecycle call started earlier, so it never returns while that one is still running.
///
private async Task ReleaseDeviceStatesAsync()
{
// Pause, not dispose: the subscription and what the server was told survive the rebuild.
await _alarmProjection.PauseAsync().ConfigureAwait(false);
- if (_devices.Count == 0) return;
- var loops = new List();
- foreach (var state in _devices.Values)
+ IReadOnlyDictionary old;
+ Task prior;
+ var done = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ lock (_lifecycleLock)
{
+ old = _devices;
+ Volatile.Write(ref _devices, EmptyDevices);
+ prior = _releaseTail;
+ _releaseTail = done.Task;
+ }
+ try
+ {
+ if (old.Count > 0) await ReleaseTableAsync(old.Values).ConfigureAwait(false);
+ await prior.ConfigureAwait(false);
+ }
+ finally { done.TrySetResult(); }
+ }
+
+ ///
+ /// Retires and tears down one swapped-out table: retire every device first (so a connect still in flight
+ /// disposes what it opens), cancel every loop, join them (bounded), then dispose the CTSs and — under each
+ /// device's connect gate — its client.
+ ///
+ private async Task ReleaseTableAsync(IEnumerable table)
+ {
+ var states = table.ToList();
+ var loops = new List();
+ foreach (var state in states)
+ {
+ state.Retire();
+ // Cancel-then-dispose can race in tight shutdown loops; swallowing is intentional
+ // but we log the cause so a noisy shutdown leaves a Debug trace.
try { state.ProbeCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); }
try { state.RecycleCts?.Cancel(); }
@@ -388,15 +494,18 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
if (state.RecycleTask is { } rt) loops.Add(rt);
if (state.FixedTreeTask is { } ft) loops.Add(ft);
}
+ // Cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the
+ // client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could
+ // finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver.
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("FOCAS re-initialise: {Count} background loop(s) did not stop within {Timeout}; proceeding",
+ _logger.LogWarning("FOCAS teardown: {Count} background loop(s) did not stop within {Timeout}; proceeding",
loops.Count, LoopJoinTimeout);
}
- foreach (var state in _devices.Values)
+ foreach (var state in states)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
@@ -407,9 +516,22 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
state.FixedTreeCts?.Dispose();
state.FixedTreeCts = null;
state.FixedTreeTask = null;
- state.DisposeClient();
+ await DisposeClientGatedAsync(state).ConfigureAwait(false);
}
- _devices.Clear();
+ }
+
+ ///
+ /// Disposes a device's client under its , so it can neither null a
+ /// client a concurrent just installed nor race that connect. The wait
+ /// is bounded: a connect wedged past may not wedge teardown too (the device
+ /// is retired, so that connect disposes its own client when it finally returns).
+ ///
+ private async Task DisposeClientGatedAsync(DeviceState state)
+ {
+ var entered = await state.ConnectGate.WaitAsync(LoopJoinTimeout).ConfigureAwait(false);
+ try { state.DisposeClient(); }
+ catch (Exception ex) { _logger.LogDebug(ex, "FOCAS client dispose failed for {Host}", state.Options.HostAddress); }
+ finally { if (entered) state.ConnectGate.Release(); }
}
///
@@ -423,88 +545,63 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
internal static readonly TimeSpan LoopJoinTimeout = TimeSpan.FromSeconds(5);
///
+ // Safe to overlap with itself (Lane A disposes beside a still-running shutdown — the second call waits for
+ // the first's teardown via _releaseTail) and with an abandoned InitializeAsync (the generation bump below
+ // supersedes it, so it publishes nothing).
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
+ var generation = Interlocked.Increment(ref _lifecycleGeneration);
await _poll.DisposeAsync().ConfigureAwait(false);
- // Pause, not dispose — see _alarmProjection. A later InitializeAsync resumes it.
- await _alarmProjection.PauseAsync().ConfigureAwait(false);
- // cancel every loop first, JOIN them (bounded), and only then dispose the CTSs and the
- // client. Fire-and-forget loops used to outlive shutdown: one parked in EnsureConnectedAsync could
- // finish its connect AFTER DisposeClient and leave a live socket on a shut-down driver.
- var loops = new List();
- foreach (var state in _devices.Values)
+ // Pauses (not disposes) the alarm projection — see _alarmProjection; a later InitializeAsync resumes it.
+ await ReleaseDeviceStatesAsync().ConfigureAwait(false);
+ lock (_lifecycleLock)
{
- // Cancel-then-dispose can race in tight shutdown loops; swallowing is intentional
- // but we now log the cause so a noisy shutdown leaves a Debug trace.
- try { state.ProbeCts?.Cancel(); }
- catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); }
- try { state.RecycleCts?.Cancel(); }
- catch (Exception ex) { _logger.LogDebug(ex, "Cancelling recycle CTS for {Host} failed", state.Options.HostAddress); }
- try { state.FixedTreeCts?.Cancel(); }
- catch (Exception ex) { _logger.LogDebug(ex, "Cancelling fixed-tree CTS for {Host} failed", state.Options.HostAddress); }
- if (state.ProbeTask is { } pt) loops.Add(pt);
- if (state.RecycleTask is { } rt) loops.Add(rt);
- if (state.FixedTreeTask is { } ft) loops.Add(ft);
+ // A later initialise owns the tables (and health) now — leave them alone.
+ if (generation != Volatile.Read(ref _lifecycleGeneration)) return;
+ Volatile.Write(ref _tagsByRawPath, new Dictionary(StringComparer.Ordinal));
+ Volatile.Write(ref _alarmTags, new Dictionary(StringComparer.Ordinal));
+ Volatile.Write(ref _parsedAddressesByTagName,
+ new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase));
+ _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
+ _fixedTreeActive = false;
+ ResetAlarmHealth();
+ Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
}
- 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("FOCAS shutdown: {Count} background loop(s) did not stop within {Timeout}; proceeding with teardown",
- loops.Count, LoopJoinTimeout);
- }
- foreach (var state in _devices.Values)
- {
- state.ProbeCts?.Dispose();
- state.ProbeCts = null;
- state.ProbeTask = null;
- state.RecycleCts?.Dispose();
- state.RecycleCts = null;
- state.RecycleTask = null;
- state.FixedTreeCts?.Dispose();
- state.FixedTreeCts = null;
- state.FixedTreeTask = null;
- state.DisposeClient();
- }
- _devices.Clear();
- Volatile.Write(ref _tagsByRawPath, new Dictionary(StringComparer.Ordinal));
- Volatile.Write(ref _alarmTags, new Dictionary(StringComparer.Ordinal));
- _resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
- _parsedAddressesByTagName.Clear();
- _fixedTreeActive = false;
- ResetAlarmHealth();
- Volatile.Write(ref _health, new DriverHealth(DriverState.Unknown, Volatile.Read(ref _health).LastSuccessfulRead, null));
}
///
- // The worst of the value path (_health) and the alarm path (_alarmTransportFailures),
- // computed together. LastSuccessfulRead is the later of the two paths' last successes: while either
- // path still reads, the comms-loss watchdog stays quiet; when both have stopped it is frozen.
- // Faulted / Unknown / Initializing come from the value (lifecycle) path unchanged.
+ // The worst of the value path (_health), the alarm path (_alarmTransportFailures) and the fixed-tree path
+ // (_fixedTreeFailures), computed together. LastSuccessfulRead is the latest of the three paths' last
+ // successes: while any path still reads, the comms-loss watchdog stays quiet; when all have stopped it is
+ // frozen. Faulted / Unknown / Initializing come from the value (lifecycle) path unchanged.
public DriverHealth GetHealth()
{
var value = Volatile.Read(ref _health);
- var ticks = Interlocked.Read(ref _alarmLastSuccessTicks);
- DateTime? alarmLast = ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc);
- var last = value.LastSuccessfulRead is { } v && (alarmLast is null || v >= alarmLast) ? v : alarmLast;
+ var last = Latest(Latest(value.LastSuccessfulRead, Stamp(Interlocked.Read(ref _alarmLastSuccessTicks))),
+ Stamp(Interlocked.Read(ref _fixedTreeLastSuccessTicks)));
if (value.State is not (DriverState.Healthy or DriverState.Degraded)) return value;
- if (value.State == DriverState.Healthy && !_alarmTransportFailures.IsEmpty)
- {
- var reason = _alarmTransportFailures.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase).First().Value;
+ if (value.State == DriverState.Healthy
+ && (FirstFailure(_alarmTransportFailures) ?? FirstFailure(_fixedTreeFailures)) is { } reason)
return new DriverHealth(DriverState.Degraded, last, reason);
- }
return value.LastSuccessfulRead == last ? value : value with { LastSuccessfulRead = last };
+
+ static DateTime? Stamp(long ticks) => ticks == 0 ? null : new DateTime(ticks, DateTimeKind.Utc);
+ static DateTime? Latest(DateTime? a, DateTime? b) => a is null ? b : b is null || a >= b ? a : b;
+ static string? FirstFailure(ConcurrentDictionary failures) => failures.IsEmpty
+ ? null
+ : failures.OrderBy(kv => kv.Key, StringComparer.OrdinalIgnoreCase).Select(kv => kv.Value).FirstOrDefault();
}
/// Gets the number of configured devices.
- internal int DeviceCount => _devices.Count;
+ internal int DeviceCount => Devices.Count;
+ /// Test seam — the live device table snapshot (swapped whole, never mutated in place).
+ internal IReadOnlyDictionary DeviceTable => Devices;
/// Gets the state of a device by host address.
/// The host address of the device.
/// The device state if found; otherwise null.
internal DeviceState? GetDeviceState(string hostAddress) =>
- _devices.TryGetValue(hostAddress, out var s) ? s : null;
+ Devices.TryGetValue(hostAddress, out var s) ? s : null;
/// Test seam — the resolved options the factory built this driver from.
internal FocasDriverOptions Options => _options;
@@ -548,23 +645,25 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// ---- IReadable ----
///
+ // Devices are read CONCURRENTLY, each one's tags in order on its own (serialised) socket: a slow CNC — a
+ // TCP-accept-then-stall control costs its Timeout per tag — used to hold every other device's read in the
+ // batch hostage, because the whole batch was one sequential walk.
public async Task> ReadAsync(
IReadOnlyList fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
+ // The generation FIRST, then one snapshot of each swapped table for the whole call (see _devices): health
+ // written by this batch is dropped if a lifecycle call has moved on meanwhile.
+ var generation = CurrentGeneration;
+ var devices = Devices;
+ var alarmTags = Volatile.Read(ref _alarmTags);
+ var fixedTreeActive = _fixedTreeActive;
- // Merge adjacent / near PMC reads of one device into fewer pmc_rdpmcrng calls. Indices it
- // serves are marked here and skipped by the per-tag loop below; everything else (including every
- // single-tag range and any range the CNC rejected) takes the unchanged per-tag path.
- var served = _options.PmcCoalesce.Enabled
- ? await CoalescePmcReadsAsync(fullReferences, results, now, cancellationToken).ConfigureAwait(false)
- : null;
-
+ Dictionary>? byDevice = null;
for (var i = 0; i < fullReferences.Count; i++)
{
- if (served is not null && served[i]) continue;
var reference = fullReferences[i];
// Fixed-tree T1 — fixed-tree references are synthesized from the cached
@@ -574,63 +673,155 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// This branch matches a reference that IS a "{host}/{path}" fixed-tree key. Authored
// tags do not take it — their reference is a RawPath — and are served further down by
// their FIXED: address instead.
- if (_fixedTreeActive && TryReadFixedTree(reference, now) is { } fx)
+ if (fixedTreeActive && TryReadFixedTree(devices, reference, now) is { } fx)
{
results[i] = fx;
continue;
}
// An ALARM: tag is a Part 9 condition, not a value — there is nothing to read.
- if (Volatile.Read(ref _alarmTags).ContainsKey(reference))
+ if (alarmTags.ContainsKey(reference))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNotSupported, null, now);
continue;
}
+ if (!_resolver.TryResolve(reference, out var def)
+ || !devices.TryGetValue(def.DeviceHostAddress, out var device))
+ {
+ results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
+ continue;
+ }
+
+ byDevice ??= [];
+ if (!byDevice.TryGetValue(device, out var indices)) byDevice[device] = indices = [];
+ indices.Add(i);
+ }
+
+ if (byDevice is null) return results;
+ try
+ {
+ if (byDevice.Count == 1)
+ {
+ foreach (var (device, indices) in byDevice)
+ await ReadDeviceAsync(device, indices, fullReferences, results, now, generation, cancellationToken).ConfigureAwait(false);
+ }
+ else
+ {
+ await Task.WhenAll(byDevice.Select(kv =>
+ ReadDeviceAsync(kv.Key, kv.Value, fullReferences, results, now, generation, cancellationToken))).ConfigureAwait(false);
+ }
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // Stamp the generation this batch STARTED under (review round 2, m3), so the poll engine's onError
+ // (HandlePollError) cannot degrade a generation that re-initialised after the batch began.
+ ex.Data[BatchGenerationKey] = generation;
+ throw;
+ }
+ return results;
+ }
+
+ /// key carrying the lifecycle generation a failed batch started under.
+ internal const string BatchGenerationKey = "FocasDriver.BatchGeneration";
+
+ /// Test seam — the live lifecycle generation.
+ internal int LifecycleGeneration => CurrentGeneration;
+
+ ///
+ /// Reads one device's share of a batch, in request order: the PMC coalescing pre-pass first, then every
+ /// index it did not serve through the per-tag path. Each result lands at its own index, so concurrent
+ /// device reads never touch the same slot.
+ ///
+ ///
+ /// One link failure ends the device's batch (#740 review I1). Concurrency alone does not bound a
+ /// batch: Task.WhenAll waits for the slowest device, and a CNC that accepts TCP and then stalls costs
+ /// the full call timeout on EVERY tag — so a 10-tag stalled device held the healthy devices' values back by
+ /// ten timeouts. After the first timeout / transport failure / connect failure on this device — i.e. NO
+ /// reply arrived — its remaining tags in the batch fail fast with BadCommunicationError, exactly as the coalescer's
+ /// FailMembers does for a merged range. Fixed-tree tags are still served from the cache: they need no
+ /// link. Any status the CNC RETURNED — a refusal, or a comms-classed rc such as EW_REJECT — does not end the
+ /// batch: a reply proves the link answered.
+ ///
+ private async Task ReadDeviceAsync(
+ DeviceState device, List indices, IReadOnlyList fullReferences,
+ DataValueSnapshot[] results, DateTime now, int generation, CancellationToken cancellationToken)
+ {
+ // Merge adjacent / near PMC reads of the device into fewer pmc_rdpmcrng calls. Indices it serves are
+ // marked here and skipped by the per-tag loop below; everything else (including every single-tag range
+ // and any range the CNC rejected) takes the unchanged per-tag path.
+ var (served, linkDown) = _options.PmcCoalesce.Enabled
+ ? await CoalescePmcReadsAsync(device, indices, fullReferences, results, now, generation, cancellationToken).ConfigureAwait(false)
+ : (null, false);
+
+ foreach (var i in indices)
+ {
+ if (served is not null && served.Contains(i)) continue;
+ var reference = fullReferences[i];
if (!_resolver.TryResolve(reference, out var def))
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
- if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
+
+ FocasAddress parsed;
+ try { parsed = ResolveParsedAddress(def); }
+ catch (InvalidOperationException)
{
+ // A malformed address is a config fault of this tag, not of the link.
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
+ // A FIXED: tag names a field of this device's fixed-tree snapshot, which the
+ // fixed-tree loop already maintains — there is no area read to issue, and no
+ // connection to force. A null here means the path resolves to nothing on THIS
+ // device (an unsupported capability, a renamed axis, a typo), which is a genuine
+ // BadNodeIdUnknown; a device that has not answered yet reports a comms fault
+ // instead, because the address may well be fine.
+ if (parsed.Kind == FocasAreaKind.Fixed)
+ {
+ results[i] = TryReadFixedTreeOnDevice(device, parsed.FixedPath!, now)
+ ?? new DataValueSnapshot(
+ null,
+ device.LastFixedPollUtc is null
+ ? FocasStatusMapper.BadCommunicationError
+ : FocasStatusMapper.BadNodeIdUnknown,
+ null,
+ now);
+ continue;
+ }
+
+ if (linkDown)
+ {
+ // Already failed (and health already degraded) this batch — don't pay another timeout.
+ results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
+ continue;
+ }
+
try
{
- var parsed = ResolveParsedAddress(def);
-
- // A FIXED: tag names a field of this device's fixed-tree snapshot, which the
- // fixed-tree loop already maintains — there is no area read to issue, and no
- // connection to force. A null here means the path resolves to nothing on THIS
- // device (an unsupported capability, a renamed axis, a typo), which is a genuine
- // BadNodeIdUnknown; a device that has not answered yet reports a comms fault
- // instead, because the address may well be fine.
- if (parsed.Kind == FocasAreaKind.Fixed)
- {
- results[i] = TryReadFixedTreeOnDevice(device, parsed.FixedPath!, now)
- ?? new DataValueSnapshot(
- null,
- device.LastFixedPollUtc is null
- ? FocasStatusMapper.BadCommunicationError
- : FocasStatusMapper.BadNodeIdUnknown,
- null,
- now);
- continue;
- }
-
var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var (value, status) = await client.ReadAsync(parsed, def.DataType, cancellationToken).ConfigureAwait(false);
results[i] = new DataValueSnapshot(value, status, now, now);
if (status == FocasStatusMapper.Good)
- Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, now, null));
+ {
+ _refusalWarned.TryRemove(reference, out _);
+ CreditHealth(generation, now);
+ }
+ else if (IsCommsStatus(status))
+ {
+ // Degrade, but do NOT end the batch (#740 review round 2, N1): this status came back in a reply,
+ // so the CNC answered — EW_REJECT and every unlisted rc map to BadCommunicationError. Ending the
+ // batch here starved every later tag of a reachable control forever (authored order is fixed).
+ // A session that is really dead throws or times out on the next tag, which does end it.
+ DegradeHealth(generation, $"FOCAS status 0x{status:X8} reading {reference}");
+ }
else
- Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
- Volatile.Read(ref _health).LastSuccessfulRead,
- $"FOCAS status 0x{status:X8} reading {reference}"));
+ {
+ NoteRefusal(reference, def.Address, status, now, generation);
+ }
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; }
catch (OperationCanceledException)
@@ -639,22 +830,84 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Timeout budget. Surface a recoverable comm error so the BadWaitingForInitialData
// seed is overwritten and health degrades, instead of the read hanging forever.
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
- Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
- Volatile.Read(ref _health).LastSuccessfulRead, $"FOCAS read timed out for {reference}"));
+ DegradeHealth(generation, $"FOCAS read timed out for {reference}");
+ linkDown = true;
}
catch (Exception ex)
{
results[i] = new DataValueSnapshot(null, FocasStatusMapper.BadCommunicationError, null, now);
- Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
- Volatile.Read(ref _health).LastSuccessfulRead, ex.Message));
+ DegradeHealth(generation, ex.Message);
+ linkDown = true;
}
}
-
- return results;
}
///
- /// PMC coalescing pre-pass. Groups the batch's PMC tags by device, plans merged ranges
+ /// Applies a data-path health transition — only while is still the live
+ /// lifecycle generation (#740 review minor 8): a read that started against the previous table must not
+ /// write its Degraded onto the next initialise's health. Taken under the lifecycle lock, so the check and
+ /// the write cannot straddle a publish; the lock is uncontended outside a lifecycle transition.
+ ///
+ /// The generation the caller's operation started under.
+ /// The transition; returns its input unchanged to leave health alone.
+ private void UpdateHealth(int generation, Func next)
+ {
+ lock (_lifecycleLock)
+ {
+ if (generation != Volatile.Read(ref _lifecycleGeneration)) return;
+ Volatile.Write(ref _health, next(Volatile.Read(ref _health)));
+ }
+ }
+
+ /// A comms fault: Degraded with LastSuccessfulRead preserved; never downgrades Faulted.
+ private void DegradeHealth(int generation, string reason) =>
+ UpdateHealth(generation, h => h.State == DriverState.Faulted
+ ? h
+ : new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, reason));
+
+ /// An answered read: Healthy with LastSuccessfulRead advanced.
+ private void CreditHealth(int generation, DateTime now) =>
+ UpdateHealth(generation, _ => new DriverHealth(DriverState.Healthy, now, null));
+
+ /// The live lifecycle generation — captured by a data-path operation when it starts.
+ private int CurrentGeneration => Volatile.Read(ref _lifecycleGeneration);
+
+ ///
+ /// RawPaths whose CNC refusal has already been logged — a refusal warns ONCE per tag, not once per poll,
+ /// and re-arms when the tag next reads Good. Cleared at initialise.
+ ///
+ private readonly ConcurrentDictionary _refusalWarned = new(StringComparer.Ordinal);
+
+ ///
+ /// True for a status that says the LINK failed (or the read never got an answer), as opposed to the CNC
+ /// answering with a refusal. maps EW_SOCKET / EW_UNEXP /
+ /// EW_PARITY / unknown codes to BadCommunicationError and EW_HANDLE to BadInternalError; the wire client
+ /// reports a dead session as BadCommunicationError.
+ ///
+ private static bool IsCommsStatus(uint status) => status is FocasStatusMapper.BadCommunicationError
+ or FocasStatusMapper.BadInternalError or FocasStatusMapper.BadTimeout;
+
+ ///
+ /// A deterministic CNC refusal (drivers.md health contract): per-tag Bad only — one Warning per tag, no
+ /// state change — but the CNC DID answer, so LastSuccessfulRead advances (else a reachable control whose
+ /// only tags are refused would look silent to the comms-loss watchdog).
+ ///
+ private void NoteRefusal(string reference, string address, uint status, DateTime now, int generation)
+ {
+ // The link answered, so a value-path Degraded left by an earlier comms fault is over (review minor 2): a
+ // device that recovers and then only refuses must not stay Degraded forever. Under the lifecycle lock
+ // (UpdateHealth), so concurrent device reads cannot interleave a read-modify-write.
+ UpdateHealth(generation, h => h.State is DriverState.Healthy or DriverState.Degraded
+ ? new DriverHealth(DriverState.Healthy, h.LastSuccessfulRead is { } l && l > now ? l : now, null)
+ : h);
+ if (_refusalWarned.TryAdd(reference, 0))
+ _logger.LogWarning(
+ "FOCAS CNC refused {RawPath} ({Address}) with 0x{Status:X8}; the tag reads Bad until it is fixed (warned once). Driver={DriverInstanceId}",
+ reference, address, status, _driverInstanceId);
+ }
+
+ ///
+ /// PMC coalescing pre-pass for ONE device's share of a batch. Plans merged ranges
/// () and serves every multi-member range with ONE
/// pmc_rdpmcrng, slicing each tag's value out of the returned slots.
///
@@ -667,110 +920,109 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// leaves the members UNSERVED, so the per-tag loop reads them individually and each gets its own
/// true status — and the range is remembered so later polls skip the doomed merged read.
///
- /// Returns null when nothing was served.
+ /// Returns the served indices, or null when nothing was served.
///
- private async Task CoalescePmcReadsAsync(
- IReadOnlyList references, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
+ private async Task<(HashSet? Served, bool LinkDown)> CoalescePmcReadsAsync(
+ DeviceState device, List indices, IReadOnlyList references, DataValueSnapshot[] results,
+ DateTime now, int generation, CancellationToken ct)
{
- Dictionary>? byDevice = null;
- for (var i = 0; i < references.Count; i++)
+ List? items = null;
+ foreach (var i in indices)
{
try
{
- var reference = references[i];
- if (_fixedTreeActive && TryReadFixedTree(reference, now) is not null) continue;
- if (!_resolver.TryResolve(reference, out var def)) continue;
- if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
+ if (!_resolver.TryResolve(references[i], out var def)) continue;
var parsed = ResolveParsedAddress(def);
if (parsed.Kind != FocasAreaKind.Pmc || parsed.PmcLetter is null) continue;
- (byDevice ??= []).TryAdd(device, []);
- byDevice[device].Add(new FocasPmcReadItem(i, parsed.PmcLetter, parsed.Number, def.DataType, parsed.BitIndex));
+ (items ??= []).Add(new FocasPmcReadItem(i, parsed.PmcLetter, parsed.Number, def.DataType, parsed.BitIndex));
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
/* malformed / unresolvable — the per-tag loop reports it with its usual status */
}
}
- if (byDevice is null) return null;
+ if (items is null || items.Count < 2) return (null, false);
- bool[]? served = null;
- foreach (var (device, items) in byDevice)
+ var plans = FocasPmcCoalescer.Plan(items, _options.PmcCoalesce.MaxSpanBytes, _options.PmcCoalesce.MaxGapBytes);
+ if (!plans.Any(p => p.Members.Count >= 2 && !device.CoalesceRejected.ContainsKey(p.Key))) return (null, false);
+
+ HashSet? served = null;
+ var linkDown = false;
+ // One connect attempt per device per batch: if it fails, every PMC tag of the device fails with
+ // that status here instead of paying a connect attempt per plan and again per tag.
+ IFocasClient client;
+ try { client = await EnsureConnectedAsync(device, ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception)
{
- if (items.Count < 2) continue;
- var plans = FocasPmcCoalescer.Plan(items, _options.PmcCoalesce.MaxSpanBytes, _options.PmcCoalesce.MaxGapBytes);
- if (!plans.Any(p => p.Members.Count >= 2 && !device.CoalesceRejected.ContainsKey(p.Key))) continue;
+ FailMembers(items, FocasStatusMapper.BadCommunicationError,
+ $"FOCAS connect failed reading PMC tags of {device.Options.HostAddress}");
+ return (served, true);
+ }
- // One connect attempt per device per batch: if it fails, every PMC tag of the device fails with
- // that status here instead of paying a connect attempt per plan and again per tag.
- IFocasClient client;
- try { client = await EnsureConnectedAsync(device, ct).ConfigureAwait(false); }
- catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
- catch (Exception)
+ foreach (var plan in plans)
+ {
+ if (plan.Members.Count < 2) continue;
+ if (device.CoalesceRejected.ContainsKey(plan.Key)) continue;
+ if (linkDown)
{
- FailMembers(items, FocasStatusMapper.BadCommunicationError, references, results, now, ref served,
- $"FOCAS connect failed reading PMC tags of {device.Options.HostAddress}");
+ // The link failed on an earlier range this batch: fail the rest without another timeout.
+ FailMembers(plan.Members, FocasStatusMapper.BadCommunicationError,
+ $"FOCAS transport failure on {device.Options.HostAddress} earlier in this batch");
continue;
}
- foreach (var plan in plans)
+ var members = plan.Members;
+ try
{
- if (plan.Members.Count < 2) continue;
- if (device.CoalesceRejected.ContainsKey(plan.Key)) continue;
+ var (slots, status) = await client
+ .ReadPmcSlotsAsync(plan.Letter, plan.SlotType, plan.Start, plan.End, ct)
+ .ConfigureAwait(false);
- var members = plan.Members;
- try
+ if (status == FocasStatusMapper.Good && slots is not null)
{
- var (slots, status) = await client
- .ReadPmcSlotsAsync(plan.Letter, plan.SlotType, plan.Start, plan.End, ct)
- .ConfigureAwait(false);
-
- if (status == FocasStatusMapper.Good && slots is not null)
+ foreach (var m in members)
{
- foreach (var m in members)
- {
- var slot = plan.SlotOf(m);
- results[m.Index] = slot < slots.Count
- ? new DataValueSnapshot(FocasPmcCoalescer.MapSlot(slots[slot], m.Type, m.BitIndex), status, now, now)
- : new DataValueSnapshot(null, FocasStatusMapper.BadOutOfRange, now, now);
- (served ??= new bool[references.Count])[m.Index] = true;
- }
- Volatile.Write(ref _health, new DriverHealth(DriverState.Healthy, now, null));
- }
- else
- {
- // The CNC REFUSED the merged range (any return code, mapped or not). Don't guess which
- // member is at fault: let the per-tag loop give each its own answer. Remember the range
- // so later polls skip the doomed read — except for a transient refusal (EW_BUSY maps to
- // BadDeviceFailure), which says nothing about the range.
- if (status != FocasStatusMapper.BadDeviceFailure)
- device.CoalesceRejected[plan.Key] = 0;
- _logger.LogDebug(
- "FOCAS PMC range {Key} on {Host} rejected (0x{Status:X8}) — reading its {Count} tags individually",
- plan.Key, device.Options.HostAddress, status, members.Count);
+ var slot = plan.SlotOf(m);
+ results[m.Index] = slot < slots.Count
+ ? new DataValueSnapshot(FocasPmcCoalescer.MapSlot(slots[slot], m.Type, m.BitIndex), status, now, now)
+ : new DataValueSnapshot(null, FocasStatusMapper.BadOutOfRange, now, now);
+ (served ??= []).Add(m.Index);
}
+ CreditHealth(generation, now);
}
- catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
- catch (Exception)
+ else
{
- // Transport failure / timeout: the link is suspect, so fail the members without per-tag retries.
- FailMembers(members, FocasStatusMapper.BadCommunicationError, references, results, now, ref served,
- $"FOCAS transport failure reading PMC range {plan.Key}");
+ // The CNC REFUSED the merged range (any return code, mapped or not). Don't guess which
+ // member is at fault: let the per-tag loop give each its own answer. Remember the range
+ // so later polls skip the doomed read — except for a transient refusal (EW_BUSY maps to
+ // BadDeviceFailure), which says nothing about the range.
+ if (status != FocasStatusMapper.BadDeviceFailure)
+ device.CoalesceRejected[plan.Key] = 0;
+ _logger.LogDebug(
+ "FOCAS PMC range {Key} on {Host} rejected (0x{Status:X8}) — reading its {Count} tags individually",
+ plan.Key, device.Options.HostAddress, status, members.Count);
}
}
+ catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
+ catch (Exception)
+ {
+ // Transport failure / timeout: the link is suspect, so fail the members without per-tag retries.
+ FailMembers(members, FocasStatusMapper.BadCommunicationError,
+ $"FOCAS transport failure reading PMC range {plan.Key}");
+ linkDown = true;
+ }
}
- return served;
+ return (served, linkDown);
- void FailMembers(IEnumerable failed, uint status, IReadOnlyList refs,
- DataValueSnapshot[] res, DateTime stamp, ref bool[]? servedRef, string reason)
+ void FailMembers(IEnumerable failed, uint status, string reason)
{
foreach (var m in failed)
{
- res[m.Index] = new DataValueSnapshot(null, status, null, stamp);
- (servedRef ??= new bool[refs.Count])[m.Index] = true;
+ results[m.Index] = new DataValueSnapshot(null, status, null, now);
+ (served ??= []).Add(m.Index);
}
- Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
- Volatile.Read(ref _health).LastSuccessfulRead,
- $"{reason} (0x{status:X8})"));
+ DegradeHealth(generation, $"{reason} (0x{status:X8})");
}
}
@@ -781,6 +1033,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
IReadOnlyList writes, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
+ var generation = CurrentGeneration;
var results = new WriteResult[writes.Count];
for (var i = 0; i < writes.Count; i++)
@@ -802,7 +1055,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
results[i] = new WriteResult(FocasStatusMapper.BadNotWritable);
continue;
}
- if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
+ if (!Devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(FocasStatusMapper.BadNodeIdUnknown);
continue;
@@ -831,14 +1084,14 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Per-call timeout (not external cancellation) — the write stalled past the device
// Timeout budget. Surface a recoverable comm error rather than aborting the batch.
results[i] = new WriteResult(FocasStatusMapper.BadCommunicationError);
- Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
- Volatile.Read(ref _health).LastSuccessfulRead, $"FOCAS write timed out for {w.FullReference}"));
+ DegradeHealth(generation, $"FOCAS write timed out for {w.FullReference}");
}
catch (NotSupportedException nse)
{
+ // A refusal, not a comms fault (drivers.md, #611): nothing was wrong with the link, so health is
+ // left alone — degrading here handed the comms-loss watchdog a reason to reconnect a healthy CNC.
results[i] = new WriteResult(FocasStatusMapper.BadNotSupported);
- Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
- Volatile.Read(ref _health).LastSuccessfulRead, nse.Message));
+ _logger.LogDebug(nse, "FOCAS write to {RawPath} refused as unsupported", w.FullReference);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException)
{
@@ -851,8 +1104,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
catch (Exception ex)
{
results[i] = new WriteResult(FocasStatusMapper.BadCommunicationError);
- Volatile.Write(ref _health, new DriverHealth(DriverState.Degraded,
- Volatile.Read(ref _health).LastSuccessfulRead, ex.Message));
+ DegradeHealth(generation, ex.Message);
}
}
@@ -890,7 +1142,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Fixed-tree T1 — Identity + Axes subtrees, populated once per session
// from cnc_sysinfo + cnc_rdaxisname at init time and kept in DeviceState.
if (_fixedTreeActive
- && _devices.TryGetValue(device.HostAddress, out var state)
+ && Devices.TryGetValue(device.HostAddress, out var state)
&& state.FixedTreeCache is { } cache)
{
var identity = deviceFolder.Folder("Identity", "Identity");
@@ -1142,7 +1394,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
///
public IReadOnlyList GetHostStatuses() =>
- [.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
+ [.. Devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
{
@@ -1204,6 +1456,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Each optional call is attempted once; failures (EW_FUNC / EW_NOOPT / EW_VERSION)
// record the capability as unsupported and suppress the corresponding nodes
// in DiscoverAsync + the poll loop.
+ state.FixedTreeStartedUtc = DateTime.UtcNow;
while (!ct.IsCancellationRequested && state.FixedTreeCache is null)
{
try
@@ -1234,6 +1487,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// so stamp liveness here as well as per tick, or a CNC whose axis poll never
// succeeds would serve a perfectly valid sysinfo as stale.
state.LastFixedPollUtc = DateTime.UtcNow;
+ NoteFixedTreeAnswered(state);
// Published LAST (release write): LastStatus is the field callers watch for "the first
// answer is in", and an authored FIXED:Status/* read is only servable once
// FixedTreeCache and LastFixedPollUtc are set (TryReadFixedTreeOnDevice). Publishing it
@@ -1245,6 +1499,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
_logger.LogDebug(ex, "FOCAS fixed-tree bootstrap failed for {Host} — retrying",
state.Options.HostAddress);
+ NoteFixedTreeFailed(state, ex);
try { await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
@@ -1293,6 +1548,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// of the loop: EnsureConnectedAsync can hand back a cached handle without any wire
// traffic, so it alone does not prove the CNC is still there.
state.LastFixedPollUtc = DateTime.UtcNow;
+ // …and it is the driver's evidence of life too: a fixed-only driver has no value read to credit.
+ NoteFixedTreeAnswered(state);
// Servo loads + spindle loads — both return bulk arrays, so folding
// into the axis cadence is cheap. Each is gated by the bootstrap
@@ -1388,6 +1645,8 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/* next tick retries — transient blips are expected */
_logger.LogDebug(ex, "FOCAS fixed-tree poll tick failed for {Host}",
state.Options.HostAddress);
+ // …but a sustained silence is a comms loss, and nothing else will report it for a fixed-only driver.
+ NoteFixedTreeFailed(state, ex);
}
try { await Task.Delay(_options.FixedTree.PollInterval, ct).ConfigureAwait(false); }
@@ -1508,12 +1767,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// the reference looks like a fixed-tree FullName; null when it doesn't
/// (callers fall through to the user-authored tag path).
///
- private DataValueSnapshot? TryReadFixedTree(string reference, DateTime now)
+ private DataValueSnapshot? TryReadFixedTree(
+ IReadOnlyDictionary devices, string reference, DateTime now)
{
- foreach (var state in _devices.Values)
+ foreach (var state in devices.Values)
{
- if (!reference.StartsWith(state.Options.HostAddress + "/", StringComparison.OrdinalIgnoreCase)) continue;
- if (TryReadFixedTreeOnDevice(state, reference[(state.Options.HostAddress.Length + 1)..], now) is { } hit)
+ if (!reference.StartsWith(state.FixedTreePrefix, StringComparison.OrdinalIgnoreCase)) continue;
+ if (TryReadFixedTreeOnDevice(state, reference[state.FixedTreePrefix.Length..], now) is { } hit)
return hit;
}
return null;
@@ -1549,7 +1809,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
var polledUtc = state.LastFixedPollUtc;
if (polledUtc is null) return null;
- var status = now - polledUtc.Value > FixedTreeStaleAfter(_options.FixedTree.PollInterval)
+ var status = now - polledUtc.Value > FixedTreeStaleBudget
? FocasStatusMapper.BadCommunicationError
: FocasStatusMapper.Good;
var sourceUtc = polledUtc.Value;
@@ -1651,6 +1911,13 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
return null;
}
+ ///
+ /// How long a handle recycle waits for in-flight ops on the detached client: one call timeout (each op is
+ /// bounded by it) plus slack, so a recycle never kills a read that would have completed.
+ ///
+ private TimeSpan RecycleDrainBudget =>
+ (_options.Timeout > TimeSpan.Zero ? _options.Timeout : TimeSpan.FromSeconds(2)) + TimeSpan.FromMilliseconds(500);
+
private async Task RecycleLoopAsync(DeviceState state, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
@@ -1660,14 +1927,31 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
// Close the current handle — the next Read / Write / Probe call triggers
// EnsureConnectedAsync, which reopens a fresh one. We don't block here on
- // reconnect because the goal is just to release the FWLIB handle slot; a
- // readable tick one probe cycle later is an acceptable cost.
- try { state.DisposeClient(); }
+ // reconnect because the goal is just to release the FWLIB handle slot.
+ //
+ // Under the device's ConnectGate, exactly like EnsureConnectedAsync's install: unserialised, the
+ // recycle could null a client a concurrent connect had just installed (orphaning a live two-socket
+ // session) or dispose one a poll was mid-read on (BadCommunicationError + Degraded every interval).
+ // The gate is held through the drain so no fresh session opens beside the old one — the CNC's
+ // handle pool is small — and the detached client is disposed only once the ops already holding it
+ // have finished (bounded by the call timeout).
+ try { await state.ConnectGate.WaitAsync(ct).ConfigureAwait(false); }
+ catch (OperationCanceledException) { break; }
+ try
+ {
+ var old = state.Client;
+ state.Client = null;
+ if (old is SynchronizedFocasClient sync)
+ await sync.DisposeWhenIdleAsync(RecycleDrainBudget).ConfigureAwait(false);
+ else
+ old?.Dispose();
+ }
catch (Exception ex)
{
/* already disposed or race — next EnsureConnected recovers */
_logger.LogDebug(ex, "FOCAS handle-recycle dispose failed for {Host}", state.Options.HostAddress);
}
+ finally { state.ConnectGate.Release(); }
}
}
@@ -1764,6 +2048,59 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
{
_alarmTransportFailures.Clear();
Interlocked.Exchange(ref _alarmLastSuccessTicks, 0);
+ _fixedTreeFailures.Clear();
+ Interlocked.Exchange(ref _fixedTreeLastSuccessTicks, 0);
+ }
+
+ ///
+ /// The FIXED-TREE half of driver health: device host → why its fixed-tree loop has had no answer for longer
+ /// than the staleness budget. Kept apart from like the alarm path, and for the same
+ /// reason — a driver serving only FIXED: tags has no value read to degrade it, so before this it
+ /// read Healthy on /hosts with every node Bad and the #527 watchdog could never fire.
+ ///
+ private readonly ConcurrentDictionary _fixedTreeFailures = new(StringComparer.OrdinalIgnoreCase);
+
+ /// UTC ticks of the last answered fixed-tree poll on any device (0 = none).
+ private long _fixedTreeLastSuccessTicks;
+
+ /// Test seam: replaces the fixed-tree staleness budget (production derives it from the poll interval).
+ internal TimeSpan? FixedTreeStaleBudgetOverride { get; init; }
+
+ ///
+ /// How long a device's fixed tree may go unanswered before its values read Bad AND the driver degrades —
+ /// one budget for both, so the node quality and the health surface cannot disagree.
+ ///
+ private TimeSpan FixedTreeStaleBudget =>
+ FixedTreeStaleBudgetOverride ?? FixedTreeStaleAfter(_options.FixedTree.PollInterval);
+
+ /// An answered fixed-tree pass of : clears its failure, advances LastSuccessfulRead.
+ private void NoteFixedTreeAnswered(DeviceState state)
+ {
+ if (state.IsRetired) return;
+ _fixedTreeFailures.TryRemove(state.Options.HostAddress, out _);
+ Interlocked.Exchange(ref _fixedTreeLastSuccessTicks, DateTime.UtcNow.Ticks);
+ }
+
+ ///
+ /// A failed fixed-tree tick. A CNC REFUSAL (non-OK rc, transport fine) is not a comms loss: it clears any
+ /// failure AND credits the answer to LastSuccessfulRead (the CNC replied); a transport failure degrades the driver only once the device has been
+ /// silent past (measured from its last answer, or from loop start when
+ /// it never answered), so one dropped tick on a slow control does not flap /hosts.
+ ///
+ private void NoteFixedTreeFailed(DeviceState state, Exception error)
+ {
+ if (state.IsRetired) return; // a released device's late tick says nothing about the live table
+ if (FocasAlarmProjection.IsCncRefusal(error))
+ {
+ // The CNC answered — evidence of life (review minor 2): clear the failure AND credit the answer, so a
+ // control that only refuses after an outage does not look silent to the comms-loss watchdog.
+ NoteFixedTreeAnswered(state);
+ return;
+ }
+ var since = state.LastFixedPollUtc ?? state.FixedTreeStartedUtc;
+ if (DateTime.UtcNow - since > FixedTreeStaleBudget)
+ _fixedTreeFailures[state.Options.HostAddress] =
+ $"FOCAS fixed-tree poll of {state.Options.HostAddress} has had no answer since {since:O}: {error.Message}";
}
///
@@ -1776,7 +2113,7 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// The device's active alarms.
internal async Task> ReadDeviceAlarmsAsync(string hostAddress, CancellationToken ct)
{
- if (!_devices.TryGetValue(hostAddress, out var state))
+ if (!Devices.TryGetValue(hostAddress, out var state))
throw new InvalidOperationException($"FOCAS device '{hostAddress}' is not configured.");
var client = await EnsureConnectedAsync(state, ct).ConfigureAwait(false);
return await client.ReadAlarmsAsync(ct).ConfigureAwait(false);
@@ -1822,6 +2159,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false);
try
{
+ // A released device (re-initialise / shutdown) is never reconnected: whoever still holds it read
+ // the table before the swap, and a client opened now would be orphaned on a state nobody tears down.
+ if (device.IsRetired)
+ throw new ObjectDisposedException(nameof(DeviceState),
+ $"FOCAS device {device.Options.HostAddress} was released by a re-initialise or shutdown.");
+
// Another caller may have connected while this one waited.
if (device.Client is { IsConnected: true } connected) return connected;
@@ -1864,6 +2207,15 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
throw;
}
device.Backoff.RecordSuccess(); // recovery resets the window immediately
+ // Retired WHILE this connect ran (the release waits on this gate only up to its bound): the connect
+ // is ours to undo, or it leaves a live session on a shut-down driver.
+ if (device.IsRetired)
+ {
+ client.Dispose();
+ if (ReferenceEquals(device.Client, client)) device.Client = null;
+ throw new ObjectDisposedException(nameof(DeviceState),
+ $"FOCAS device {device.Options.HostAddress} was released while connecting.");
+ }
return client;
}
finally { device.ConnectGate.Release(); }
@@ -1928,6 +2280,11 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public FocasHostAddress ParsedAddress { get; } = parsedAddress;
/// Gets the device configuration options.
public FocasDeviceOptions Options { get; } = options;
+ ///
+ /// "{HostAddress}/" — the prefix of this device's fixed-tree cache keys, built once rather than
+ /// per reference per poll (twice per poll before #740).
+ ///
+ public string FixedTreePrefix { get; } = options.HostAddress + "/";
/// Gets or sets the FOCAS client instance.
public IFocasClient? Client { get; set; }
@@ -1981,6 +2338,9 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// it falls outside .
///
public DateTime? LastFixedPollUtc { get; set; }
+
+ /// When this device's fixed-tree loop started — the silence origin for a device that never answered.
+ public DateTime FixedTreeStartedUtc { get; set; } = DateTime.UtcNow;
///
/// Gets the last fixed-tree snapshots by field name. Double-typed so axis
/// positions can carry the engineering-unit scale applied at
@@ -2020,6 +2380,17 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
///
public ConcurrentDictionary LastSpindleMotorSpeeds { get; } = new();
+ private int _retired;
+
+ ///
+ /// True once a re-initialise / shutdown has released this device. A retired device never connects
+ /// again, and a connect that was already running when it retired disposes what it opened.
+ ///
+ public bool IsRetired => Volatile.Read(ref _retired) != 0;
+
+ /// Marks the device released. Idempotent.
+ public void Retire() => Volatile.Write(ref _retired, 1);
+
/// Disposes the FOCAS client instance.
public void DisposeClient()
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs
index 9dc81fca..a0bade40 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/SynchronizedFocasClient.cs
@@ -32,6 +32,9 @@ public sealed class SynchronizedFocasClient : IFocasClient
private readonly IFocasClient _inner;
private readonly TimeSpan _callTimeout;
private readonly SemaphoreSlim _gate = new(1, 1);
+ /// Ops holding or queued for — what drains.
+ private int _pending;
+ private int _disposed;
/// Wraps with per-device serialization + a per-call timeout.
/// The underlying FOCAS client to serialize access to.
@@ -115,26 +118,66 @@ public sealed class SynchronizedFocasClient : IFocasClient
public Task> GetSpindleMotorSpeedsAsync(CancellationToken cancellationToken) =>
RunBoundedAsync(ct => _inner.GetSpindleMotorSpeedsAsync(ct), cancellationToken);
- /// Disposes the inner client and the call gate.
+ ///
+ /// Disposes the inner client. Ops still queued on the call gate then fail with
+ /// instead of running on a disposed socket. The gate itself is NOT
+ /// disposed: an op that already holds it releases it on the way out, and releasing a disposed
+ /// threw out of a read that had otherwise completed.
+ ///
public void Dispose()
{
+ if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
_inner.Dispose();
- _gate.Dispose();
+ }
+
+ ///
+ /// Disposes once every op that already holds (or is queued for) this client has finished, waiting at most
+ /// . Used by the handle-recycle loop, which detaches the client under the device's
+ /// connect gate first — so nothing new can reach it — and then must not kill a read that is mid-flight on
+ /// it (that read used to surface BadCommunicationError + Degraded on every recycle tick).
+ ///
+ /// Upper bound on the wait; the client is disposed regardless once it passes.
+ /// A task that completes when the client is disposed.
+ public async Task DisposeWhenIdleAsync(TimeSpan maxWait)
+ {
+ var deadline = DateTime.UtcNow + maxWait;
+ while (Volatile.Read(ref _pending) > 0 && DateTime.UtcNow < deadline)
+ await Task.Delay(5, CancellationToken.None).ConfigureAwait(false);
+ Dispose();
+ }
+
+ /// Gets the number of ops currently holding or queued for the call gate. Test seam.
+ internal int PendingOperations => Volatile.Read(ref _pending);
+
+ private void ThrowIfDisposed()
+ {
+ if (Volatile.Read(ref _disposed) != 0)
+ throw new ObjectDisposedException(nameof(SynchronizedFocasClient), "The FOCAS client was recycled or disposed.");
}
// Gate only — the caller already governs the budget (connect timeout arg / probe linked token).
private async Task RunGatedAsync(Func> op, CancellationToken ct)
{
- await _gate.WaitAsync(ct).ConfigureAwait(false);
- try { return await op(ct).ConfigureAwait(false); }
- finally { _gate.Release(); }
+ Interlocked.Increment(ref _pending);
+ try
+ {
+ await _gate.WaitAsync(ct).ConfigureAwait(false);
+ try { ThrowIfDisposed(); return await op(ct).ConfigureAwait(false); }
+ finally { _gate.Release(); }
+ }
+ finally { Interlocked.Decrement(ref _pending); }
}
private async Task RunGatedAsync(Func op, CancellationToken ct)
{
- await _gate.WaitAsync(ct).ConfigureAwait(false);
- try { await op(ct).ConfigureAwait(false); }
- finally { _gate.Release(); }
+ Interlocked.Increment(ref _pending);
+ try
+ {
+ await _gate.WaitAsync(ct).ConfigureAwait(false);
+ try { ThrowIfDisposed(); await op(ct).ConfigureAwait(false); }
+ finally { _gate.Release(); }
+ }
+ finally { Interlocked.Decrement(ref _pending); }
}
// Gate + per-call timeout. A fired timeout surfaces as OperationCanceledException whose token is
@@ -142,16 +185,22 @@ public sealed class SynchronizedFocasClient : IFocasClient
// their own token's IsCancellationRequested.
private async Task RunBoundedAsync(Func> op, CancellationToken ct)
{
- await _gate.WaitAsync(ct).ConfigureAwait(false);
+ Interlocked.Increment(ref _pending);
try
{
- if (_callTimeout <= TimeSpan.Zero)
- return await op(ct).ConfigureAwait(false);
+ await _gate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ ThrowIfDisposed();
+ if (_callTimeout <= TimeSpan.Zero)
+ return await op(ct).ConfigureAwait(false);
- using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
- linked.CancelAfter(_callTimeout);
- return await op(linked.Token).ConfigureAwait(false);
+ using var linked = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ linked.CancelAfter(_callTimeout);
+ return await op(linked.Token).ConfigureAwait(false);
+ }
+ finally { _gate.Release(); }
}
- finally { _gate.Release(); }
+ finally { Interlocked.Decrement(ref _pending); }
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs
index db407d18..fd76bf66 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireClient.cs
@@ -467,18 +467,41 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// Optional path ID override; defaults to .
/// The raw parameter payload bytes, wrapped in a .
public async Task> ReadParameterBytesAsync(
- short dataNumber,
+ int dataNumber,
short axis = 0,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
{
using var callTimeout = CreateCallTimeout(cancellationToken, timeout);
- var secondArg = axis == 0 ? dataNumber : axis;
- var block = await SendSingleRequestAsync(callTimeout.Token, new RequestBlock(0x000e, dataNumber, secondArg, PathId: EffectivePathId(pathId))).ConfigureAwait(false);
+ var block = await SendSingleRequestAsync(callTimeout.Token, ParameterRequest(dataNumber, axis, EffectivePathId(pathId))).ConfigureAwait(false);
return ToResult(block, payload => payload);
}
+ ///
+ /// The cnc_rdparam (0x000e) request block. The data number travels in the 32-bit
+ /// Arg1 UNNARROWED — the request block has always carried arguments, and
+ /// a short cast in the adapter once asked the CNC for a different (wrapped) number than the
+ /// one authored. A global parameter repeats the number in Arg2; an axis parameter carries
+ /// the axis there.
+ ///
+ /// The parameter data number.
+ /// Axis selector; zero means a global parameter.
+ /// The CNC path.
+ /// The request block.
+ internal static RequestBlock ParameterRequest(int dataNumber, short axis, ushort pathId) =>
+ new(0x000e, dataNumber, axis == 0 ? dataNumber : axis, PathId: pathId);
+
+ ///
+ /// The cnc_rdmacro (0x0015) request block: the variable number, unnarrowed, in both 32-bit
+ /// arguments (start / end of a one-variable range) — byte-identical to the live 31i-B capture for #500.
+ ///
+ /// The macro variable number.
+ /// The CNC path.
+ /// The request block.
+ internal static RequestBlock MacroRequest(int number, ushort pathId) =>
+ new(0x0015, number, number, PathId: pathId);
+
/// Typed Int32 parameter read — convenience over .
/// The parameter data number.
/// The FOCAS parameter type code.
@@ -487,7 +510,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// Optional path ID override; defaults to .
/// The typed parameter value, wrapped in a .
public async Task> ReadParameterAsync(
- short dataNumber,
+ int dataNumber,
short type = 0,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
@@ -507,7 +530,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// Optional per-call timeout override.
/// Optional path ID override; defaults to .
/// The typed 8-bit parameter value, wrapped in a .
- public async Task> ReadParameterByteAsync(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
+ public async Task> ReadParameterByteAsync(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null
@@ -522,7 +545,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// Optional per-call timeout override.
/// Optional path ID override; defaults to .
/// The typed 16-bit parameter value, wrapped in a .
- public async Task> ReadParameterInt16Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
+ public async Task> ReadParameterInt16Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null
@@ -537,7 +560,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// Optional per-call timeout override.
/// Optional path ID override; defaults to .
/// The typed 32-bit parameter value, wrapped in a .
- public async Task> ReadParameterInt32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
+ public async Task> ReadParameterInt32Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null
@@ -552,7 +575,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// Optional per-call timeout override.
/// Optional path ID override; defaults to .
/// The typed single-precision parameter value, wrapped in a .
- public async Task> ReadParameterFloat32Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
+ public async Task> ReadParameterFloat32Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null || result.Value.Length < 4
@@ -567,7 +590,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// Optional per-call timeout override.
/// Optional path ID override; defaults to .
/// The typed double-precision parameter value, wrapped in a .
- public async Task> ReadParameterFloat64Async(short dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
+ public async Task> ReadParameterFloat64Async(int dataNumber, short axis = 0, CancellationToken cancellationToken = default, TimeSpan? timeout = null, ushort? pathId = null)
{
var result = await ReadParameterBytesAsync(dataNumber, axis, cancellationToken, timeout, pathId).ConfigureAwait(false);
return !result.IsOk || result.Value is null || result.Value.Length < 8
@@ -582,14 +605,17 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// Optional path ID override; defaults to .
/// The macro variable value, wrapped in a .
public Task> ReadMacroAsync(
- short number,
+ int number,
CancellationToken cancellationToken = default,
TimeSpan? timeout = null,
ushort? pathId = null)
- => ReadSingleWithTimeoutAsync(
- 0x0015,
+ {
+ var request = MacroRequest(number, EffectivePathId(pathId));
+ return ReadSingleWithTimeoutAsync(
+ request.Command,
payload => ParseMacro(number, payload),
- cancellationToken, timeout, EffectivePathId(pathId), number, number);
+ cancellationToken, timeout, request.PathId, request.Arg1, request.Arg2);
+ }
///
/// Decode a cnc_rdmacro (0x0015) payload. The on-wire ODBM record is 8
@@ -609,7 +635,7 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// The response-block payload.
/// The decoded macro.
/// The payload is shorter than 8 bytes.
- internal static WireMacro ParseMacro(short number, byte[] payload)
+ internal static WireMacro ParseMacro(int number, byte[] payload)
{
RequireLength(payload, 8, "cnc_rdmacro");
return new WireMacro(number, ReadInt32(payload, 0), ReadInt16(payload, 6));
@@ -1111,15 +1137,88 @@ public sealed class FocasWireClient : IAsyncDisposable, IDisposable
/// The requested record count.
/// The decoded alarms.
/// The payload length matches neither record layout.
+ ///
+ /// Choosing the layout. The response block carries no record count, so a length that is a
+ /// multiple of BOTH strides (1,520 B = 20 × 76 = 19 × 80, and its multiples) is ambiguous; picking the
+ /// vendor stride on Length % 76 == 0 alone decoded 19 legacy records as 20 garbage ones. The choice
+ /// now uses what IS known: (1) the record count the REQUEST asked for — a reply never holds more, so a
+ /// layout implying more records than is ruled out; (2) each layout's own field
+ /// invariants, checked on every record ( / ).
+ /// Only when both readings remain plausible does the vendor layout win, as before.
+ ///
internal static IReadOnlyList ParseAlarms(byte[] payload, short count)
{
- if (payload.Length % 76 == 0) return ParseVendorAlarms(payload, count);
- if (payload.Length % 80 == 0) return ParseLegacyWireAlarms(payload, count);
+ if (payload.Length == 0) return [];
+ var vendor = payload.Length % VendorAlarmStride == 0 && payload.Length / VendorAlarmStride <= Math.Max((int)count, 1);
+ var legacy = payload.Length % LegacyAlarmStride == 0 && payload.Length / LegacyAlarmStride <= Math.Max((int)count, 1);
+ if (vendor && legacy)
+ {
+ // Ambiguous length: a legacy record's int32 type/axis/length fields must each fit the short they are
+ // read into and its length must fit the 64-byte message — a vendor payload read at the legacy stride
+ // fails that on its first non-empty message. Prefer the reading whose every record is self-consistent.
+ var legacyOk = LegacyRecordsValid(payload);
+ var vendorOk = VendorRecordsValid(payload);
+ if (legacyOk && !vendorOk) vendor = false;
+ else if (legacyOk && vendorOk && LengthsMatchText(payload, LegacyAlarmStride, 12, 4, 16)
+ && !LengthsMatchText(payload, VendorAlarmStride, 10, 2, 12)) vendor = false;
+ }
+ if (vendor) return ParseVendorAlarms(payload, count);
+ if (legacy) return ParseLegacyWireAlarms(payload, count);
+ if (payload.Length % VendorAlarmStride == 0 || payload.Length % LegacyAlarmStride == 0)
+ {
+ // Fits a stride but holds more records than were requested: fall back to the stride it fits, capped
+ // at the count, exactly as before this check existed.
+ return payload.Length % VendorAlarmStride == 0
+ ? ParseVendorAlarms(payload, count)
+ : ParseLegacyWireAlarms(payload, count);
+ }
throw new FocasWireException(
$"cnc_rdalmmsg2 returned a {payload.Length}-byte payload, which fits neither the 76- nor the 80-byte " +
"record layout; refusing to read it as an empty alarm list.", rc: null);
}
+ private const int VendorAlarmStride = 76;
+ private const int LegacyAlarmStride = 80;
+ private const int AlarmMessageBytes = 64;
+
+ /// Every 80-byte record's int32 type / axis / length fit a short, and the length fits the message.
+ private static bool LegacyRecordsValid(byte[] payload)
+ {
+ for (var o = 0; o + LegacyAlarmStride <= payload.Length; o += LegacyAlarmStride)
+ {
+ var type = ReadInt32(payload, o + 4);
+ var axis = ReadInt32(payload, o + 8);
+ var length = ReadInt32(payload, o + 12);
+ if (type is < short.MinValue or > short.MaxValue) return false;
+ if (axis is < short.MinValue or > short.MaxValue) return false;
+ if (length is < 0 or > AlarmMessageBytes) return false;
+ }
+ return true;
+ }
+
+ /// Every 76-byte record's i16 message length fits the 64-byte message.
+ private static bool VendorRecordsValid(byte[] payload)
+ {
+ for (var o = 0; o + VendorAlarmStride <= payload.Length; o += VendorAlarmStride)
+ {
+ var length = ReadInt16(payload, o + 10);
+ if (length is < 0 or > AlarmMessageBytes) return false;
+ }
+ return true;
+ }
+
+ /// Every record's declared message length equals the length of the text actually in its message field.
+ private static bool LengthsMatchText(byte[] payload, int stride, int lengthOffset, int lengthWidth, int messageOffset)
+ {
+ for (var o = 0; o + stride <= payload.Length; o += stride)
+ {
+ var length = lengthWidth == 4 ? ReadInt32(payload, o + lengthOffset) : ReadInt16(payload, o + lengthOffset);
+ var text = FocasWireProtocol.ReadAscii(payload.AsSpan(o + messageOffset, AlarmMessageBytes));
+ if (length != text.Length) return false;
+ }
+ return true;
+ }
+
private static IReadOnlyList ParseVendorAlarms(byte[] payload, short count)
{
var alarms = new List();
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs
index ac6f32ee..70ca55f6 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/FocasWireModels.cs
@@ -170,7 +170,7 @@ public readonly record struct WireSpindleRecord(short Index, string Name);
/// Parameter value returned by cnc_rdparam, interpreted as a scalar Int32.
public sealed record WireParameter(
- short DataNumber,
+ int DataNumber,
short Type,
int Value);
@@ -184,7 +184,7 @@ public sealed record WireParameter(
/// The 9-significant-digit mantissa.
/// Implied decimal places; negative (0xFFFF on the wire) = vacant.
public sealed record WireMacro(
- short Number,
+ int Number,
int Value,
short Decimal)
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs
index d6d5a3a4..ec44e6e3 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS/Wire/WireFocasClient.cs
@@ -80,6 +80,7 @@ public sealed class WireFocasClient : IFocasClient
var area = FocasPmcAreaLookup.FromLetter(letter);
if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
var dataType = FocasPmcDataTypeLookup.FromFocasDataType(slotType);
+ if (start < 0 || end > ushort.MaxValue || end < start) return (null, FocasStatusMapper.BadOutOfRange);
try
{
var result = await _wire.ReadPmcRangeAsync(area.Value, dataType, (ushort)start, (ushort)end, cancellationToken)
@@ -334,18 +335,38 @@ public sealed class WireFocasClient : IFocasClient
// ---- PMC / Parameter / Macro read paths ------------------------------------------
+ ///
+ /// The 16-bit byte range pmc_rdpmcrng addresses for a slot of bytes at
+ /// . False when any byte of it falls outside 0..65535 — the range is refused
+ /// rather than wrapped onto a different PMC address (an Unknown-series device admits any number).
+ ///
+ /// The authored PMC byte address.
+ /// The slot width in bytes (≥ 1).
+ /// The first byte, when representable.
+ /// The last byte, when representable.
+ /// True when the whole slot is addressable.
+ internal static bool TryPmcRange(int number, int width, out ushort start, out ushort end)
+ {
+ start = end = 0;
+ var last = (long)number + Math.Max(width, 1) - 1;
+ if (number < 0 || last > ushort.MaxValue) return false;
+ start = (ushort)number;
+ end = (ushort)last;
+ return true;
+ }
+
private async Task<(object? value, uint status)> ReadPmcAsync(
FocasAddress address, FocasDataType type, CancellationToken cancellationToken)
{
var area = FocasPmcAreaLookup.FromLetter(address.PmcLetter ?? string.Empty);
if (area is null) return (null, FocasStatusMapper.BadNodeIdUnknown);
var dataType = FocasPmcDataTypeLookup.FromFocasDataType(type);
- var start = (ushort)address.Number;
// pmc_rdpmcrng returns (end-start+1) BYTES. A multi-byte slot (Word/Long/Real/Double) needs
// the range widened to its byte width, else the value parser gets too few bytes → 0 values
// → spurious BadOutOfRange (live-confirmed on the 31i-B: a Word read with end=start returned
// a single byte). Bit/Byte stay width 1 so end==start. 2026-06-25.
- var end = (ushort)(start + FocasWireClient.PmcByteWidth((short)dataType) - 1);
+ if (!TryPmcRange(address.Number, FocasWireClient.PmcByteWidth((short)dataType), out var start, out var end))
+ return (null, FocasStatusMapper.BadOutOfRange);
try
{
@@ -373,23 +394,23 @@ public sealed class WireFocasClient : IFocasClient
switch (type)
{
case FocasDataType.Byte:
- var b = await _wire.ReadParameterByteAsync((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
+ var b = await _wire.ReadParameterByteAsync(address.Number, 0, cancellationToken).ConfigureAwait(false);
return b.IsOk ? ((object)(sbyte)b.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(b.Rc));
case FocasDataType.Int16:
- var s = await _wire.ReadParameterInt16Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
+ var s = await _wire.ReadParameterInt16Async(address.Number, 0, cancellationToken).ConfigureAwait(false);
return s.IsOk ? ((object)s.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(s.Rc));
case FocasDataType.Float32:
- var f = await _wire.ReadParameterFloat32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
+ var f = await _wire.ReadParameterFloat32Async(address.Number, 0, cancellationToken).ConfigureAwait(false);
return f.IsOk ? ((object)f.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(f.Rc));
case FocasDataType.Float64:
- var d = await _wire.ReadParameterFloat64Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
+ var d = await _wire.ReadParameterFloat64Async(address.Number, 0, cancellationToken).ConfigureAwait(false);
return d.IsOk ? ((object)d.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(d.Rc));
case FocasDataType.Bit when address.BitIndex is int bit:
- var bi = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
+ var bi = await _wire.ReadParameterInt32Async(address.Number, 0, cancellationToken).ConfigureAwait(false);
if (!bi.IsOk) return (null, FocasStatusMapper.MapFocasReturn(bi.Rc));
return ((object)((bi.Value >> bit & 1) != 0), FocasStatusMapper.Good);
default:
- var i = await _wire.ReadParameterInt32Async((short)address.Number, 0, cancellationToken).ConfigureAwait(false);
+ var i = await _wire.ReadParameterInt32Async(address.Number, 0, cancellationToken).ConfigureAwait(false);
return i.IsOk ? ((object)i.Value, FocasStatusMapper.Good) : (null, FocasStatusMapper.MapFocasReturn(i.Rc));
}
}
@@ -404,7 +425,7 @@ public sealed class WireFocasClient : IFocasClient
{
try
{
- var result = await _wire.ReadMacroAsync((short)address.Number, cancellationToken).ConfigureAwait(false);
+ var result = await _wire.ReadMacroAsync(address.Number, cancellationToken).ConfigureAwait(false);
if (!result.IsOk || result.Value is null)
return (null, FocasStatusMapper.MapFocasReturn(result.Rc));
return MapMacro(result.Value, type, address.BitIndex);
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
index c7ab8850..649998b0 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/AdsTwinCATClient.cs
@@ -469,6 +469,20 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
{
try
{
+ if (type is TwinCATDataType.String or TwinCATDataType.WString)
+ {
+ // A STRING / WSTRING is written as raw bytes at the PLC's own declared width, encoded by the
+ // same codec the read decodes with: Windows-1252 (or UTF-8 when the target says so) rather
+ // than the host's ANSI code page, NUL-padded so no stale tail survives, and refused
+ // (OverflowException → BadOutOfRange) when it does not fit STRING(n).
+ var (shape, _, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
+ .ConfigureAwait(false);
+ if (symbolStatus != TwinCATStatusMapper.Good) return symbolStatus;
+ var (planStatus, encoded) = PlanStringWrite(type, shape, value, StringsAreUtf8());
+ if (planStatus != TwinCATStatusMapper.Good) return planStatus;
+ return await WriteRawAsync(symbolPath, symbol!, encoded, cancellationToken).ConfigureAwait(false);
+ }
+
var converted = ConvertForWrite(type, value);
var result = await _client.WriteValueAsync(symbolPath, converted, cancellationToken)
.ConfigureAwait(false);
@@ -482,6 +496,123 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
}
}
+ ///
+ /// The pure encode plan of a STRING / WSTRING write against the RESOLVED symbol: the declared kind must
+ /// match the PLC's (, else BadTypeMismatch), then the value is
+ /// encoded at the PLC's byte width by .
+ ///
+ /// The declared String / WString type.
+ /// The resolved PLC symbol shape.
+ /// The written value.
+ /// True when the target reports UTF-8 STRING data.
+ /// Good with the bytes to write, or the refusal status with no bytes.
+ /// The value does not fit the PLC string.
+ internal static (uint status, byte[] bytes) PlanStringWrite(
+ TwinCATDataType type, TwinCATPlcSymbolShape shape, object? value, bool utf8)
+ {
+ if (TwinCATTypeGuard.CheckRead(type, shape) is not null) return (TwinCATStatusMapper.BadTypeMismatch, []);
+ var text = Convert.ToString(value, System.Globalization.CultureInfo.InvariantCulture) ?? string.Empty;
+ return (TwinCATStatusMapper.Good, type == TwinCATDataType.WString
+ ? TwinCATStringCodec.EncodeWString(text, shape.ByteSize)
+ : TwinCATStringCodec.EncodeString(text, shape.ByteSize, utf8));
+ }
+
+ ///
+ /// The pure encode plan of an array write against the RESOLVED PLC array: the layout comes from
+ /// (the array read's rule, else BadTypeMismatch) and the
+ /// bytes from .
+ ///
+ /// The declared element type.
+ /// The converted elements.
+ /// The resolved PLC symbol shape.
+ /// The resolved PLC array shape, or null for a non-array symbol.
+ /// True when the target reports UTF-8 STRING data.
+ /// Good with the bytes to write, or the refusal status with no bytes.
+ /// A string element does not fit its stride.
+ internal static (uint status, byte[] bytes) PlanArrayWrite(TwinCATDataType type, IReadOnlyList elements,
+ TwinCATPlcSymbolShape shape, TwinCATPlcArrayShape? array, bool utf8)
+ {
+ if (TwinCATTypeGuard.CheckArray(type, elements.Count, shape, array, out var layout) is not null)
+ return (TwinCATStatusMapper.BadTypeMismatch, []);
+ return (TwinCATStatusMapper.Good, TwinCATArrayCodec.Encode(type, elements, layout, utf8));
+ }
+
+ ///
+ public async Task WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList elements,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ // Plan the layout from the RESOLVED PLC array exactly as the array read does, so the element
+ // stride (incl. a STRING(n) element's n+1) and the strict element-type rule are shared.
+ var (shape, array, symbol, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
+ .ConfigureAwait(false);
+ if (symbolStatus != TwinCATStatusMapper.Good) return symbolStatus;
+ var (planStatus, bytes) = PlanArrayWrite(type, elements, shape, array, StringsAreUtf8());
+ if (planStatus != TwinCATStatusMapper.Good) return planStatus;
+ return await WriteRawAsync(symbolPath, symbol!, bytes, cancellationToken).ConfigureAwait(false);
+ }
+ catch (AdsErrorException ex)
+ {
+ return MapAndSignal((uint)ex.ErrorCode);
+ }
+ }
+
+ ///
+ public async Task WriteBitAsync(string parentPath, int bit, bool value, CancellationToken cancellationToken)
+ {
+ try
+ {
+ // Resolve the parent so the RMW happens at its OWN width (the notification path's model):
+ // the old 32-bit UDInt round-trip wrote a 16-bit parent's neighbours and overflowed on a
+ // signed parent with its sign bit set.
+ var (parent, _, symbol, status) = await DescribeSymbolAsync(parentPath, cancellationToken)
+ .ConfigureAwait(false);
+ if (status != TwinCATStatusMapper.Good) return status;
+ if (TwinCATBitAccess.CheckParent(bit, parent) is not null)
+ return TwinCATBitAccess.ApplyBitWrite(parent, ReadOnlySpan.Empty, bit, value, out _);
+
+ var (bytes, readStatus) = await ReadRawAsync(parentPath, symbol!, parent.ByteSize, cancellationToken)
+ .ConfigureAwait(false);
+ if (readStatus != TwinCATStatusMapper.Good) return readStatus;
+ var modify = TwinCATBitAccess.ApplyBitWrite(parent, bytes, bit, value, out var updated);
+ if (modify != TwinCATStatusMapper.Good) return modify;
+ return await WriteRawAsync(parentPath, symbol!, updated, cancellationToken).ConfigureAwait(false);
+ }
+ catch (AdsErrorException ex)
+ {
+ return MapAndSignal((uint)ex.ErrorCode);
+ }
+ }
+
+ ///
+ /// Writes raw bytes to a resolved symbol — the mirror of : by its index
+ /// group/offset, or through a fresh variable handle for a handle-addressed symbol (0xF005).
+ ///
+ private async Task WriteRawAsync(
+ string symbolPath, IAdsSymbol symbol, ReadOnlyMemory data, CancellationToken cancellationToken)
+ {
+ if (symbol.IndexGroup != SymbolValueByHandle)
+ {
+ var direct = await _client.WriteAsync(symbol.IndexGroup, symbol.IndexOffset, data, cancellationToken)
+ .ConfigureAwait(false);
+ return direct.ErrorCode == AdsErrorCode.NoError ? TwinCATStatusMapper.Good : MapAndSignal((uint)direct.ErrorCode);
+ }
+
+ var handle = await _client.CreateVariableHandleAsync(symbolPath, cancellationToken).ConfigureAwait(false);
+ if (handle.ErrorCode != AdsErrorCode.NoError) return MapAndSignal((uint)handle.ErrorCode);
+ try
+ {
+ var write = await _client.WriteAsync(handle.Handle, data, cancellationToken).ConfigureAwait(false);
+ return write.ErrorCode == AdsErrorCode.NoError ? TwinCATStatusMapper.Good : MapAndSignal((uint)write.ErrorCode);
+ }
+ finally
+ {
+ try { await _client.DeleteVariableHandleAsync(handle.Handle, CancellationToken.None).ConfigureAwait(false); }
+ catch { /* best-effort release */ }
+ }
+ }
+
///
public async Task ProbeAsync(CancellationToken cancellationToken)
{
@@ -523,8 +654,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var (parent, _, _, parentStatus) = await DescribeSymbolAsync(registerPath, cancellationToken)
.ConfigureAwait(false);
if (parentStatus != TwinCATStatusMapper.Good)
- throw new InvalidOperationException(
- $"Resolving {registerPath} for a notification failed with status 0x{parentStatus:X8}");
+ throw RegistrationFailure(registerPath, "resolving the parent word", parentStatus);
if (TwinCATBitAccess.CheckParent(bit, parent) is { } bitReason)
throw new TwinCATTypeMismatchException(symbolPath, bitReason);
clrType = typeof(byte[]);
@@ -538,8 +668,7 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
var (shape, array, _, symbolStatus) = await DescribeSymbolAsync(symbolPath, cancellationToken)
.ConfigureAwait(false);
if (symbolStatus != TwinCATStatusMapper.Good)
- throw new InvalidOperationException(
- $"Resolving {symbolPath} for a notification failed with status 0x{symbolStatus:X8}");
+ throw RegistrationFailure(symbolPath, "resolving the symbol", symbolStatus);
if (arrayCount is int count)
{
@@ -582,8 +711,8 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
registerPath, settings, userData: null, clrType, anyTypeArgs, cancellationToken)
.ConfigureAwait(false);
if (result.ErrorCode != AdsErrorCode.NoError)
- throw new InvalidOperationException(
- $"AddDeviceNotificationExAsync failed with ADS error {result.ErrorCode} for {registerPath}");
+ throw RegistrationFailure(registerPath, $"AddDeviceNotificationEx (ADS error {result.ErrorCode})",
+ MapAndSignal((uint)result.ErrorCode));
var reg = new NotificationRegistration(symbolPath, type, bitIndex, onChange, this, result.Handle)
{
@@ -594,6 +723,17 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
return reg;
}
+ ///
+ /// Classifies a failed notification registration. A status proving the device ANSWERED (symbol not
+ /// found, invalid access, PLC not ready, …) is a per-tag refusal the driver isolates without a health
+ /// write; a comms-class status (BadCommunicationError / BadTimeout) stays an ordinary
+ /// exception — a comms fault the driver degrades on and retries.
+ ///
+ internal static Exception RegistrationFailure(string symbolPath, string step, uint status) =>
+ status is TwinCATStatusMapper.BadCommunicationError or TwinCATStatusMapper.BadTimeout
+ ? new InvalidOperationException($"{step} for {symbolPath} failed with status 0x{status:X8}")
+ : new TwinCATNotificationRefusedException(symbolPath, $"{step} failed with status 0x{status:X8}", status);
+
///
/// Runs on the AMS router thread. Does the cheap decode (bit
/// extraction, string, array) then enqueues — no driver logic, no consumer callbacks — so a slow consumer
@@ -618,15 +758,24 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
value = DecodeString(reg.Type, raw, reg.StringsUtf8);
else if (TwinCATTemporalCodec.IsTemporal(reg.Type) && value is uint rawTemporal)
value = TwinCATTemporalCodec.Decode(reg.Type, rawTemporal);
- _notificationQueue.Writer.TryWrite(new PendingNotification(reg, value));
+ if (!_notificationQueue.Writer.TryWrite(new PendingNotification(reg, value)))
+ Interlocked.Increment(ref _droppedNotifications); // queue full (DropWrite): count, never block
}
+ private long _droppedNotifications;
+
+ ///
+ public long DroppedNotificationCount => Interlocked.Read(ref _droppedNotifications);
+
private async Task DispatchLoopAsync(CancellationToken ct)
{
try
{
await foreach (var pending in _notificationQueue.Reader.ReadAllAsync(ct).ConfigureAwait(false))
{
+ // A value queued before its registration was deleted is purged here rather than delivered
+ // after the unsubscribe.
+ if (pending.Registration.IsDeleted) continue;
try { pending.Registration.OnChange(pending.Registration.SymbolPath, pending.Value); }
catch { /* consumer-side errors stay on this managed task, not the ADS thread */ }
}
@@ -877,9 +1026,15 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
///
public void Dispose()
{
+ IsDeleted = true;
// Fire-and-forget AMS call — caller has already committed to the tear-down.
_ = owner.DeleteNotificationAsync(handle, CancellationToken.None);
}
+
+ /// Gets a value indicating whether the registration was disposed; queued values for it are dropped.
+ public bool IsDeleted { get => Volatile.Read(ref _deleted); private set => Volatile.Write(ref _deleted, value); }
+
+ private bool _deleted;
}
private static Type MapToClrType(TwinCATDataType type) => type switch
@@ -916,14 +1071,43 @@ internal sealed class AdsTwinCATClient : ITwinCATClient
TwinCATDataType.UDInt => Convert.ToUInt32(value),
TwinCATDataType.LInt => Convert.ToInt64(value),
TwinCATDataType.ULInt => Convert.ToUInt64(value),
- TwinCATDataType.Real => Convert.ToSingle(value),
- TwinCATDataType.LReal => Convert.ToDouble(value),
+ TwinCATDataType.Real => ToSingleInRange(value),
+ TwinCATDataType.LReal => ToDoubleInRange(value),
TwinCATDataType.String or TwinCATDataType.WString => Convert.ToString(value) ?? string.Empty,
TwinCATDataType.Time or TwinCATDataType.Date
or TwinCATDataType.DateTime or TwinCATDataType.TimeOfDay => TwinCATTemporalCodec.Encode(type, value),
_ => throw new NotSupportedException($"TwinCATDataType {type} not writable."),
};
+ // Convert.ToSingle(1e300) is +Infinity, and Convert.ToDouble("1e400") too: writing that to the PLC
+ // would store a value nobody wrote. A finite source that overflows the target is out of range; an
+ // explicit infinity (or NaN) passes through as the caller's own value.
+ private static float ToSingleInRange(object? value)
+ {
+ var f = Convert.ToSingle(value, System.Globalization.CultureInfo.InvariantCulture);
+ if (float.IsInfinity(f) && !IsExplicitInfinity(value))
+ throw new OverflowException($"{value} is outside the REAL range (±{float.MaxValue:R})");
+ return f;
+ }
+
+ private static double ToDoubleInRange(object? value)
+ {
+ var d = Convert.ToDouble(value, System.Globalization.CultureInfo.InvariantCulture);
+ if (double.IsInfinity(d) && !IsExplicitInfinity(value))
+ throw new OverflowException($"{value} is outside the LREAL range");
+ return d;
+ }
+
+ // An explicit infinity — a float/double infinity, or a string that SPELLS one ("Infinity", "-inf", "∞")
+ // — is the caller's own value and passes; a finite number that overflows ("1e400") does not.
+ private static bool IsExplicitInfinity(object? value) => value switch
+ {
+ float f => float.IsInfinity(f),
+ double d => double.IsInfinity(d),
+ string s => s.Contains("inf", StringComparison.OrdinalIgnoreCase) || s.Contains('∞'),
+ _ => false,
+ };
+
private static bool ExtractBit(object? rawWord, int bit) => rawWord switch
{
short s => (s & (1 << bit)) != 0,
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
index 3849edd0..cb552f0c 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/ITwinCATClient.cs
@@ -102,6 +102,12 @@ public interface ITwinCATClient : IDisposable
/// The overall ADS code of the most recent such fallback (0 = a malformed response).
uint LastSumFallbackCode => 0;
+ ///
+ /// Native notifications this client dropped because its bounded dispatch queue was full (the router
+ /// thread never blocks on a slow consumer). The driver warns when it grows. Default 0.
+ ///
+ long DroppedNotificationCount => 0;
+
///
/// Write a symbolic value. Returns the mapped OPC UA status for the operation
/// (0 = Good, non-zero = error mapped via ).
@@ -119,6 +125,39 @@ public interface ITwinCATClient : IDisposable
object? value,
CancellationToken cancellationToken);
+ ///
+ /// Writes an array tag: (already converted to the declared element type by
+ /// ) as raw bytes at the layout planned from the RESOLVED
+ /// PLC array () and encoded by
+ /// — the exact inverse of the array read, so an array tag writes
+ /// what it reads. Fewer elements than the PLC array writes its leading sub-range.
+ ///
+ /// The ADS symbol path of the array.
+ /// The declared element type.
+ /// The converted elements (the authored ArrayLength of them).
+ /// Cancellation token for the write.
+ /// The mapped OPC UA status: Good; BadTypeMismatch when the declared element type /
+ /// length cannot address the PLC array; the ADS write's status otherwise.
+ /// A string element does not fit the PLC element's width.
+ Task WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList elements,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Sets or clears one bit of an integer parent word — the write half of a BOOL-within-word tag
+ /// (MAIN.Flags.3; ADS has no symbol for WORD.N). A read-modify-write of the parent's raw
+ /// bytes at the parent's OWN resolved width (), so a 16-bit
+ /// parent is never written as 32 bits and a signed parent's sign bit is just a bit. The caller
+ /// serialises concurrent writers to the same parent; the PLC program writing the word between the
+ /// read and the write is inherent to RMW.
+ ///
+ /// The parent word's ADS symbol path (the bit path without its .N).
+ /// The bit index (0 = least significant bit of the first byte).
+ /// The bit value to write.
+ /// Cancellation token for the read and the write.
+ /// The mapped OPC UA status: Good; the parent read's status; BadOutOfRange for a bit
+ /// beyond the parent; BadTypeMismatch for a parent that is not an integer word.
+ Task WriteBitAsync(string parentPath, int bit, bool value, CancellationToken cancellationToken);
+
///
/// Cheap health probe — returns true when the target's AMS state is reachable.
/// Used by 's probe loop.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs
index d008a714..d8fa56c1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATArrayCodec.cs
@@ -19,6 +19,84 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
///
internal static class TwinCATArrayCodec
{
+ ///
+ /// Converts a client-supplied array value into exactly elements of the
+ /// declared type, each converted the way a scalar write converts (
+ /// — so TIME/DATE elements become their encoded uint). Any CLR collection is accepted and the
+ /// element type is CONVERTED, not exact-matched: ScadaBridge writes an int[] to an Int16 node.
+ ///
+ /// The tag's declared element type.
+ /// The written value — an array or list.
+ /// The tag's authored ArrayLength.
+ /// The converted elements, in order.
+ /// The value is not a collection (a scalar, a string, null), or
+ /// an element cannot convert.
+ /// The element count differs from , or an
+ /// element is outside the declared type's range.
+ public static object[] ToWriteElements(TwinCATDataType declared, object? value, int count)
+ {
+ if (value is null or string || value is not System.Collections.IEnumerable items)
+ throw new InvalidCastException(
+ $"an array tag of {count} {declared} takes an array value, not {value?.GetType().Name ?? "null"}");
+ var elements = new List(count);
+ foreach (var item in items) elements.Add(AdsTwinCATClient.ConvertForWrite(declared, item));
+ if (elements.Count != count)
+ throw new OverflowException($"wrote {elements.Count} elements to an array tag of arrayLength {count}");
+ return [.. elements];
+ }
+
+ ///
+ /// Encodes converted elements () into the PLC's raw bytes at the planned
+ /// layout — the exact inverse of : little-endian at the element stride, a
+ /// STRING / WSTRING element encoded by within its fixed stride.
+ ///
+ /// The tag's declared element type.
+ /// The converted elements; exactly .
+ /// The layout planned from the PLC array ().
+ /// True when the target reports UTF-8 STRING data (else Windows-1252).
+ /// Exactly bytes.
+ /// The element count differs from the layout.
+ /// A string element does not fit its stride.
+ public static byte[] Encode(TwinCATDataType declared, IReadOnlyList elements, TwinCATArrayLayout layout, bool utf8)
+ {
+ if (layout.Stride <= 0 || elements.Count != layout.ElementCount)
+ throw new ArgumentException(
+ $"{elements.Count} elements do not match a layout of {layout.ElementCount} × {layout.Stride} B", nameof(elements));
+
+ var bytes = new byte[layout.ByteLength];
+ var stride = layout.Stride;
+ for (var i = 0; i < elements.Count; i++)
+ {
+ var slot = bytes.AsSpan(i * stride, stride);
+ var e = elements[i];
+ switch (declared)
+ {
+ case TwinCATDataType.Bool: slot[0] = Convert.ToBoolean(e) ? (byte)1 : (byte)0; break;
+ case TwinCATDataType.SInt: slot[0] = unchecked((byte)Convert.ToSByte(e)); break;
+ case TwinCATDataType.USInt: slot[0] = Convert.ToByte(e); break;
+ case TwinCATDataType.Int: BinaryPrimitives.WriteInt16LittleEndian(slot, Convert.ToInt16(e)); break;
+ case TwinCATDataType.UInt: BinaryPrimitives.WriteUInt16LittleEndian(slot, Convert.ToUInt16(e)); break;
+ case TwinCATDataType.DInt: BinaryPrimitives.WriteInt32LittleEndian(slot, Convert.ToInt32(e)); break;
+ case TwinCATDataType.UDInt or TwinCATDataType.Time or TwinCATDataType.TimeOfDay
+ or TwinCATDataType.Date or TwinCATDataType.DateTime:
+ BinaryPrimitives.WriteUInt32LittleEndian(slot, Convert.ToUInt32(e)); break;
+ case TwinCATDataType.LInt: BinaryPrimitives.WriteInt64LittleEndian(slot, Convert.ToInt64(e)); break;
+ case TwinCATDataType.ULInt: BinaryPrimitives.WriteUInt64LittleEndian(slot, Convert.ToUInt64(e)); break;
+ case TwinCATDataType.Real: BinaryPrimitives.WriteSingleLittleEndian(slot, Convert.ToSingle(e)); break;
+ case TwinCATDataType.LReal: BinaryPrimitives.WriteDoubleLittleEndian(slot, Convert.ToDouble(e)); break;
+ case TwinCATDataType.String:
+ TwinCATStringCodec.EncodeString(Convert.ToString(e) ?? string.Empty, stride, utf8).CopyTo(slot);
+ break;
+ case TwinCATDataType.WString:
+ TwinCATStringCodec.EncodeWString(Convert.ToString(e) ?? string.Empty, stride).CopyTo(slot);
+ break;
+ default:
+ throw new NotSupportedException($"TwinCATDataType {declared} has no array element encoding");
+ }
+ }
+ return bytes;
+ }
+
/// Decodes .ElementCount elements of .
/// The tag's declared element type.
/// The PLC bytes from the array's first element (at least ).
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATBitAccess.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATBitAccess.cs
index 186e1cee..277bd317 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATBitAccess.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATBitAccess.cs
@@ -32,6 +32,36 @@ internal static class TwinCATBitAccess
: $"bit {bit} is outside the {parent.ByteSize * 8}-bit parent {parent.TypeName}";
}
+ ///
+ /// The modify step of a bit write's read-modify-write: checks the bit against the parent
+ /// () and returns the parent's bytes — exactly parent.ByteSize of them —
+ /// with one bit set or cleared. Works on raw bytes, so the parent's width and signedness play no part.
+ ///
+ /// The parent symbol's resolved shape.
+ /// The parent's current bytes as read from the PLC.
+ /// The bit index.
+ /// The bit value.
+ /// The bytes to write back when the result is Good; otherwise empty.
+ /// Good; BadOutOfRange for a bit beyond the parent; BadTypeMismatch for a parent
+ /// that is not an integer word; BadCommunicationError for a read shorter than the parent
+ /// (writing it back would zero the missing bytes).
+ public static uint ApplyBitWrite(TwinCATPlcSymbolShape parent, ReadOnlySpan current, int bit, bool value,
+ out byte[] updated)
+ {
+ updated = [];
+ if (CheckParent(bit, parent) is not null)
+ return parent.Kind != TwinCATPlcKind.Scalar || parent.IsFloat
+ ? TwinCATStatusMapper.BadTypeMismatch
+ : TwinCATStatusMapper.BadOutOfRange;
+ if (current.Length < parent.ByteSize) return TwinCATStatusMapper.BadCommunicationError;
+
+ var bytes = current[..parent.ByteSize].ToArray();
+ var mask = (byte)(1 << (bit % 8));
+ bytes[bit / 8] = value ? (byte)(bytes[bit / 8] | mask) : (byte)(bytes[bit / 8] & ~mask);
+ updated = bytes;
+ return TwinCATStatusMapper.Good;
+ }
+
/// Reads bit of a little-endian parent word.
/// The parent word's raw bytes.
/// The bit index (0 = least significant bit of the first byte).
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
index 66663ef8..8b6c0927 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATDriver.cs
@@ -11,7 +11,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
/// the skeleton; read / write / discover / subscribe / probe / host-
/// resolver land in PRs 2 and 3.
///
-public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
+public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable,
IHostConnectivityProbe, IPerCallHostResolver, IRediscoverable, ISkippedTagReporter, IDriverDiagnostics,
IDisposable, IAsyncDisposable
{
@@ -22,26 +22,86 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
// Mutable so ReinitializeAsync can apply a new config generation. The constructor seeds
- // it; InitializeAsync re-parses driverConfigJson over the top of it.
- private TwinCATDriverOptions _options;
+ // it; InitializeAsync re-parses driverConfigJson over the top of it (committed under _lifecycleLock).
+ private volatile TwinCATDriverOptions _options;
private readonly string _driverInstanceId;
private readonly ITwinCATClientFactory _clientFactory;
private readonly ILogger _logger;
- private readonly PollGroupEngine _poll;
- // ConcurrentDictionary so ShutdownAsync (Clear) and ReadAsync/WriteAsync/SubscribeAsync
- // (TryGetValue) don't race — plain Dictionary is not safe for concurrent read+write.
- private readonly ConcurrentDictionary _devices =
- new(StringComparer.OrdinalIgnoreCase);
+ // The poll engine belongs to a teardown GENERATION (#739 fix round 2): ShutdownAsync / Dispose retire the
+ // current engine and install a fresh one in the same swap, then dispose the retired one — possibly in the
+ // background after a cancelled or over-budget shutdown. PollGroupEngine.DisposeAsync clears whatever its
+ // own table holds when it finishes, so a subscription made after a later InitializeAsync must never land
+ // in a retiring engine, or the old teardown would dispose its CTS without cancelling it (a poll loop no
+ // UnsubscribeAsync can find, running forever).
+ private volatile PollGeneration _poll;
+ private long _nextPollGeneration;
+
+ /// One generation's poll engine plus its serial (the serial is part of every handle it issues).
+ private sealed record PollGeneration(long Serial, PollGroupEngine Engine);
+
+ ///
+ /// A poll subscription handle, qualified by the engine generation that issued it. Each engine numbers its
+ /// handles from 1, so without the generation a retired engine's late callback would carry a handle EQUAL
+ /// to a new subscription's and be taken for it.
+ ///
+ private sealed record TwinCATPollHandle(long Generation, ISubscriptionHandle Inner) : ISubscriptionHandle
+ {
+ ///
+ public string DiagnosticId => $"twincat-poll-g{Generation}-{Inner.DiagnosticId}";
+ }
+
+ // Device + tag tables are BUILD-AND-SWAP (#739 L8): InitializeAsync builds fresh tables and publishes
+ // them by reference under _lifecycleLock; ShutdownAsync / Dispose swap in empty ones and release what
+ // they took. A retained poll loop, the probe tick or an in-flight write therefore always sees one whole
+ // generation — never a table mid-Clear — and two overlapping teardowns can never release the same
+ // device twice (each device set is taken by exactly one swap).
+ private volatile ConcurrentDictionary _devices = NewDeviceTable();
// v3: the authored RawPath → definition table. Keyed case-sensitive ordinal (the RawPath is
// the exact wire reference). Built in InitializeAsync from _options.RawTags via the pure mapper.
- private readonly ConcurrentDictionary _tagsByRawPath =
- new(StringComparer.Ordinal);
+ private volatile IReadOnlyDictionary _tagsByRawPath = EmptyTags;
+
+ private static readonly IReadOnlyDictionary EmptyTags =
+ new Dictionary(StringComparer.Ordinal);
+
+ private static ConcurrentDictionary NewDeviceTable() => new(StringComparer.OrdinalIgnoreCase);
+
+ // Serialises the table swaps against each other. _lifecycleGeneration is bumped by every
+ // InitializeAsync and every teardown, so an InitializeAsync that was overtaken while it awaited
+ // (an abandoned init overlapping the runtime's next ShutdownAsync, or a newer init) sees a
+ // different generation at its commit and releases what it built instead of installing it.
+ private readonly object _lifecycleLock = new();
+ private long _lifecycleGeneration;
+
+ /// Test accessor: the RMW gate for a device + parent word, or null.
+ internal SemaphoreSlim? BitRmwGateForTest(string host, string parentPath) =>
+ _bitRmwLocks.TryGetValue($"{host}|{parentPath}", out var g) ? g : null;
+
+ /// Test seam: awaited after the new generation is built and before it is committed, so a test can
+ /// interleave a teardown deterministically. Never set in production.
+ internal Func? BeforeCommitForTest { get; set; }
+
+ /// Upper bound on joining released devices' probe loops (one wait for all of them).
+ internal static readonly TimeSpan ProbeJoinTimeout = TimeSpan.FromSeconds(2);
+
+ ///
+ /// Upper bound on the whole of . The runtime gives a final stop 5 s, so
+ /// the driver stops waiting a little earlier; anything still running past it (a poll loop wedged in
+ /// a read) is cancelled already and finishes in the background.
+ ///
+ internal static readonly TimeSpan ShutdownBudget = TimeSpan.FromSeconds(4.5);
+
+ // References that already logged their bad-read Warning in the current bad episode (#739 P10).
+ // Removed when the tag reads Good; cleared on (re-)initialise and shutdown.
+ private readonly ConcurrentDictionary _warnedRefs = new(StringComparer.Ordinal);
// Per-parent-word RMW gate for BOOL-within-word writes: a single-bit write is a
// read-modify-write of the parent word (TwinCAT's symbol table doesn't expose "Word.N"),
// so concurrent bit-writers to the same word must serialise or they lose each other's
// updates. Keyed by device + parent symbol. (Cannot guard against the PLC program writing
- // the word between our read and write — inherent to RMW.)
+ // the word between our read and write — inherent to RMW.) The gates are KEPT across generations
+ // and teardowns (never cleared or disposed): a write still in flight on the old generation and the
+ // first write of the next one must serialise on the SAME gate. A SemaphoreSlim owns no kernel handle
+ // unless AvailableWaitHandle is read, and the key set is bounded by the authored bit tags.
private readonly ConcurrentDictionary _bitRmwLocks =
new(StringComparer.OrdinalIgnoreCase);
@@ -54,6 +114,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// Occurs when a subscribed tag value changes.
public event EventHandler? OnDataChange;
+ ///
+ /// Batched data changes (R02:P1): one raise per poll cycle on the poll path, one per notification on
+ /// the native path (each ADS notification is its own burst). DriverInstanceActor subscribes only this
+ /// event of a batch-capable driver, so EVERY data change the driver publishes goes through both.
+ ///
+ public event EventHandler? OnDataChangeBatch;
/// Occurs when a device host connectivity status changes.
public event EventHandler? OnHostStatusChanged;
/// Occurs when the Galaxy object hierarchy or TwinCAT symbol table is rediscovered.
@@ -75,17 +141,27 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
_logger = logger ?? NullLogger.Instance;
_resolver = new EquipmentTagRefResolver(
r => _tagsByRawPath.TryGetValue(r, out var t) ? t : null);
- _poll = new PollGroupEngine(
+ _poll = CreatePollEngine();
+ }
+
+ /// Builds one generation's poll engine; its callbacks publish generation-qualified handles.
+ private PollGeneration CreatePollEngine()
+ {
+ var driverInstanceId = _driverInstanceId;
+ var serial = Interlocked.Increment(ref _nextPollGeneration);
+ return new PollGeneration(serial, new PollGroupEngine(
reader: ReadAsync,
onChange: (handle, tagRef, snapshot) =>
- OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
+ OnDataChange?.Invoke(this, new DataChangeEventArgs(new TwinCATPollHandle(serial, handle), tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap,
+ onBatch: (handle, items) => OnDataChangeBatch?.Invoke(this,
+ new DataChangeBatchEventArgs(new TwinCATPollHandle(serial, 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,
new KeyValuePair(OtOpcUaTelemetry.PollOverrunDriverTypeTag, DriverTypeNames.TwinCAT),
- new KeyValuePair(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId)));
+ new KeyValuePair(OtOpcUaTelemetry.PollOverrunDriverInstanceTag, driverInstanceId))));
}
/// Upper bound on the poll-loop failure backoff — adopts the S7-proven 30 s cap fleet-wide.
@@ -111,31 +187,52 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public string DriverType => "TwinCAT";
///
+ ///
+ /// An initialise that is overtaken before it commits — a /
+ /// or a newer bumped the lifecycle generation while it was building — releases
+ /// what it built and throws . Cancellation is the honest outcome:
+ /// the work was abandoned by a later lifecycle call, not failed, so it must not write Faulted over
+ /// the newer state or let a caller believe this generation is live; returning normally would claim a
+ /// driver that was never installed. The runtime has usually abandoned the task by then, and an
+ /// overlapping retry treats it like any other cancelled attempt.
+ ///
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 + clients before
- // the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
- await ReleaseDeviceStatesAsync().ConfigureAwait(false);
- _health = new DriverHealth(DriverState.Initializing, null, null);
+ // allows it, and a reconnect retry can overlap a running attempt). The new generation is BUILT
+ // first and published by one swap; the previous device states (probe loops, clients, native
+ // registrations) are released right after the swap — never orphaned, and a retained poll loop
+ // never sees an empty table in between. Deliberately NOT ShutdownAsync: that also drops _poll and
+ // the subscriptions.
+ long generation;
+ lock (_lifecycleLock)
+ {
+ generation = ++_lifecycleGeneration;
+ _health = new DriverHealth(DriverState.Initializing, null, null);
+ }
+ _warnedRefs.Clear();
+
+ var devices = NewDeviceTable();
+ var committed = false;
try
{
// Apply the supplied config generation. A blank or content-free document keeps the
// constructor-seeded options — that path covers callers that have already
// materialised options up front (the factory passes both, in agreement).
+ var options = _options;
if (!string.IsNullOrWhiteSpace(driverConfigJson))
{
var parsed = TwinCATDriverFactoryExtensions.ParseOptions(driverConfigJson, _driverInstanceId);
if (parsed.Devices.Count > 0 || parsed.RawTags.Count > 0)
- _options = parsed;
+ options = parsed;
}
- foreach (var device in _options.Devices)
+ foreach (var device in options.Devices)
{
var addr = TwinCATAmsAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"TwinCAT device has invalid HostAddress '{device.HostAddress}' — expected 'ads://{{netId}}:{{port}}'.");
- _devices[device.HostAddress] = new DeviceState(addr, device);
+ devices[device.HostAddress] = new DeviceState(addr, device);
}
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
@@ -144,14 +241,15 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// DeviceName is resolved to the target's live host (def.DeviceHostAddress) via ResolveDeviceHost
// so the existing device routing (_devices keyed by HostAddress) is unchanged. A mapper miss is
// skipped (logged), never thrown — a bad tag must not fail the whole driver init.
- _skippedTags.BeginRebuild(_options.RawTags.Count);
- foreach (var entry in _options.RawTags)
+ var tags = new Dictionary(StringComparer.Ordinal);
+ _skippedTags.BeginRebuild(options.RawTags.Count);
+ foreach (var entry in options.RawTags)
{
if (TwinCATTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
- _tagsByRawPath[entry.RawPath] = def with
+ tags[entry.RawPath] = def with
{
WriteIdempotent = entry.WriteIdempotent,
- DeviceHostAddress = ResolveDeviceHost(entry.DeviceName),
+ DeviceHostAddress = ResolveDeviceHost(options, entry.DeviceName),
};
else
{
@@ -163,49 +261,125 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
}
_skippedTags.Commit();
- if (_options.Probe.Enabled)
+ if (BeforeCommitForTest is { } pause) await pause().ConfigureAwait(false);
+
+ ConcurrentDictionary previous;
+ lock (_lifecycleLock)
{
- foreach (var state in _devices.Values)
+ if (generation != _lifecycleGeneration)
+ throw new OperationCanceledException(
+ "TwinCAT InitializeAsync was superseded by a shutdown or a newer initialise while it ran.");
+
+ _options = options;
+ _tagsByRawPath = tags;
+ previous = _devices;
+ _devices = devices;
+ committed = true;
+ // Probe loops start INSIDE the commit so a teardown that swaps this generation out right
+ // after always finds (and cancels) them.
+ if (options.Probe.Enabled)
{
- state.ProbeCts = new CancellationTokenSource();
- var ct = state.ProbeCts.Token;
- // Store the task so ShutdownAsync can await it.
- state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
+ foreach (var state in devices.Values)
+ {
+ state.ProbeCts = new CancellationTokenSource();
+ var ct = state.ProbeCts.Token;
+ // Store the task so the teardown can join it.
+ state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
+ }
+ }
+ _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
+ }
+
+ // The replaced generation is now unreachable from the tables: release it (bounded).
+ await ReleaseDevicesAsync(previous.Values).ConfigureAwait(false);
+ }
+ catch (Exception ex) when (!committed)
+ {
+ // Nothing of this generation was published: release what was built. A superseded init leaves
+ // health to whoever overtook it.
+ await ReleaseDevicesAsync(devices.Values).ConfigureAwait(false);
+ if (ex is not OperationCanceledException)
+ {
+ lock (_lifecycleLock)
+ {
+ if (generation == _lifecycleGeneration)
+ _health = new DriverHealth(DriverState.Faulted, null, ex.Message);
}
}
- _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
- }
- catch (Exception ex)
- {
- _health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
}
///
- /// Cancels, awaits and disposes every existing device state's probe loop, client and gate, then
- /// clears the device table. Device part of only.
+ /// Releases a set of device states that have already been swapped out of :
+ /// marks them released (so a probe wedged in the blocking ADS connect disposes the client it
+ /// eventually gets), disposes their native registrations (a re-initialise must not leave live ADS
+ /// notifications bound to a dead generation), cancels every probe loop and joins them all together
+ /// within , then disposes their clients. The connect gates are NOT
+ /// disposed — an in-flight read/write may still hold one.
///
- private async Task ReleaseDeviceStatesAsync()
+ /// The released device states (each owned by exactly one caller).
+ private async Task ReleaseDevicesAsync(ICollection states)
{
- if (_devices.IsEmpty) return;
- foreach (var state in _devices.Values)
+ if (states.Count == 0) return;
+ var probes = BeginRelease(states);
+ if (probes.Count > 0)
{
- try { state.ProbeCts?.Cancel(); } catch { }
- if (state.ProbeTask is Task pt)
- {
- try { await pt.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); }
- catch { /* cancelled, faulted or wedged — teardown continues */ }
- }
- state.ProbeCts?.Dispose();
+ try { await Task.WhenAll(probes).WaitAsync(ProbeJoinTimeout).ConfigureAwait(false); }
+ catch { /* cancelled, faulted or wedged in the synchronous ADS connect — teardown continues */ }
+ }
+ EndRelease(states);
+ }
+
+ /// The non-blocking first half of a release: mark, retract registrations, cancel probes.
+ private static List BeginRelease(ICollection states)
+ {
+ var probes = new List(states.Count);
+ foreach (var state in states)
+ {
+ state.Released = true;
+ foreach (var reg in state.NativeRegistrations.Values) { try { reg.Dispose(); } catch { } }
+ try { state.ProbeCts?.Cancel(); } catch { /* already disposed */ }
+ if (state.ProbeTask is Task pt) probes.Add(pt);
+ }
+ return probes;
+ }
+
+ /// The second half of a release, after the bounded probe join: dispose CTSs and clients.
+ private static void EndRelease(ICollection states)
+ {
+ foreach (var state in states)
+ {
+ try { state.ProbeCts?.Dispose(); } catch { }
state.ProbeCts = null;
state.ProbeTask = null;
- state.DisposeClient();
- state.DisposeGate();
+ try { state.DisposeClient(); } catch { /* best-effort */ }
}
- _devices.Clear();
}
+ ///
+ /// Swaps out every table AND the poll engine under the lifecycle lock and returns what the caller now
+ /// owns. Everything returned belongs to the retired generation only; nothing a later
+ /// / creates is reachable from it.
+ ///
+ private (ICollection devices, PollGroupEngine poll) TakeAllForTeardown()
+ {
+ lock (_lifecycleLock)
+ {
+ _lifecycleGeneration++;
+ var taken = _devices;
+ _devices = NewDeviceTable();
+ _tagsByRawPath = EmptyTags;
+ var poll = _poll;
+ _poll = CreatePollEngine();
+ return (taken.Values, poll.Engine);
+ }
+ }
+
+ /// The most recent teardown's background work (poll-engine dispose + device release). Test
+ /// visibility only: a cancelled shutdown leaves it running.
+ internal Task? LastTeardownForTest { get; private set; }
+
///
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
@@ -214,6 +388,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
}
///
+ ///
+ /// Safe to run concurrently with itself, with / and with
+ /// an abandoned : the tables are taken by one atomic swap, so each device
+ /// is released exactly once and an overtaken init releases its own generation. Bounded by
+ /// .
+ ///
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
// Native subs first — disposing the handles is cheap + lets the client close its
@@ -222,32 +402,34 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
_nativeSubs.Clear();
- await _poll.DisposeAsync().ConfigureAwait(false);
+ var (taken, retiredPoll) = TakeAllForTeardown();
- // Cancel every probe loop and await its task before disposing the client + gate so the
- // loop can never touch a disposed object.
- foreach (var state in _devices.Values)
+ // The poll loops and the device release wind down in parallel (releasing a client can unblock a
+ // poll loop stuck in a read on it); the whole teardown is bounded. Both halves own ONLY the retired
+ // generation, so finishing in the background after a cancelled wait is safe.
+ var teardown = Task.WhenAll(retiredPoll.DisposeAsync().AsTask(), ReleaseDevicesAsync(taken));
+ LastTeardownForTest = teardown;
+ var cancelled = false;
+ try { await teardown.WaitAsync(ShutdownBudget, cancellationToken).ConfigureAwait(false); }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
- try { state.ProbeCts?.Cancel(); } catch { }
- if (state.ProbeTask is Task pt)
- {
- try { await pt.ConfigureAwait(false); }
- catch (OperationCanceledException) { /* expected — probe loop exits on cancel */ }
- catch { /* other probe errors are not fatal to shutdown */ }
- }
- state.ProbeCts?.Dispose();
- state.ProbeCts = null;
- state.DisposeClient();
- state.DisposeGate();
+ // The caller stopped waiting. The tables are already swapped out and every loop is cancelled, so
+ // the rest of the teardown finishes in the background; report the cancellation after the cheap
+ // bookkeeping below.
+ cancelled = true;
}
- _devices.Clear();
- _tagsByRawPath.Clear();
+ catch (TimeoutException)
+ {
+ _logger.LogWarning(
+ "TwinCAT driver '{DriverInstanceId}' shutdown exceeded {Budget}; the remaining teardown continues in the background",
+ _driverInstanceId, ShutdownBudget);
+ }
+ catch { /* best-effort teardown */ }
+
+ _warnedRefs.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
- // Dispose + clear the per-parent-word RMW gates so ReinitializeAsync cycles don't orphan
- // their SemaphoreSlim instances (each leaks a wait handle once contended).
- foreach (var sem in _bitRmwLocks.Values) sem.Dispose();
- _bitRmwLocks.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
+ if (cancelled) throw new OperationCanceledException(cancellationToken);
}
///
@@ -330,41 +512,82 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var (value, status) = await client.ReadValueAsync(
symbolName, def.DataType, parsed?.BitIndex, def.ArrayLength, cancellationToken)
.ConfigureAwait(false);
- return CompleteRead(reference, def, symbolName, value, status, now);
+ return CompleteRead(device, reference, def, symbolName, value, status, now);
}
catch (OperationCanceledException) { throw; }
catch (Exception ex)
{
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
+ MarkCommsFault(ex.Message, device);
return new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
}
}
/// Maps one read outcome to a snapshot and applies the health / log side effects.
- private DataValueSnapshot CompleteRead(
+ private DataValueSnapshot CompleteRead(DeviceState device,
string reference, TwinCATTagDefinition def, string symbolName, object? value, uint status, DateTime now)
{
if (status == TwinCATStatusMapper.Good)
{
- _health = new DriverHealth(DriverState.Healthy, now, null);
+ _warnedRefs.TryRemove(reference, out _); // a later failure is a new episode and warns again
+ RecordDeviceAnswered(now, device, force: true);
return new DataValueSnapshot(value, status, now, now);
}
- if (status == TwinCATStatusMapper.BadTypeMismatch)
- _logger.LogWarning(
- "TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
- "cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch, array element type " +
- "or length mismatch, or a reference/container)",
- _driverInstanceId, reference, def.DataType, symbolName);
+ // Once per tag per bad episode: a Warning every poll for every bad tag (50 typo'd symbols on a
+ // 500 ms poll = 100 Warnings/s) buried everything else in the log.
+ if (_warnedRefs.TryAdd(reference, 0))
+ {
+ if (status == TwinCATStatusMapper.BadTypeMismatch)
+ _logger.LogWarning(
+ "TwinCAT driver '{DriverInstanceId}' refused '{Reference}': declared {DataType} " +
+ "cannot decode the PLC symbol '{Symbol}' (string/numeric kind mismatch, array element type " +
+ "or length mismatch, or a reference/container); logged once until the tag reads Good",
+ _driverInstanceId, reference, def.DataType, symbolName);
+ else
+ _logger.LogWarning(
+ "TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'; " +
+ "logged once until the tag reads Good",
+ _driverInstanceId, status, reference);
+ }
+
+ if (IsCommsStatus(status))
+ MarkCommsFault($"ADS status {status:X8} reading {reference}", device);
else
- _logger.LogWarning(
- "TwinCAT driver '{DriverInstanceId}' ADS read error 0x{Status:X8} for '{Reference}'",
- _driverInstanceId, status, reference);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
- $"ADS status {status:X8} reading {reference}");
+ RecordDeviceAnsweredKeepState(now); // a refusal: the device answered — no state change
return new DataValueSnapshot(value, status, now, now);
}
+ ///
+ /// Applies the health contract to a write the client returned a status for: Good is the device
+ /// answering (clears that device's comms fault, advances LastSuccessfulRead); a comms-class
+ /// status is a comms fault; anything else is a refusal — the device answered, the state is unchanged.
+ ///
+ private void NoteWriteOutcome(DeviceState device, uint status, string reference)
+ {
+ var now = DateTime.UtcNow;
+ if (status == TwinCATStatusMapper.Good) RecordDeviceAnswered(now, device, force: true);
+ else if (IsCommsStatus(status)) MarkCommsFault($"ADS status {status:X8} writing {reference}", device);
+ else RecordDeviceAnsweredKeepState(now);
+ }
+
+ ///
+ /// True for a status that means the request did not get a device answer (router / transport /
+ /// timeout). Every other non-Good status is the device refusing this one tag (symbol not found, type
+ /// mismatch, PLC not running, access denied): per-tag Bad with no health write.
+ ///
+ /// The mapped OPC UA status.
+ /// True for a comms-class status.
+ internal static bool IsCommsStatus(uint status) =>
+ status is TwinCATStatusMapper.BadCommunicationError or TwinCATStatusMapper.BadTimeout;
+
+ /// Advances LastSuccessfulRead without changing the state (the device answered with a refusal).
+ private void RecordDeviceAnsweredKeepState(DateTime now)
+ {
+ var current = _health;
+ if (current.State is DriverState.Faulted or DriverState.Unknown or DriverState.Initializing) return;
+ _health = current with { LastSuccessfulRead = now };
+ }
+
///
/// Reads one device's gathered tags as ADS sum-reads of at most SumReadMaxItems symbols each.
/// A target that refuses the sum command flips and the
@@ -412,7 +635,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{
var e = chunk[k];
var (value, status) = k < bulk.Count ? bulk[k] : (null, TwinCATStatusMapper.BadCommunicationError);
- results[e.Index] = CompleteRead(e.Reference, e.Def, e.SymbolName, value, status, now);
+ results[e.Index] = CompleteRead(device, e.Reference, e.Def, e.SymbolName, value, status, now);
}
}
catch (OperationCanceledException) { throw; }
@@ -430,7 +653,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{
foreach (var e in chunk)
results[e.Index] = new DataValueSnapshot(null, TwinCATStatusMapper.BadCommunicationError, null, now);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
+ MarkCommsFault(ex.Message, device);
// Once per device: the health message is overwritten by the next good read in the same poll, so
// without this a sum-read path failing on every poll left no trace (hw2 live gate, 2026-10-08).
if (connected && !device.SumChunkFailureWarned)
@@ -475,59 +698,64 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
try
{
- var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
+ var bitIndex = def.DataType == TwinCATDataType.Bool ? parsed?.BitIndex : null;
+ // Shape checks run BEFORE any wire traffic (connect included): an array write needs exactly
+ // ArrayLength elements, each converted to the declared element type (a CLR int[] to an Int16
+ // array converts; it is not exact-matched) — a length mismatch / out-of-range element is
+ // OverflowException (BadOutOfRange), a scalar value InvalidCastException (BadTypeMismatch).
+ var bitValue = bitIndex is not null ? Convert.ToBoolean(w.Value) : false;
+ var elements = bitIndex is null && def.ArrayLength is int arrayLength
+ ? TwinCATArrayCodec.ToWriteElements(def.DataType, w.Value, arrayLength)
+ : null;
- // BOOL-within-word write — read-modify-write of the parent word. Mirrors the bit-read
- // (AdsTwinCATClient.ReadValueAsync) which reads the parent as uint: read the parent as
- // UDInt (-> uint), flip the bit, write it back, all under a per-parent lock.
- if (def.DataType == TwinCATDataType.Bool && parsed?.BitIndex is int bit)
+ var client = await EnsureConnectedAsync(device, cancellationToken).ConfigureAwait(false);
+
+ // BOOL-within-word write — a read-modify-write of the parent word at the parent's OWN
+ // width and on raw bytes (the client resolves the parent and checks the bit against it, the
+ // notification path's model), under a per-parent lock so concurrent bit writers to the same
+ // word don't lose each other's updates.
+ if (bitIndex is int bit)
{
- var parentPath = (parsed with { BitIndex = null }).ToAdsSymbolName();
+ var parentPath = (parsed! with { BitIndex = null }).ToAdsSymbolName();
var gate = _bitRmwLocks.GetOrAdd(
$"{def.DeviceHostAddress}|{parentPath}", static _ => new SemaphoreSlim(1, 1));
await gate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- var (parentValue, readStatus) = await client.ReadValueAsync(
- parentPath, TwinCATDataType.UDInt, null, null, cancellationToken).ConfigureAwait(false);
- if (readStatus != TwinCATStatusMapper.Good)
- {
- results[i] = new WriteResult(readStatus);
- }
- else if (parentValue is null)
- {
- // Good status but no value — treating null as 0 would write a zeroed
- // word and clear every bit set on the device. Surface a Bad status and
- // write nothing rather than corrupt the parent word.
- results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError);
- }
- else
- {
- var word = Convert.ToUInt32(parentValue);
- var updated = Convert.ToBoolean(w.Value) ? word | (1u << bit) : word & ~(1u << bit);
- var writeStatus = await client.WriteValueAsync(
- parentPath, TwinCATDataType.UDInt, null, updated, cancellationToken).ConfigureAwait(false);
- results[i] = new WriteResult(writeStatus);
- }
+ results[i] = new WriteResult(await client.WriteBitAsync(
+ parentPath, bit, bitValue, cancellationToken).ConfigureAwait(false));
}
finally
{
gate.Release();
}
+ NoteWriteOutcome(device, results[i].StatusCode, w.FullReference);
continue;
}
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
+ if (elements is not null)
+ {
+ // Array tag: raw bytes encoded by TwinCATArrayCodec at the layout planned from the PLC
+ // array — the inverse of the array read.
+ results[i] = new WriteResult(await client.WriteArrayAsync(
+ symbolName, def.DataType, elements, cancellationToken).ConfigureAwait(false));
+ NoteWriteOutcome(device, results[i].StatusCode, w.FullReference);
+ continue;
+ }
+
var status = await client.WriteValueAsync(
symbolName, def.DataType, parsed?.BitIndex, w.Value, cancellationToken).ConfigureAwait(false);
results[i] = new WriteResult(status);
+ NoteWriteOutcome(device, status, w.FullReference);
}
catch (OperationCanceledException) { throw; }
- catch (NotSupportedException nse)
+ catch (NotSupportedException)
{
+ // A write the client cannot express is a refusal, not a comms fault (#611): no health
+ // write, or the comms-loss watchdog would reconnect a healthy device over a config issue.
results[i] = new WriteResult(TwinCATStatusMapper.BadNotSupported);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException)
{
@@ -540,7 +768,7 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
catch (Exception ex)
{
results[i] = new WriteResult(TwinCATStatusMapper.BadCommunicationError);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
+ MarkCommsFault(ex.Message, device);
}
}
@@ -656,18 +884,39 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken)
{
if (!_options.UseNativeNotifications)
- return _poll.Subscribe(fullReferences, publishingInterval);
+ {
+ // A teardown racing this call may retire the engine we read: re-home the subscription on the
+ // current one so it never lives in an engine that is being disposed.
+ while (true)
+ {
+ var generation = _poll;
+ var inner = generation.Engine.Subscribe(fullReferences, publishingInterval);
+ if (ReferenceEquals(generation, _poll)) return new TwinCATPollHandle(generation.Serial, inner);
+ generation.Engine.Unsubscribe(inner);
+ }
+ }
var id = Interlocked.Increment(ref _nextNativeSubId);
var handle = new NativeSubscriptionHandle(id);
var registrations = new List(fullReferences.Count);
+ // Devices whose connect failed during THIS call: the rest of their tags are stored as dead
+ // intents straight away rather than each paying (and logging) its own failed connect.
+ HashSet? unreachable = null;
try
{
foreach (var reference in fullReferences)
{
- if (!_resolver.TryResolve(reference, out var def)) continue;
- if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) continue;
+ // An unresolvable reference (unknown RawPath, or a tag routed to an unconfigured device)
+ // publishes BadNodeIdUnknown once — the poll path's status — instead of leaving the node on
+ // whatever status the server seeded it with.
+ if (!_resolver.TryResolve(reference, out var def)
+ || !_devices.TryGetValue(def.DeviceHostAddress, out var device))
+ {
+ RaiseDataChange(handle, reference, new DataValueSnapshot(
+ null, TwinCATStatusMapper.BadNodeIdUnknown, null, DateTime.UtcNow));
+ continue;
+ }
var parsed = TwinCATSymbolPath.TryParse(def.SymbolPath);
var symbolName = parsed?.ToAdsSymbolName() ?? def.SymbolPath;
@@ -678,44 +927,79 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var reg = new NativeRegistration(
Interlocked.Increment(ref _nextRegId), device, reference, symbolName,
def.DataType, bitIndex, def.ArrayLength, publishingInterval,
- onChange: (_, value) => OnDataChange?.Invoke(this,
- new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
- value, TwinCATStatusMapper.Good, DateTime.UtcNow, DateTime.UtcNow))),
+ onChange: (_, value) =>
+ {
+ // A push is the device answering: stamp LastSuccessfulRead so a notification-only
+ // driver does not look silent to the comms-loss watchdog.
+ var now = DateTime.UtcNow;
+ RecordDeviceAnswered(now, device);
+ RaiseDataChange(handle, reference, new DataValueSnapshot(value, TwinCATStatusMapper.Good, now, now));
+ },
// When the handle goes dead (a failed replay) push a Bad snapshot so
// subscribers see the tag go Bad instead of a frozen-Good-stale value.
- onUnavailable: () => OnDataChange?.Invoke(this,
- new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
- null, TwinCATStatusMapper.BadCommunicationError, null, DateTime.UtcNow))));
+ onUnavailable: status => RaiseDataChange(handle, reference, new DataValueSnapshot(
+ null, status, null, DateTime.UtcNow)));
+ registrations.Add(reg);
+
+ if (unreachable?.Contains(device) == true)
+ {
+ // Same device already failed to connect in this call: keep the intent dead for the
+ // probe-tick retry / reconnect replay and surface Bad now.
+ device.NativeRegistrations[reg.Id] = reg;
+ NoteDeadFailure(reg, TwinCATStatusMapper.BadCommunicationError, refused: false, DateTime.UtcNow);
+ RaiseDataChange(handle, reference, new DataValueSnapshot(
+ null, TwinCATStatusMapper.BadCommunicationError, null, DateTime.UtcNow));
+ continue;
+ }
try
{
await RegisterNotificationAsync(device, reg, cancellationToken).ConfigureAwait(false);
}
+ catch (OperationCanceledException) { throw; }
catch (TwinCATNotificationRefusedException refused)
{
- // The declared type / array length cannot decode this PLC symbol (ignitionoee audit), or the array is over the native-notification size cap: isolate
- // THIS tag to Bad — one WARN, one snapshot carrying the refusal's status
- // (BadTypeMismatch / BadOutOfRange) — and keep the rest of the subscription. Its
- // intent stays on the device with no live handle, so the probe-tick retry re-checks
- // it after a PLC program change.
+ // The device answered and refused THIS tag (declared type cannot decode the symbol,
+ // array over the notification cap, symbol not found): isolate it — one WARN, one
+ // snapshot carrying the refusal's status, no health write (a refusal is not a comms
+ // fault). Its intent stays on the device with no live handle, so the probe-tick retry
+ // re-checks it after a PLC program change.
_logger.LogWarning(
"TwinCAT driver '{DriverInstanceId}' refused native notification for '{Reference}': {Reason}",
_driverInstanceId, reference, refused.Message);
- OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, new DataValueSnapshot(
- null, refused.StatusCode, null, DateTime.UtcNow)));
+ NoteDeadFailure(reg, refused.StatusCode, refused: true, DateTime.UtcNow);
+ RaiseDataChange(handle, reference, new DataValueSnapshot(
+ null, refused.StatusCode, null, DateTime.UtcNow));
+ }
+ catch (DeviceUnavailableException unavailable)
+ {
+ // The device could not be connected (offline, router down, backoff window open).
+ // Every tag of this device goes Bad + dead for the retry; other devices carry on.
+ (unreachable ??= []).Add(device);
+ MarkCommsFault(unavailable.InnerException?.Message ?? unavailable.Message, device);
+ NoteDeadFailure(reg, TwinCATStatusMapper.BadCommunicationError, refused: false, DateTime.UtcNow);
+ RaiseDataChange(handle, reference, new DataValueSnapshot(
+ null, TwinCATStatusMapper.BadCommunicationError, null, DateTime.UtcNow));
+ }
+ catch (Exception ex)
+ {
+ // A per-tag registration failure that is not a refusal (the AddDeviceNotification
+ // round-trip failed): this tag goes Bad + dead for the retry; the rest keep going.
+ _logger.LogWarning(ex,
+ "TwinCAT driver '{DriverInstanceId}' native-notification registration failed for " +
+ "'{Reference}'; it will be retried on the next healthy probe tick",
+ _driverInstanceId, reference);
+ MarkCommsFault(ex.Message, device);
+ NoteDeadFailure(reg, TwinCATStatusMapper.BadCommunicationError, refused: false, DateTime.UtcNow);
+ RaiseDataChange(handle, reference, new DataValueSnapshot(
+ null, TwinCATStatusMapper.BadCommunicationError, null, DateTime.UtcNow));
}
- registrations.Add(reg);
}
}
- catch (Exception ex)
+ catch (OperationCanceledException)
{
- // On any registration failure, tear down everything we got so far + rethrow. Leaves
- // the subscription in a clean "never existed" state rather than a half-registered
- // state the caller has to clean up.
- _logger.LogWarning(ex,
- "TwinCAT driver '{DriverInstanceId}' native-notification registration failed; " +
- "tearing down {Count} partial registrations",
- _driverInstanceId, registrations.Count);
+ // Cancelled mid-subscribe: leave the subscription in a clean "never existed" state rather
+ // than a half-registered one the caller has to clean up.
foreach (var r in registrations) { try { r.Dispose(); } catch { } }
throw;
}
@@ -724,6 +1008,160 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
return handle;
}
+ ///
+ /// The device state was released (re-initialise / teardown) while a connect on it ran. Not an
+ /// : the caller's token was not cancelled, so the data path maps
+ /// it like any failed request (Bad for this call) and ignores it.
+ ///
+ private sealed class DeviceReleasedException(string host)
+ : Exception($"TwinCAT device {host} was released during connect.");
+
+ /// The connect step of a native registration failed — the whole device is unreachable.
+ private sealed class DeviceUnavailableException(Exception inner)
+ : Exception("TwinCAT device unreachable: " + inner.Message, inner);
+
+ ///
+ /// Records a comms fault: preserving LastSuccessfulRead.
+ /// Never downgrades . A fault observed on a device is remembered ON
+ /// that device () and only that device answering clears it — the
+ /// driver state is the worst of every device (#739 fix round 1).
+ ///
+ /// The fault description.
+ /// The device the fault was observed on; a fault on a device already swapped out of
+ /// the table (a read/write racing a re-initialise or teardown) belongs to a dead generation and is
+ /// not recorded.
+ private void MarkCommsFault(string message, DeviceState? device = null)
+ {
+ if (device?.Released == true) return;
+ if (device is not null) device.CommsFault = message;
+ var current = _health;
+ if (current.State != DriverState.Faulted)
+ _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, message);
+ }
+
+ ///
+ /// Consecutive failed probe ticks (connect failure, failed ReadState) after which a device counts as
+ /// unreachable and the driver goes with LastSuccessfulRead
+ /// preserved — so an unreachable AMS router reaches the comms-loss watchdog instead of leaving the
+ /// driver Healthy with stale-Good values. Three ticks ride out a single dropped ReadState.
+ ///
+ internal const int ProbeFailuresBeforeDegraded = 3;
+
+ /// Minimum spacing between LastSuccessfulRead stamps from the notification path.
+ private static readonly TimeSpan AnsweredStampInterval = TimeSpan.FromMilliseconds(250);
+
+ ///
+ /// Why one device keeps the driver Degraded, or null when it does not: probe-unreachable, a PLC whose
+ /// ADS state is not Run (coordinator ruling: a stopped / config-mode PLC is Degraded with a
+ /// reason even though it answers), or an uncleared comms fault seen on it.
+ ///
+ ///
+ /// The ADS state comes from the probe's ReadState, so PLC-not-in-RUN detection needs the probe
+ /// (Probe.Enabled, the default); with the probe off the state is unknown and never degrades.
+ ///
+ private static string? DeviceDegradedReason(DeviceState d)
+ {
+ if (d.ConsecutiveProbeFailures >= ProbeFailuresBeforeDegraded)
+ return $"TwinCAT device {d.Options.HostAddress} unreachable ({d.ConsecutiveProbeFailures} consecutive failed probes)";
+ if (d.PlcState is { } st && !string.Equals(st, "Run", StringComparison.OrdinalIgnoreCase))
+ return $"TwinCAT device {d.Options.HostAddress}: PLC in {st}";
+ return d.CommsFault;
+ }
+
+ ///
+ /// Records that a device answered (a native push, a successful probe, a Good read): clears THAT
+ /// device's comms fault, advances LastSuccessfulRead, and sets the state to the worst of every
+ /// device — Healthy only when no device is unreachable, out of RUN or still faulted (then Degraded
+ /// with that device's reason; the stamp still advances, since a device did answer). Throttled so a
+ /// notification burst does not allocate a health record per value; never touches Faulted.
+ ///
+ /// The time the device answered.
+ /// The device that answered; null when not attributable.
+ /// Write even inside the throttle window (an explicit read / probe outcome).
+ private void RecordDeviceAnswered(DateTime now, DeviceState? device = null, bool force = false)
+ {
+ if (device?.Released == true) return;
+ if (device?.CommsFault is not null)
+ {
+ device.CommsFault = null;
+ force = true;
+ }
+ var current = _health;
+ if (current.State == DriverState.Faulted) return;
+ // Hot path (a notification on a healthy driver inside the window): no device scan, no allocation.
+ if (!force && current.State == DriverState.Healthy && current.LastSuccessfulRead is { } recent
+ && now - recent < AnsweredStampInterval)
+ return;
+ string? reason = null;
+ foreach (var d in _devices.Values)
+ if ((reason = DeviceDegradedReason(d)) is not null) break;
+ var state = reason is null ? DriverState.Healthy : DriverState.Degraded;
+ if (!force && current.State == state && current.LastSuccessfulRead is { } last
+ && now - last < AnsweredStampInterval)
+ return;
+ _health = new DriverHealth(state, now, reason);
+ }
+
+ /// First probe-tick retry delay of a REFUSED dead registration (the PLC answered and refused it).
+ internal static readonly TimeSpan RefusedRetryInitial = TimeSpan.FromSeconds(30);
+
+ /// Cap of the refused-registration retry backoff.
+ internal static readonly TimeSpan RefusedRetryMax = TimeSpan.FromMinutes(10);
+
+ ///
+ /// Books a failed registration attempt. A REFUSAL is deterministic until the PLC program changes, so it
+ /// backs off exponentially (30 s doubling to 10 min) and is re-armed at once by a symbol-version change
+ /// or retried by the reconnect replay; a comms-class failure stays on the every-tick retry.
+ ///
+ /// True when the published status changes (the caller republishes the tag).
+ /// The registration.
+ /// The status the failure maps to.
+ /// True for a refusal (backs off), false for a comms-class failure.
+ /// The failure time.
+ /// The registration's when the attempt
+ /// started; when a re-arm happened while it was in flight the backoff is NOT re-imposed (the re-arm wins).
+ private static bool NoteDeadFailure(NativeRegistration reg, uint status, bool refused, DateTime now,
+ int? epochAtAttempt = null)
+ {
+ if (epochAtAttempt is int epoch && epoch != reg.RearmEpoch)
+ {
+ // A symbol-version change re-armed this registration mid-attempt: keep it re-armed.
+ }
+ else if (refused)
+ {
+ reg.RefusalBackoff = reg.RefusalBackoff == TimeSpan.Zero
+ ? RefusedRetryInitial
+ : TimeSpan.FromTicks(Math.Min(reg.RefusalBackoff.Ticks * 2, RefusedRetryMax.Ticks));
+ reg.NextRetryUtc = now + reg.RefusalBackoff;
+ }
+ else
+ {
+ reg.RefusalBackoff = TimeSpan.Zero;
+ reg.NextRetryUtc = DateTime.MinValue;
+ }
+ var changed = reg.DeadStatus != status;
+ reg.DeadStatus = status;
+ return changed;
+ }
+
+ /// Clears the dead-registration bookkeeping after a successful (re-)registration.
+ private static void NoteLive(NativeRegistration reg)
+ {
+ reg.DeadStatus = null;
+ reg.RefusalBackoff = TimeSpan.Zero;
+ reg.NextRetryUtc = DateTime.MinValue;
+ }
+
+ ///
+ /// Raises one data change for a native subscription on BOTH events: the per-tag one (legacy consumers)
+ /// and a one-item batch (a fresh list per raise — the consumer may retain it).
+ ///
+ private void RaiseDataChange(ISubscriptionHandle handle, string reference, DataValueSnapshot snapshot)
+ {
+ OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, reference, snapshot));
+ OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, [new DataChangeItem(reference, snapshot)]));
+ }
+
///
/// Registers one native notification and records its replayable intent on the device — both
/// under the device so the register can't race a
@@ -736,7 +1174,20 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false);
try
{
- var client = await EnsureConnectedUnderGateAsync(device, ct, timeoutOverride: null).ConfigureAwait(false);
+ ITwinCATClient client;
+ try
+ {
+ client = await EnsureConnectedUnderGateAsync(device, ct, timeoutOverride: null).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (Exception ex)
+ {
+ // Keep the intent dead (no live handle): the replay after the next successful connect
+ // re-registers it. The caller short-circuits the rest of this device's tags.
+ device.NativeRegistrations[reg.Id] = reg;
+ throw new DeviceUnavailableException(ex);
+ }
+
ITwinCATNotificationHandle newHandle;
try
{
@@ -744,7 +1195,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
reg.SymbolName, reg.DataType, reg.BitIndex, reg.ArrayLength, reg.Interval,
_options.NotificationMaxDelayMs, reg.OnChange, ct).ConfigureAwait(false);
}
- catch (TwinCATNotificationRefusedException)
+ catch (OperationCanceledException) { throw; }
+ catch
{
// Keep the intent with no live handle (a "dead" registration) so the probe-tick
// retry re-checks it and UnsubscribeAsync still removes it; the caller isolates it.
@@ -772,7 +1224,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
}
// Async teardown: the engine drains the in-flight poll without blocking the caller's
// thread — this runs inside a DriverInstanceActor mailbox turn.
- await _poll.UnsubscribeAsync(handle).ConfigureAwait(false);
+ // A handle of a retired generation has nothing left to stop: its engine was disposed with it.
+ var current = _poll;
+ if (handle is TwinCATPollHandle poll && poll.Generation == current.Serial)
+ await current.Engine.UnsubscribeAsync(poll.Inner).ConfigureAwait(false);
}
private sealed record NativeSubscriptionHandle(long Id) : ISubscriptionHandle
@@ -819,7 +1274,14 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// the shape has no quality channel. Closes over the same
/// (handle, reference) pair as .
///
- public Action OnUnavailable { get; }
+ public Action OnUnavailableStatus { get; }
+
+ /// Publishes BadCommunicationError for a handle that went dead on a comms failure.
+ public void OnUnavailable() => OnUnavailableStatus(TwinCATStatusMapper.BadCommunicationError);
+
+ /// Publishes the status of a refusal (the PLC answered and refused this tag).
+ /// The refusal's OPC UA status.
+ public void OnRefused(uint status) => OnUnavailableStatus(status);
private ITwinCATNotificationHandle? _handle;
@@ -839,10 +1301,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// Authored array length for an array tag; null for a scalar.
/// Requested notification interval.
/// Value-change callback (closes over the owning subscription handle + reference).
- /// Bad-quality callback fired when the handle goes dead.
+ /// Bad-quality callback (given the status to publish) fired when the handle goes dead.
public NativeRegistration(long id, DeviceState device, string reference, string symbolName,
TwinCATDataType dataType, int? bitIndex, int? arrayLength, TimeSpan interval, Action onChange,
- Action onUnavailable)
+ Action onUnavailable)
{
Id = id;
Device = device;
@@ -852,8 +1314,10 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
BitIndex = bitIndex;
ArrayLength = arrayLength;
Interval = interval;
- OnChange = onChange;
- OnUnavailable = onUnavailable;
+ // A notification already queued in the client's dispatch channel can be delivered after this
+ // registration is disposed (unsubscribe / teardown): drop it here, for every client.
+ OnChange = (symbol, value) => { if (!IsDisposed) onChange(symbol, value); };
+ OnUnavailableStatus = status => { if (!IsDisposed) onUnavailable(status); };
}
/// Installs a freshly-registered handle and returns the previous one (null on first set).
@@ -871,9 +1335,40 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public ITwinCATNotificationHandle? MarkHandleDead() =>
Interlocked.Exchange(ref _handle, null);
+ /// The Bad status last published for this registration while dead; null while live.
+ public uint? DeadStatus { get; set; }
+
+ /// Earliest time the probe-tick retry may re-attempt this dead registration.
+ public DateTime NextRetryUtc { get; set; }
+
+ /// Current refusal backoff (zero after a comms-class failure, a success or a re-arm).
+ public TimeSpan RefusalBackoff { get; set; }
+
+ private int _rearmEpoch;
+
+ /// Bumped by every symbol-version re-arm; a retry that started before it must not re-impose a backoff.
+ public int RearmEpoch => Volatile.Read(ref _rearmEpoch);
+
+ ///
+ /// Re-arms the retry after a PLC program change: retry on the next tick and restart the backoff
+ /// from (the refusal it backed off from may no longer apply).
+ ///
+ public void Rearm()
+ {
+ Interlocked.Increment(ref _rearmEpoch);
+ RefusalBackoff = TimeSpan.Zero;
+ NextRetryUtc = DateTime.MinValue;
+ }
+
+ private volatile bool _disposed;
+
+ /// Gets a value indicating whether this registration has been disposed (its callbacks are dropped).
+ public bool IsDisposed => _disposed;
+
/// Removes this registration from its device and disposes its live handle.
public void Dispose()
{
+ _disposed = true;
Device.NativeRegistrations.TryRemove(Id, out _);
var h = Interlocked.Exchange(ref _handle, null);
try { h?.Dispose(); } catch { /* best-effort */ }
@@ -901,6 +1396,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
var client = await EnsureConnectedAsync(state, ct, _options.Probe.Timeout, bypassBackoff: true)
.ConfigureAwait(false);
success = await client.ProbeAsync(ct).ConfigureAwait(false);
+ // The probe's ReadState also says whether the PLC is in RUN; a stopped PLC answers but
+ // serves no live data, so it keeps the driver Degraded (RecordDeviceAnswered below).
+ if (success) state.PlcState = client.LastAdsState;
// A locally-connected client whose wire probe FAILS is wire-dead but port-open:
// force a recycle so the next tick rebuilds + replays native notifications. Without
@@ -927,6 +1425,18 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
}
+ if (success)
+ {
+ state.ConsecutiveProbeFailures = 0;
+ RecordDeviceAnswered(DateTime.UtcNow, state, force: true);
+ if (probed is not null) ReportNotificationDrops(state, probed);
+ }
+ else if (++state.ConsecutiveProbeFailures >= ProbeFailuresBeforeDegraded)
+ {
+ MarkCommsFault($"TwinCAT device {state.Options.HostAddress} unreachable " +
+ $"({state.ConsecutiveProbeFailures} consecutive failed probes)", state);
+ }
+
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
// Opt-in PLC state / task-cycle sampling for /hosts. After the host-state transition so a
@@ -942,6 +1452,24 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
}
}
+ ///
+ /// Logs a Warning when the client's native-notification queue dropped values since the last probe
+ /// tick (the queue is bounded and drops on overflow — silently, before #739 L8). At most one
+ /// Warning per probe interval, carrying the delta and the total.
+ ///
+ private void ReportNotificationDrops(DeviceState state, ITwinCATClient client)
+ {
+ var total = client.DroppedNotificationCount;
+ var delta = total - state.ReportedNotificationDrops;
+ if (delta <= 0) return;
+ state.ReportedNotificationDrops = total;
+ _logger.LogWarning(
+ "TwinCAT driver '{DriverInstanceId}' device '{HostAddress}' dropped {Dropped} native notification(s) " +
+ "since the last probe ({Total} on this connection): the notification queue was full — the consumer " +
+ "is not keeping up with the PLC's change rate",
+ _driverInstanceId, state.Options.HostAddress, delta, total);
+ }
+
// ---- IDriverDiagnostics ----
// Well-known system symbols, exported by TC3 (and most TC2) PLC runtimes under TwinCAT_SystemInfoVarList.
@@ -1131,11 +1659,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// CLI, pass the host string as the DeviceName). An empty DeviceName routes to the sole device
/// on a single-device instance; an unmatched name leaves the def unrouted (→ BadNodeIdUnknown).
///
+ /// The options generation being initialised.
/// The owning device's name (or host) from the raw-tag entry.
/// The resolved device host address, or empty when unresolved.
- private string ResolveDeviceHost(string deviceName)
+ private static string ResolveDeviceHost(TwinCATDriverOptions options, string deviceName)
{
- var devices = _options.Devices;
+ var devices = options.Devices;
if (string.IsNullOrEmpty(deviceName))
return devices.Count == 1 ? devices[0].HostAddress : "";
foreach (var d in devices)
@@ -1207,6 +1736,8 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
{
// Re-check under the gate: another caller may have connected while we waited.
if (device.Client is { IsConnected: true } c) return c;
+ // A released device (swapped out by a re-initialise / teardown) never connects again.
+ if (device.Released) throw new DeviceReleasedException(device.Options.HostAddress);
// Inside an open backoff window a data-path caller fails fast — no client
// create, no wire connect. The probe loop bypasses this (its interval is the recovery
@@ -1247,7 +1778,16 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
device.Backoff.RecordFailure(DateTime.UtcNow); // open the backoff window
throw;
}
+ if (device.Released)
+ {
+ // This device was torn down while the (blocking) connect ran: never install a client on a
+ // released generation, where nothing would ever dispose it.
+ client.OnSymbolVersionChanged -= HandleSymbolVersionChanged;
+ try { client.Dispose(); } catch { /* best-effort */ }
+ throw new DeviceReleasedException(device.Options.HostAddress);
+ }
device.Client = client;
+ device.ReportedNotificationDrops = 0;
device.UnavailableDiagSymbols.Clear(); // a rebuilt client may face a PLC that now exports them
device.Backoff.RecordSuccess(); // recovery resets the window immediately
@@ -1289,8 +1829,23 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
try { orphan?.Dispose(); } catch { /* best-effort */ }
continue;
}
+ NoteLive(reg);
replayed++;
}
+ catch (TwinCATNotificationRefusedException refused)
+ {
+ // The PLC answered and refused this tag again (it was already isolated at subscribe, or a
+ // program change made it undecodable): republish the refusal's status, keep it dead for the
+ // probe-tick retry, and leave health alone — a refusal is not a comms fault.
+ _logger.LogDebug(
+ "TwinCAT driver '{DriverInstanceId}' native notification '{Symbol}' on device '{HostAddress}' " +
+ "still refused after reconnect: {Reason}",
+ _driverInstanceId, reg.SymbolName, device.Options.HostAddress, refused.Message);
+ var dead = reg.MarkHandleDead();
+ try { dead?.Dispose(); } catch { /* already-dead handle from the disposed client */ }
+ if (NoteDeadFailure(reg, refused.StatusCode, refused: true, DateTime.UtcNow))
+ try { reg.OnRefused(refused.StatusCode); } catch { /* never abort recovery of the rest */ }
+ }
catch (Exception ex)
{
_logger.LogWarning(ex,
@@ -1303,10 +1858,9 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
// no quality channel), and degrade health preserving the last successful read.
var dead = reg.MarkHandleDead();
try { dead?.Dispose(); } catch { /* already-dead handle from the disposed client */ }
- try { reg.OnUnavailable(); } catch { /* never let a subscriber callback abort recovery of the rest */ }
- if (_health.State != DriverState.Faulted)
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
- $"native-notification replay failed for '{reg.SymbolName}' on {device.Options.HostAddress}");
+ if (NoteDeadFailure(reg, TwinCATStatusMapper.BadCommunicationError, refused: false, DateTime.UtcNow))
+ try { reg.OnUnavailable(); } catch { /* never let a subscriber callback abort recovery of the rest */ }
+ MarkCommsFault($"native-notification replay failed for '{reg.SymbolName}' on {device.Options.HostAddress}", device);
}
}
@@ -1335,9 +1889,13 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
if (device.Client is not { IsConnected: true } client) return;
var recovered = 0;
+ var now = DateTime.UtcNow;
foreach (var reg in device.NativeRegistrations.Values)
{
if (reg.HasLiveHandle) continue;
+ // A refused registration waits out its backoff (a symbol-version change re-arms it).
+ if (now < reg.NextRetryUtc) continue;
+ var epoch = reg.RearmEpoch;
try
{
var newHandle = await client.AddNotificationAsync(
@@ -1351,11 +1909,18 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
try { orphan?.Dispose(); } catch { /* best-effort */ }
continue;
}
+ NoteLive(reg);
recovered++;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
+ // Republish only when the failure CLASS changes (a comms blip that turned into a refusal,
+ // or the reverse); the same failure again publishes nothing.
+ var refusal = ex as TwinCATNotificationRefusedException;
+ var status = refusal?.StatusCode ?? TwinCATStatusMapper.BadCommunicationError;
+ if (NoteDeadFailure(reg, status, refusal is not null, now, epoch))
+ try { reg.OnRefused(status); } catch { /* never abort the rest */ }
_logger.LogDebug(ex,
"TwinCAT driver '{DriverInstanceId}' probe-tick retry of dead notification " +
"'{Symbol}' on device '{HostAddress}' failed; will retry next tick",
@@ -1411,10 +1976,18 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// space must be rebuilt — this is the documented TwinCAT failure mode, not a transient
/// connection error.
///
- private void HandleSymbolVersionChanged(object? sender, EventArgs e) =>
+ private void HandleSymbolVersionChanged(object? sender, EventArgs e)
+ {
+ // A program change can make a refused registration valid: re-arm every backed-off dead intent of the
+ // device whose client saw it, so the next probe tick re-checks them at once.
+ foreach (var d in _devices.Values)
+ if (ReferenceEquals(d.Client, sender))
+ foreach (var reg in d.NativeRegistrations.Values)
+ reg.Rearm();
OnRediscoveryNeeded?.Invoke(this, new RediscoveryEventArgs(
"TwinCAT symbol-version-changed (DeviceSymbolVersionInvalid 0x0711) — PLC program re-downloaded",
ScopeHint: "TwinCAT"));
+ }
///
/// Synchronous teardown — no await, no captured sync context. The OPC UA stack
@@ -1432,30 +2005,17 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
foreach (var r in sub.Registrations) { try { r.Dispose(); } catch { } }
_nativeSubs.Clear();
- // PollGroupEngine.DisposeAsync awaits loop tasks; we drive that synchronously here
- // (bounded wait — same 5s ceiling DisposeAsync uses internally) using Wait() on the
- // returned ValueTask so no sync-context capture happens.
- try { _poll.DisposeAsync().AsTask().Wait(TimeSpan.FromSeconds(5)); } catch { }
+ var (taken, retiredPoll) = TakeAllForTeardown();
+ var probes = BeginRelease(taken);
- foreach (var state in _devices.Values)
- {
- try { state.ProbeCts?.Cancel(); } catch { }
- if (state.ProbeTask is Task pt)
- {
- try { pt.Wait(TimeSpan.FromSeconds(2)); } catch { /* probe-cancel races are expected */ }
- }
- state.ProbeCts?.Dispose();
- state.ProbeCts = null;
- state.DisposeClient();
- state.DisposeGate();
- }
- _devices.Clear();
- _tagsByRawPath.Clear();
+ // PollGroupEngine.DisposeAsync awaits loop tasks; drive it synchronously with a bounded Wait() on
+ // the returned task (no sync-context capture), in parallel with the probe join.
+ var poll = retiredPoll.DisposeAsync().AsTask();
+ try { Task.WhenAll([poll, .. probes]).Wait(ProbeJoinTimeout); } catch { /* cancel races are expected */ }
+ EndRelease(taken);
+
+ _warnedRefs.Clear();
_resolver.Clear(); // no-op in v3 (the resolver reads the live table by closure); kept for symmetry
- // Dispose + clear the per-parent-word RMW gates (mirrors ShutdownAsync) so the
- // SemaphoreSlim instances aren't orphaned on disposal.
- foreach (var sem in _bitRmwLocks.Values) sem.Dispose();
- _bitRmwLocks.Clear();
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
}
@@ -1517,6 +2077,15 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
/// True once an unexpected sum-read chunk failure was logged for this device.
public volatile bool SumChunkFailureWarned;
+ /// The last comms fault seen on this device; cleared when this device answers. Null = none.
+ public volatile string? CommsFault;
+
+ /// The ADS state (Run, Stop, Config, …) the last successful probe read; null = unknown.
+ public volatile string? PlcState;
+
+ /// Consecutive failed probe ticks; reset by a successful probe. Written only by this device's probe loop.
+ public volatile int ConsecutiveProbeFailures;
+
/// Latest probe-sampled PLC diagnostics; null before the first sample.
public TwinCATDeviceDiagnostics? LastDiagnostics { get; set; }
@@ -1532,7 +2101,14 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
UnavailableDiagSymbols.Clear();
}
- /// Disposes the connection gate semaphore.
- public void DisposeGate() => ConnectGate.Dispose();
+ ///
+ /// Set once this device state has been swapped out of the driver's table. A connect that
+ /// completes afterwards (a probe wedged in the blocking ADS Connect past the bounded join)
+ /// disposes its client instead of installing it on a dead generation.
+ ///
+ public volatile bool Released;
+
+ /// Total notification drops last reported for this device's current client.
+ public long ReportedNotificationDrops;
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStringCodec.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStringCodec.cs
index d3a90c1f..bdaff2ae 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStringCodec.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT/TwinCATStringCodec.cs
@@ -50,6 +50,57 @@ internal static class TwinCATStringCodec
return (utf8 ? Encoding.UTF8 : Windows1252).GetString(bytes);
}
+ // Strict encoders for the WRITE side: a character the target encoding cannot hold is refused rather
+ // than silently replaced with '?' (the replacement would be written to the PLC as if it were the value).
+ private static readonly Encoding StrictWindows1252 = CreateStrict(Windows1252.CodePage);
+ private static readonly Encoding StrictUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
+ private static readonly Encoding StrictUtf16 = new UnicodeEncoding(bigEndian: false, byteOrderMark: false, throwOnInvalidBytes: true);
+
+ private static Encoding CreateStrict(int codePage) =>
+ Encoding.GetEncoding(codePage, EncoderFallback.ExceptionFallback, DecoderFallback.ReplacementFallback);
+
+ ///
+ /// Encodes a value into an IEC STRING(n) buffer of the PLC's declared width (n+1 bytes): the
+ /// inverse of — Windows-1252 unless , NUL-terminated
+ /// and NUL-padded so no stale tail of a longer earlier value survives.
+ ///
+ /// The value to write.
+ /// The PLC symbol's byte width (n+1 for a STRING(n)).
+ /// True when the target reports UTF-8 string data.
+ /// Exactly bytes.
+ /// The value does not fit STRING(n), or holds a character the
+ /// target encoding cannot represent.
+ public static byte[] EncodeString(string value, int byteWidth, bool utf8) =>
+ Encode(value, byteWidth, utf8 ? StrictUtf8 : StrictWindows1252, terminatorBytes: 1, "STRING");
+
+ ///
+ /// Encodes a value into an IEC WSTRING(n) buffer (UTF-16LE, 2(n+1) bytes): the inverse of
+ /// , terminated by an aligned U+0000 and zero-padded.
+ ///
+ /// The value to write.
+ /// The PLC symbol's byte width.
+ /// Exactly bytes.
+ /// The value does not fit WSTRING(n).
+ public static byte[] EncodeWString(string value, int byteWidth) =>
+ Encode(value, byteWidth, StrictUtf16, terminatorBytes: 2, "WSTRING");
+
+ private static byte[] Encode(string value, int byteWidth, Encoding encoding, int terminatorBytes, string kind)
+ {
+ ArgumentNullException.ThrowIfNull(value);
+ byte[] encoded;
+ try { encoded = encoding.GetBytes(value); }
+ catch (ArgumentException ex) // EncoderFallbackException: a character the encoding cannot hold
+ {
+ throw new OverflowException($"'{value}' cannot be represented in the PLC's {kind} encoding: {ex.Message}", ex);
+ }
+ if (encoded.Length + terminatorBytes > byteWidth)
+ throw new OverflowException(
+ $"'{value}' is {encoded.Length} B but the PLC {kind} holds {Math.Max(0, byteWidth - terminatorBytes)} B");
+ var buffer = new byte[byteWidth];
+ encoded.CopyTo(buffer, 0);
+ return buffer;
+ }
+
/// Decodes an IEC WSTRING(n) buffer (UTF-16LE): ends at the first aligned U+0000.
/// The raw symbol bytes (2(n+1) for a WSTRING(n)).
/// The decoded value.
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.
+ @foreach (var v in Enum.GetValues())
+ {
+ @* String is only readable from the fixed tree (FocasTagDefinitionFactory.DescribeDataTypeMismatch):
+ offered there, and kept visible when a loaded blob already carries it so the select never lies. *@
+ if (v != FocasDataType.String || _m.DataType == FocasDataType.String
+ || FocasTagDefinitionFactory.DescribeDataTypeMismatch(v, _m.Address) is null)
+ {
+
+ }
+ }
+
+ @if (FocasTagDefinitionFactory.DescribeDataTypeMismatch(_m.DataType, _m.Address) is { } typeError)
+ {
+
@typeError
+ }
+
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/FocasTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/FocasTagConfigModel.cs
index 91851b8e..68904dff 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/FocasTagConfigModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/FocasTagConfigModel.cs
@@ -80,6 +80,10 @@ public sealed class FocasTagConfigModel
// ahead of the parse consult below and is never replaced by it.
if (FocasAddress.TryParse(Address) is null) return DescribeUnparseableAddress(Address);
+ // The driver's own address/data-type rule (String only on FIXED: paths) — checked on the typed fields so a
+ // model built in the editor is judged too, not only a loaded blob the parse gate below can see.
+ if (FocasTagDefinitionFactory.DescribeDataTypeMismatch(DataType, Address) is { } typeError) return typeError;
+
// Parse-gate backstop. ⚠️ It gates on the PARSE VERDICT, never on Inspect being non-empty: FOCAS's
// inspector emits advisories for blobs the reader ACCEPTS (an unparseable address, an authored
// 'writable':true on a read-only wire), and promoting those to authoring errors would make the modal
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.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/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.FOCAS.Cli.Tests/FocasCommandBaseValidationTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseValidationTests.cs
index 2efff255..66dcabde 100644
--- a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseValidationTests.cs
+++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Cli.Tests/FocasCommandBaseValidationTests.cs
@@ -33,6 +33,27 @@ public sealed class FocasCommandBaseValidationTests
/// Invokes option validation without interval.
public void InvokeValidateNoInterval() => ValidateOptions(intervalMs: null);
+
+ /// Invokes the address/data-type check.
+ public void InvokeValidateDataType(string address, FocasDataType type) => ValidateDataType(address, type);
+ }
+
+ ///
+ /// #740 / R07:U8 — --type String on a PMC / PARAM / MACRO address is refused at the CLI boundary
+ /// (the driver would skip the tag and the read would print BadNodeIdUnknown); a fixed-tree path keeps it.
+ ///
+ [Theory]
+ [InlineData("R100", FocasDataType.String, false)]
+ [InlineData("MACRO:500", FocasDataType.String, false)]
+ [InlineData("PARAM:6711", FocasDataType.String, false)]
+ [InlineData("R100", FocasDataType.Byte, true)]
+ [InlineData("FIXED:Identity/SeriesNumber", FocasDataType.String, true)]
+ public void Validate_data_type_against_address(string address, FocasDataType type, bool ok)
+ {
+ var sut = new Probe { CncHost = "host", IntervalMs = 1000 };
+ if (ok) Should.NotThrow(() => sut.InvokeValidateDataType(address, type));
+ else Should.Throw(() => sut.InvokeValidateDataType(address, type))
+ .Message.ShouldContain("String");
}
/// Verifies that default options are accepted.
diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/WriteCommandParseValueTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/WriteCommandParseValueTests.cs
index 89ed5c22..a06662a7 100644
--- a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/WriteCommandParseValueTests.cs
+++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Cli.Tests/WriteCommandParseValueTests.cs
@@ -184,4 +184,24 @@ public sealed class WriteCommandParseValueTests
{
ReadCommand.SynthesiseTagName(symbol, type).ShouldBe(expected);
}
+
+ /// An array value splits on the separator and parses each element per --type (#739 U9 gate tooling).
+ [Fact]
+ public void ParseArrayValue_parses_each_element()
+ {
+ WriteCommand.ParseArrayValue("1, -2,3", TwinCATDataType.Int, 3, ",")
+ .ShouldBe(new object[] { (short)1, (short)-2, (short)3 });
+ WriteCommand.ParseArrayValue("a b|c", TwinCATDataType.String, 2, "|")
+ .ShouldBe(new object[] { "a b", "c" });
+ }
+
+ /// A count that differs from --array-length is refused before the PLC is touched.
+ [Fact]
+ public void ParseArrayValue_refuses_a_count_mismatch()
+ {
+ Should.Throw(
+ () => WriteCommand.ParseArrayValue("1,2", TwinCATDataType.Int, 3, ","));
+ Should.Throw(
+ () => WriteCommand.ParseArrayValue("1", TwinCATDataType.Int, 0, ","));
+ }
}
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.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.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs
index 314fda86..ae0055b3 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendCoverageTests.cs
@@ -72,6 +72,155 @@ public sealed class WireBackendCoverageTests
}
}
+ ///
+ /// #740 / R07:H14 — a macro / parameter number above 32767 reaches the CNC unnarrowed. The mock decodes
+ /// the request's 32-bit Arg1 as the number, so a decoy is seeded at the address the old
+ /// (short) cast produced (99999 → -31073, 40000 → -25536, as the unsigned key the mock sees):
+ /// reading the decoy's value means the number was wrapped. #500 / #6711 are the positive control that
+ /// ordinary numbers still read.
+ ///
+ [Fact]
+ public async Task Macro_and_parameter_numbers_above_short_range_read_the_authored_variable()
+ {
+ if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
+ var ct = TestContext.Current.CancellationToken;
+
+ static string Wrapped(int number) => ((uint)(int)(short)number).ToString(System.Globalization.CultureInfo.InvariantCulture);
+
+ await _fx.LoadProfileAsync("FWLIB64", ct);
+ await _fx.PatchStateAsync(new
+ {
+ parameters = new Dictionary
+ {
+ ["6711"] = new { type = "long", value = 1234, @decimal = 0 },
+ ["40000"] = new { type = "long", value = 4242, @decimal = 0 },
+ [Wrapped(40000)] = new { type = "long", value = -1, @decimal = 0 },
+ },
+ macros = new Dictionary
+ {
+ ["500"] = new { value = 42000, @decimal = 3 },
+ ["99999"] = new { value = 7, @decimal = 0 },
+ [Wrapped(99999)] = new { value = 666, @decimal = 0 },
+ },
+ }, ct);
+
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ // ThirtyOne_i: the capability matrix admits #99999 (its documented ceiling); the parameter is read on
+ // an Unknown-series device, which admits any number.
+ Devices = [new FocasDeviceOptions(DeviceHost, Series: FocasCncSeries.ThirtyOne_i)],
+ RawTags = FocasRawTags.Entries(
+ new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false),
+ new FocasTagDefinition("Macro99999", DeviceHost, "MACRO:99999", FocasDataType.Int32, Writable: false),
+ new FocasTagDefinition("Param6711", DeviceHost, "PARAM:6711", FocasDataType.Int32, Writable: false)),
+ Probe = new FocasProbeOptions { Enabled = false },
+ }, driverInstanceId: "wire-wide-numbers", clientFactory: new WireFocasClientFactory());
+
+ var unknownSeries = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(DeviceHost)],
+ RawTags = FocasRawTags.Entries(
+ new FocasTagDefinition("Param40000", DeviceHost, "PARAM:40000", FocasDataType.Int32, Writable: false)),
+ Probe = new FocasProbeOptions { Enabled = false },
+ }, driverInstanceId: "wire-wide-param", clientFactory: new WireFocasClientFactory());
+
+ await using (drv)
+ await using (unknownSeries)
+ {
+ await drv.InitializeAsync("{}", ct);
+ var snaps = await drv.ReadAsync(["Macro500", "Macro99999", "Param6711"], ct);
+ snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good);
+ Convert.ToDouble(snaps[0].Value).ShouldBe(42.0, tolerance: 0.001);
+ snaps[1].Value.ShouldBe(7, "MACRO:99999 must read #99999, not the wrapped #-31073 decoy (666)");
+ Convert.ToInt32(snaps[2].Value).ShouldBe(1234);
+
+ await unknownSeries.InitializeAsync("{}", ct);
+ var param = await unknownSeries.ReadAsync(["Param40000"], ct);
+ param[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
+ Convert.ToInt32(param[0].Value).ShouldBe(4242, "PARAM:40000 must not read the wrapped decoy (-1)");
+ }
+ }
+
+ ///
+ /// #740 / R07:H13 — re-initialising a live driver (the fixed-tree loop running, reads in flight over the real
+ /// wire) swaps the device table instead of clearing it under its readers: no reader throws, reads keep
+ /// returning Good, and shutdown leaves no session behind (a fresh driver can still connect afterwards).
+ ///
+ [Fact]
+ public async Task Reinitialise_under_live_reads_swaps_the_device_table()
+ {
+ if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
+ var ct = TestContext.Current.CancellationToken;
+
+ await _fx.LoadProfileAsync("FWLIB64", ct);
+ await _fx.PatchStateAsync(new
+ {
+ macros = new Dictionary { ["500"] = new { value = 42000, @decimal = 3 } },
+ }, ct);
+
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(DeviceHost)],
+ RawTags = FocasRawTags.Entries(
+ new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false)),
+ Probe = new FocasProbeOptions { Enabled = false },
+ FixedTree = new FocasFixedTreeOptions
+ {
+ Enabled = true,
+ PollInterval = TimeSpan.FromMilliseconds(50),
+ ProgramPollInterval = TimeSpan.Zero,
+ TimerPollInterval = TimeSpan.Zero,
+ },
+ }, driverInstanceId: "wire-reinit-swap", clientFactory: new WireFocasClientFactory());
+
+ await using (drv)
+ {
+ await drv.InitializeAsync("{}", ct);
+ using var stop = CancellationTokenSource.CreateLinkedTokenSource(ct);
+ var errors = new ConcurrentQueue();
+ var good = 0;
+ var reader = Task.Run(async () =>
+ {
+ while (!stop.IsCancellationRequested)
+ {
+ try
+ {
+ _ = drv.GetHostStatuses();
+ var s = await drv.ReadAsync(["Macro500", $"{DeviceHost}/Identity/SeriesNumber"], CancellationToken.None);
+ if (s[0].StatusCode == FocasStatusMapper.Good) Interlocked.Increment(ref good);
+ }
+ catch (Exception ex) { errors.Enqueue(ex); }
+ }
+ }, CancellationToken.None);
+
+ for (var i = 0; i < 5; i++)
+ {
+ await Task.Delay(150, ct);
+ await drv.InitializeAsync("{}", ct);
+ }
+ await Task.Delay(300, ct);
+ stop.Cancel();
+ await reader;
+
+ errors.ShouldBeEmpty();
+ good.ShouldBeGreaterThan(0, "positive control: reads went over the wire between re-initialises");
+ }
+
+ // A fresh driver still connects and reads Good after the re-initialise churn and the shutdown.
+ var after = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(DeviceHost)],
+ RawTags = FocasRawTags.Entries(
+ new FocasTagDefinition("Macro500", DeviceHost, "MACRO:500", FocasDataType.Float64, Writable: false)),
+ Probe = new FocasProbeOptions { Enabled = false },
+ }, driverInstanceId: "wire-reinit-after", clientFactory: new WireFocasClientFactory());
+ await using (after)
+ {
+ await after.InitializeAsync("{}", ct);
+ (await after.ReadAsync(["Macro500"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
+ }
+ }
+
/// Verifies that discover emits device folder and tag variables.
[Fact]
public async Task Discover_emits_device_folder_and_tag_variables()
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendHealthAndLayoutTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendHealthAndLayoutTests.cs
new file mode 100644
index 00000000..c704b21f
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests/Series/WireBackendHealthAndLayoutTests.cs
@@ -0,0 +1,218 @@
+using System.Net;
+using System.Net.Sockets;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.IntegrationTests.Series;
+
+///
+/// #740 fix round 1 (review I2) — wire-level cases for H15 (fixed-tree health) and H20 (alarm record layout)
+/// against the focas-mock fixture.
+///
+[Collection(FocasSimCollection.Name)]
+public sealed class WireBackendHealthAndLayoutTests
+{
+ private readonly FocasSimFixture _fx;
+
+ /// Initializes a new instance of the test class with the FOCAS simulation fixture.
+ /// The FOCAS simulation fixture.
+ public WireBackendHealthAndLayoutTests(FocasSimFixture fx) => _fx = fx;
+
+ ///
+ /// A local TCP relay in front of the shared mock, so ONE test can take "the CNC" away and bring it back
+ /// without stopping a fixture other suites use: closes every relayed session and stops
+ /// listening (new connects are refused); listens again on the same port.
+ ///
+ private sealed class CncRelay : IAsyncDisposable
+ {
+ private readonly string _targetHost;
+ private readonly int _targetPort;
+ private readonly List _live = [];
+ private TcpListener? _listener;
+ private CancellationTokenSource? _cts;
+
+ public CncRelay(string targetHost, int targetPort)
+ {
+ _targetHost = targetHost;
+ _targetPort = targetPort;
+ Port = Listen(0);
+ }
+
+ public int Port { get; private set; }
+
+ private int Listen(int port)
+ {
+ _cts = new CancellationTokenSource();
+ _listener = new TcpListener(IPAddress.Loopback, port);
+ _listener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
+ _listener.Start();
+ var ct = _cts.Token;
+ var listener = _listener;
+ _ = Task.Run(() => AcceptLoopAsync(listener, ct), CancellationToken.None);
+ return ((IPEndPoint)_listener.LocalEndpoint).Port;
+ }
+
+ private async Task AcceptLoopAsync(TcpListener listener, CancellationToken ct)
+ {
+ while (!ct.IsCancellationRequested)
+ {
+ TcpClient inbound;
+ try { inbound = await listener.AcceptTcpClientAsync(ct); }
+ catch { return; }
+ var outbound = new TcpClient();
+ try { await outbound.ConnectAsync(_targetHost, _targetPort, ct); }
+ catch { inbound.Dispose(); outbound.Dispose(); continue; }
+ lock (_live) { _live.Add(inbound); _live.Add(outbound); }
+ _ = Pump(inbound, outbound, ct);
+ _ = Pump(outbound, inbound, ct);
+ }
+ }
+
+ private static async Task Pump(TcpClient from, TcpClient to, CancellationToken ct)
+ {
+ try { await from.GetStream().CopyToAsync(to.GetStream(), ct); }
+ catch { /* the relay was taken down, or a side closed */ }
+ finally { from.Dispose(); to.Dispose(); }
+ }
+
+ public void Down()
+ {
+ _cts?.Cancel();
+ _listener?.Stop();
+ lock (_live)
+ {
+ foreach (var c in _live) c.Dispose();
+ _live.Clear();
+ }
+ }
+
+ ///
+ /// Listens again on the SAME port (the driver's host string names it). The re-bind is retried briefly: the
+ /// OS can hold the port for a moment after , or hand it to another process — the latter
+ /// surfaces as a clear failure after the retries rather than a random flake.
+ ///
+ public void Up()
+ {
+ for (var attempt = 1; ; attempt++)
+ {
+ try { Listen(Port); return; }
+ catch (SocketException) when (attempt < 20) { Thread.Sleep(100); }
+ }
+ }
+
+ public ValueTask DisposeAsync()
+ {
+ Down();
+ return ValueTask.CompletedTask;
+ }
+ }
+
+ ///
+ /// H15 over the wire: a driver serving only FIXED: tags is Healthy with a moving LastSuccessfulRead
+ /// while its CNC answers; when the CNC disappears it degrades with LastSuccessfulRead frozen (the #527
+ /// watchdog's conjunction); and it recovers to Healthy when the CNC returns (positive control).
+ ///
+ [Fact]
+ public async Task Fixed_only_driver_degrades_on_cnc_loss_and_recovers_over_the_wire()
+ {
+ if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
+ var ct = TestContext.Current.CancellationToken;
+ await _fx.LoadProfileAsync("FWLIB64", ct);
+
+ await using var relay = new CncRelay(_fx.Host, _fx.Port);
+ var host = $"focas://127.0.0.1:{relay.Port}";
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(host)],
+ RawTags = FocasRawTags.Entries(
+ new FocasTagDefinition("Series", host, "FIXED:Identity/SeriesNumber", FocasDataType.String, Writable: false)),
+ Probe = new FocasProbeOptions { Enabled = false },
+ Timeout = TimeSpan.FromSeconds(1),
+ FixedTree = new FocasFixedTreeOptions
+ {
+ PollInterval = TimeSpan.FromMilliseconds(50),
+ ProgramPollInterval = TimeSpan.Zero,
+ TimerPollInterval = TimeSpan.Zero,
+ },
+ }, driverInstanceId: "wire-fixed-health", clientFactory: new WireFocasClientFactory())
+ {
+ FixedTreeStaleBudgetOverride = TimeSpan.FromMilliseconds(500),
+ };
+
+ await using (drv)
+ {
+ await drv.InitializeAsync("{}", ct);
+ await WaitFor(() => drv.GetDeviceState(host)?.LastFixedPollUtc is not null, TimeSpan.FromSeconds(10));
+ var first = drv.GetHealth().LastSuccessfulRead!.Value;
+ await WaitFor(() => drv.GetHealth().LastSuccessfulRead > first, TimeSpan.FromSeconds(5));
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+
+ relay.Down();
+ await WaitFor(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(15));
+ var frozen = drv.GetHealth().LastSuccessfulRead;
+ await Task.Delay(TimeSpan.FromSeconds(1), ct);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen, "nothing answered, so LastSuccessfulRead stays frozen");
+ (await drv.ReadAsync(["Series"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError,
+ "the stale fixed-tree value reads Bad on the same budget that degrades the driver");
+
+ // Positive control: the CNC returns ⇒ Healthy again, LastSuccessfulRead moves. The connect backoff
+ // (1 s doubling) bounds how soon the loop re-dials.
+ relay.Up();
+ await WaitFor(() => drv.GetHealth().State == DriverState.Healthy, TimeSpan.FromSeconds(40));
+ drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(frozen!.Value);
+ (await drv.ReadAsync(["Series"], ct))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
+ }
+ }
+
+ ///
+ /// H20 over the wire: the mock answers cnc_rdalmmsg2 in the 80-byte LEGACY layout. Nineteen active
+ /// alarms make a 1,520-byte payload — also exactly 20 × 76 — which the old length test decoded as twenty
+ /// garbage vendor records. Positive control: the unambiguous 3-alarm reply decodes the same alarms.
+ ///
+ [Fact]
+ public async Task Nineteen_legacy_alarms_decode_as_nineteen_over_the_wire()
+ {
+ if (_fx.SkipReason is not null) Assert.Skip(_fx.SkipReason);
+ var ct = TestContext.Current.CancellationToken;
+ await _fx.LoadProfileAsync("FWLIB64", ct);
+
+ var client = new WireFocasClientFactory().Create();
+ try
+ {
+ await client.ConnectAsync(FocasHostAddress.TryParse(_fx.DeviceUri)!, TimeSpan.FromSeconds(2), ct);
+
+ await _fx.PatchStateAsync(new { alarms = Alarms(3) }, ct);
+ var three = await client.ReadAlarmsAsync(ct);
+ three.Count.ShouldBe(3);
+ three[2].AlarmNumber.ShouldBe(402);
+
+ await _fx.PatchStateAsync(new { alarms = Alarms(19) }, ct);
+ var nineteen = await client.ReadAlarmsAsync(ct);
+ nineteen.Count.ShouldBe(19, "a 1,520-byte legacy reply is 19 records, not 20 vendor ones");
+ nineteen.Select(a => a.AlarmNumber).ShouldBe(Enumerable.Range(400, 19));
+ nineteen[18].Message.ShouldBe("SERVO ALARM 418");
+ nineteen[18].Axis.ShouldBe((short)(1 + (18 % 4)));
+ }
+ finally
+ {
+ client.Dispose();
+ await _fx.PatchStateAsync(new { alarms = Array.Empty() }, ct);
+ }
+
+ static object[] Alarms(int n) => [.. Enumerable.Range(0, n)
+ .Select(i => (object)new { alm_no = 400 + i, type = 2 + (i % 3), axis = 1 + (i % 4), msg = $"SERVO ALARM {400 + i}" })];
+ }
+
+ private static async Task WaitFor(Func condition, TimeSpan timeout)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (!condition())
+ {
+ if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
+ await Task.Delay(25);
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
index 00cf61d5..370bff15 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FakeFocasClient.cs
@@ -259,7 +259,7 @@ internal sealed class FakeFocasClientFactory : IFocasClientFactory
public IFocasClient Create()
{
var c = Customise?.Invoke() ?? new FakeFocasClient();
- Clients.Add(c);
+ lock (Clients) Clients.Add(c);
return c;
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmLayoutTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmLayoutTests.cs
new file mode 100644
index 00000000..467e72c3
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmLayoutTests.cs
@@ -0,0 +1,114 @@
+using System.Buffers.Binary;
+using System.Text;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// #740 / R07:H20 — ParseAlarms picked the 76-byte vendor layout whenever Length % 76 == 0, so a
+/// legacy 80-byte reply of exactly 19 records (1,520 B = 20 × 76) decoded as 20 garbage vendor records. The
+/// response block carries no record count, so the layout is now chosen from the two things that are known: the
+/// count the REQUEST asked for (a reply never holds more) and each layout's own field invariants.
+///
+[Trait("Category", "Unit")]
+public sealed class FocasAlarmLayoutTests
+{
+ private static byte[] Legacy(params (int no, int type, int axis, string msg)[] records)
+ {
+ var buf = new byte[records.Length * 80];
+ for (var i = 0; i < records.Length; i++)
+ {
+ var o = i * 80;
+ var (no, type, axis, msg) = records[i];
+ BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o, 4), no);
+ BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 4, 4), type);
+ BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 8, 4), axis);
+ BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o + 12, 4), msg.Length);
+ Encoding.ASCII.GetBytes(msg).CopyTo(buf.AsSpan(o + 16));
+ }
+ return buf;
+ }
+
+ private static byte[] Vendor(params (int no, short type, short axis, string msg)[] records)
+ {
+ var buf = new byte[records.Length * 76];
+ for (var i = 0; i < records.Length; i++)
+ {
+ var o = i * 76;
+ var (no, type, axis, msg) = records[i];
+ BinaryPrimitives.WriteInt32BigEndian(buf.AsSpan(o, 4), no);
+ BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 4, 2), type);
+ BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 6, 2), axis);
+ BinaryPrimitives.WriteInt16BigEndian(buf.AsSpan(o + 10, 2), (short)msg.Length);
+ Encoding.ASCII.GetBytes(msg).CopyTo(buf.AsSpan(o + 12));
+ }
+ return buf;
+ }
+
+ private static (int, int, int, string)[] LegacySet(int n) =>
+ [.. Enumerable.Range(0, n).Select(i => (400 + i, 2 + (i % 3), 1 + (i % 4), $"SERVO ALARM {400 + i}"))];
+
+ private static (int, short, short, string)[] VendorSet(int n) =>
+ [.. Enumerable.Range(0, n).Select(i => (1000 + i, (short)(4 + (i % 3)), (short)(i % 2), $"OVERTRAVEL +{i}"))];
+
+ /// The ambiguous length: 19 legacy records decode as 19 legacy records, not 20 vendor ones.
+ [Fact]
+ public void Nineteen_legacy_records_decode_as_legacy()
+ {
+ var payload = Legacy(LegacySet(19));
+ payload.Length.ShouldBe(1520);
+ (payload.Length % 76).ShouldBe(0, "the length really is ambiguous");
+
+ var alarms = FocasWireClient.ParseAlarms(payload, 32);
+
+ alarms.Count.ShouldBe(19);
+ alarms[0].AlarmNumber.ShouldBe(400);
+ alarms[0].Message.ShouldBe("SERVO ALARM 400");
+ alarms[18].AlarmNumber.ShouldBe(418);
+ alarms[18].Axis.ShouldBe((short)(1 + (18 % 4)));
+ }
+
+ /// Positive control at the same length: 20 vendor records still decode as vendor.
+ [Fact]
+ public void Twenty_vendor_records_decode_as_vendor()
+ {
+ var payload = Vendor(VendorSet(20));
+ payload.Length.ShouldBe(1520);
+
+ var alarms = FocasWireClient.ParseAlarms(payload, 32);
+
+ alarms.Count.ShouldBe(20);
+ alarms[0].AlarmNumber.ShouldBe(1000);
+ alarms[19].Message.ShouldBe("OVERTRAVEL +19");
+ }
+
+ ///
+ /// Review I2 edge: vendor records with type 0 and EMPTY messages also satisfy the legacy invariants (the
+ /// int32 read of type<<16 | axis is just the axis, and the "length" int32 is the empty message's
+ /// zero bytes) — both readings are plausible, so the vendor default must hold and the numbers decode.
+ ///
+ [Fact]
+ public void Twenty_vendor_records_with_type_zero_and_empty_messages_stay_vendor()
+ {
+ var payload = Vendor([.. Enumerable.Range(0, 20).Select(i => (2000 + i, (short)0, (short)(i % 3), ""))]);
+ payload.Length.ShouldBe(1520);
+
+ var alarms = FocasWireClient.ParseAlarms(payload, 32);
+
+ alarms.Count.ShouldBe(20);
+ alarms.Select(a => a.AlarmNumber).ShouldBe(Enumerable.Range(2000, 20));
+ alarms.ShouldAllBe(a => a.Type == 0 && a.Message == "");
+ }
+
+ /// The unambiguous lengths are unchanged.
+ [Fact]
+ public void Unambiguous_lengths_are_unchanged()
+ {
+ FocasWireClient.ParseAlarms(Vendor(VendorSet(3)), 32).Count.ShouldBe(3);
+ FocasWireClient.ParseAlarms(Legacy(LegacySet(3)), 32).Count.ShouldBe(3);
+ FocasWireClient.ParseAlarms([], 32).ShouldBeEmpty();
+ Should.Throw(() => FocasWireClient.ParseAlarms(new byte[77], 32));
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs
index 9006936d..0749aafc 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasAlarmProjectionTests.cs
@@ -645,7 +645,8 @@ public sealed class FocasAlarmProjectionTests
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
// Value path Degraded, alarm path good ⇒ Degraded: an alarm success cannot mask it.
- factory.Clients.Single().ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
+ // A COMMS-class status: a CNC refusal (e.g. BadOutOfRange) is not a value-path Degraded since #740.
+ factory.Clients.Single().ReadStatuses["R100"] = FocasStatusMapper.BadCommunicationError;
await drv.ReadAsync([valuePath], CancellationToken.None);
drv.NoteAlarmReadSucceeded(Host);
drv.GetHealth().State.ShouldBe(DriverState.Degraded);
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConcurrentDeviceReadTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConcurrentDeviceReadTests.cs
new file mode 100644
index 00000000..f4fa7d11
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasConcurrentDeviceReadTests.cs
@@ -0,0 +1,284 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// #740 / R07:P9 — walked the whole batch sequentially, so a SLOW CNC
+/// (TCP-accept-then-stall, the live 31i-B case) cost its timeout per tag per poll during which no other
+/// device was read. Devices are now read concurrently, each still serialised on its own socket.
+///
+[Trait("Category", "Unit")]
+public sealed class FocasConcurrentDeviceReadTests
+{
+ private const string HostA = "focas://10.0.0.5:8193";
+ private const string HostB = "focas://10.0.0.6:8193";
+
+ ///
+ /// A client that records which device it dialled. Device A's read only completes once device B's read has
+ /// STARTED — so a sequential walk (A first) can never finish A, while a concurrent one does. Also counts
+ /// the peak number of reads in flight on any one client.
+ ///
+ private sealed class OrderingClient(TaskCompletionSource bStarted, int[] global) : FakeFocasClient
+ {
+ private string _host = "";
+ private int _inFlight;
+ public int PeakInFlight;
+ public int Reads;
+
+ public override Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
+ {
+ _host = address.ToString();
+ return base.ConnectAsync(address, timeout, ct);
+ }
+
+ public override async Task<(object? value, uint status)> ReadAsync(
+ FocasAddress address, FocasDataType type, CancellationToken ct)
+ {
+ var now = Interlocked.Increment(ref _inFlight);
+ InterlockedMax(ref PeakInFlight, now);
+ Interlocked.Increment(ref Reads);
+ // global[0] = reads in flight across EVERY client, global[1] = its peak.
+ InterlockedMax(ref global[1], Interlocked.Increment(ref global[0]));
+ try
+ {
+ if (_host.Contains("10.0.0.6", StringComparison.Ordinal)) bStarted.TrySetResult();
+ else await bStarted.Task.WaitAsync(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false);
+ await Task.Delay(20, ct).ConfigureAwait(false);
+ return ((sbyte)1, FocasStatusMapper.Good);
+ }
+ finally
+ {
+ Interlocked.Decrement(ref _inFlight);
+ Interlocked.Decrement(ref global[0]);
+ }
+ }
+
+ private static void InterlockedMax(ref int target, int value)
+ {
+ int seen;
+ while ((seen = Volatile.Read(ref target)) < value
+ && Interlocked.CompareExchange(ref target, value, seen) != seen) { }
+ }
+ }
+
+ /// A stalled device no longer holds every other device's read hostage — and each device stays serialised.
+ [Fact]
+ public async Task Devices_are_read_concurrently_and_each_stays_serialised()
+ {
+ var bStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var global = new int[2];
+ var factory = new FakeFocasClientFactory { Customise = () => new OrderingClient(bStarted, global) };
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")],
+ RawTags =
+ [
+ FocasRawTags.Entry(new FocasTagDefinition("raw/A1", "A", "R100", FocasDataType.Byte)),
+ FocasRawTags.Entry(new FocasTagDefinition("raw/A2", "A", "R101", FocasDataType.Byte)),
+ FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R100", FocasDataType.Byte)),
+ ],
+ Probe = new FocasProbeOptions { Enabled = false },
+ // Off so every tag takes the per-tag path — this pins device-level concurrency, not coalescing.
+ PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false },
+ }, "focas-p9", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snaps = await drv.ReadAsync(["raw/A1", "raw/A2", "raw/B1"], CancellationToken.None);
+
+ snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.Good,
+ "device A's read can only finish once device B's has started — a sequential walk times A out");
+ factory.Clients.Count.ShouldBe(2);
+ var clients = factory.Clients.Cast().ToList();
+ // Every client really served reads (2 on A, 1 on B), so "peak 1" is measured, not vacuous …
+ clients.Select(c => c.Reads).OrderBy(n => n).ShouldBe([1, 2]);
+ clients.ShouldAllBe(c => c.PeakInFlight == 1,
+ "one device's socket is still request→response: never two reads in flight on it");
+ // … and the two devices overlapped: two reads were in flight at once across the clients.
+ global[1].ShouldBe(2, "the devices must be read at the same time, not one after the other");
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ /// A device that accepted TCP and then stopped answering: every read hangs until the call timeout.
+ private sealed class StalledClient : FakeFocasClient
+ {
+ public int Reads;
+
+ public override async Task<(object? value, uint status)> ReadAsync(
+ FocasAddress address, FocasDataType type, CancellationToken ct)
+ {
+ Interlocked.Increment(ref Reads);
+ await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
+ return (null, FocasStatusMapper.Good);
+ }
+ }
+
+ ///
+ /// #740 review I1 — Task.WhenAll waits for the slowest device, so concurrency alone still made a
+ /// stalled device cost (its tag count × the call timeout) per batch, and the healthy device's values waited
+ /// that long. After the first timeout on a device its remaining tags in the batch fail fast: the batch is
+ /// bounded by about ONE call timeout and the healthy device's values are Good.
+ ///
+ [Fact]
+ public async Task A_stalled_device_costs_one_call_timeout_per_batch_not_one_per_tag()
+ {
+ var callTimeout = TimeSpan.FromMilliseconds(200);
+ var factory = new FakeFocasClientFactory();
+ factory.Customise = () => factory.Clients.Count == 0 ? new StalledClient() : new FakeFocasClient { Values = { ["R100"] = (sbyte)9 } };
+ var stalledTags = Enumerable.Range(0, 10)
+ .Select(i => FocasRawTags.Entry(new FocasTagDefinition($"raw/A{i}", "A", $"R{100 + i}", FocasDataType.Byte)));
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")],
+ RawTags = [.. stalledTags, FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R100", FocasDataType.Byte))],
+ Probe = new FocasProbeOptions { Enabled = false },
+ PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false },
+ Timeout = callTimeout,
+ }, "focas-stall", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ // Connect device A first so it gets the stalled client (connect order = client order).
+ await drv.ReadAsync(["raw/A0"], CancellationToken.None);
+
+ var refs = Enumerable.Range(0, 10).Select(i => $"raw/A{i}").Append("raw/B1").ToArray();
+ var sw = System.Diagnostics.Stopwatch.StartNew();
+ var snaps = await drv.ReadAsync(refs, CancellationToken.None);
+ sw.Stop();
+
+ sw.Elapsed.ShouldBeLessThan(callTimeout * 3, "one stalled device must cost ~one call timeout, not ten");
+ snaps[10].StatusCode.ShouldBe(FocasStatusMapper.Good);
+ snaps[10].Value.ShouldBe((sbyte)9);
+ snaps.Take(10).ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
+ ((StalledClient)factory.Clients[0]).Reads.ShouldBe(2, "one read in the warm-up batch, one in the measured batch");
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ /// Counts per-tag reads; one address can be made to time out (throw) or answer a status.
+ private sealed class CountingClient : FakeFocasClient
+ {
+ public int Reads;
+ public string? TimeoutAt;
+
+ public override async Task<(object? value, uint status)> ReadAsync(
+ FocasAddress address, FocasDataType type, CancellationToken ct)
+ {
+ Interlocked.Increment(ref Reads);
+ if (address.Canonical == TimeoutAt)
+ {
+ await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
+ }
+ return await base.ReadAsync(address, type, ct).ConfigureAwait(false);
+ }
+ }
+
+ private static FocasDriver OneDevice(FakeFocasClientFactory factory, bool coalesce, params string[] addresses) => new(
+ new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(HostA, DeviceName: "A")],
+ RawTags = [.. addresses.Select(a => FocasRawTags.Entry(new FocasTagDefinition($"raw/{a}", "A", a, FocasDataType.Byte)))],
+ Probe = new FocasProbeOptions { Enabled = false },
+ PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = coalesce },
+ Timeout = TimeSpan.FromMilliseconds(200),
+ }, "focas-linkdown", factory);
+
+ ///
+ /// #740 review round 2 N1 — a status the CNC RETURNED (EW_REJECT and every unlisted rc map to
+ /// BadCommunicationError) proves the device answered, so it must not end the device's batch: the tags after it
+ /// still read. Before the fix, a deterministic rc on the first tag starved every later tag forever.
+ ///
+ [Fact]
+ public async Task A_returned_comms_status_does_not_fail_the_rest_of_the_batch()
+ {
+ var client = new CountingClient { Values = { ["R101"] = (sbyte)1, ["R102"] = (sbyte)2 } };
+ client.ReadStatuses["R100"] = FocasStatusMapper.MapFocasReturn(13); // EW_REJECT ⇒ BadCommunicationError
+ var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: false, "R100", "R101", "R102");
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R102"], CancellationToken.None);
+
+ snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
+ snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
+ snaps[2].Value.ShouldBe((sbyte)2);
+ client.Reads.ShouldBe(3, "every tag was really read");
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ /// Positive control for N1: a TIMEOUT (no reply at all) still fails the rest of the batch without reading it.
+ [Fact]
+ public async Task A_timeout_still_fails_the_rest_of_the_batch_fast()
+ {
+ var client = new CountingClient { TimeoutAt = "R100", Values = { ["R101"] = (sbyte)1 } };
+ var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: false, "R100", "R101", "R102");
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R102"], CancellationToken.None);
+
+ snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
+ client.Reads.ShouldBe(1, "only the tag that timed out touched the wire");
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ ///
+ /// Review round 2 m5 — the coalescer's link-down paths: a transport fault on the first merged range fails the
+ /// second range WITHOUT a second range read, and the per-tag tags of the same device without a per-tag read.
+ ///
+ [Fact]
+ public async Task Coalescer_transport_fault_fails_later_ranges_and_per_tag_tags_without_reading_them()
+ {
+ var client = new CountingClient { ThrowOnPmcRange = true };
+ // Two ranges far apart (gap > MaxGapBytes) + one lone tag that takes the per-tag path.
+ var drv = OneDevice(new FakeFocasClientFactory { Customise = () => client }, coalesce: true,
+ "R100", "R101", "R500", "R501", "D900");
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/R500", "raw/R501", "raw/D900"], CancellationToken.None);
+
+ snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
+ client.PmcRangeReads.Count.ShouldBe(1, "the second range must not pay another transport attempt");
+ client.Reads.ShouldBe(0, "the per-tag tag must not pay one either");
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ /// Review round 2 m5 — a coalescer CONNECT failure fails the per-tag tags of the device without a second connect.
+ [Fact]
+ public async Task Coalescer_connect_failure_fails_per_tag_tags_without_reconnecting()
+ {
+ var factory = new FakeFocasClientFactory { Customise = () => new CountingClient { ThrowOnConnect = true } };
+ var drv = OneDevice(factory, coalesce: true, "R100", "R101", "D900");
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snaps = await drv.ReadAsync(["raw/R100", "raw/R101", "raw/D900"], CancellationToken.None);
+
+ snaps.ShouldAllBe(s => s.StatusCode == FocasStatusMapper.BadCommunicationError);
+ factory.Clients.Count.ShouldBe(1, "one connect attempt per device per batch");
+ factory.Clients.Sum(c => c.ConnectCount).ShouldBe(1);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ /// Results land at their own indices whatever order the devices finish in.
+ [Fact]
+ public async Task Results_keep_their_request_order_across_devices()
+ {
+ var factory = new FakeFocasClientFactory();
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(HostA, DeviceName: "A"), new FocasDeviceOptions(HostB, DeviceName: "B")],
+ RawTags =
+ [
+ FocasRawTags.Entry(new FocasTagDefinition("raw/A1", "A", "R100", FocasDataType.Byte)),
+ FocasRawTags.Entry(new FocasTagDefinition("raw/B1", "B", "R200", FocasDataType.Byte)),
+ ],
+ Probe = new FocasProbeOptions { Enabled = false },
+ }, "focas-p9-order", factory)
+ ;
+ factory.Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)1, ["R200"] = (sbyte)2 } };
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snaps = await drv.ReadAsync(["raw/B1", "nope", "raw/A1"], CancellationToken.None);
+
+ snaps[0].Value.ShouldBe((sbyte)2);
+ snaps[1].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
+ snaps[2].Value.ShouldBe((sbyte)1);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDeviceTableLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDeviceTableLifecycleTests.cs
new file mode 100644
index 00000000..056b7ca8
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasDeviceTableLifecycleTests.cs
@@ -0,0 +1,200 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// #740 / R07:H13 — the device table is read by the retained poll engine, the fixed-tree loop, the alarm
+/// loop and the actor's health tick, so a re-initialise must build a fresh table and swap it in rather than
+/// clearing and refilling the live one. Also pins the lifecycle overlaps the runtime produces since Lane A:
+/// a dispose running beside a still-running shutdown, and an abandoned initialise overlapping the next
+/// attempt's shutdown.
+///
+[Trait("Category", "Unit")]
+public sealed class FocasDeviceTableLifecycleTests
+{
+ private const string Host = "focas://10.0.0.5:8193";
+
+ private static FocasDriverOptions Options(bool probe = false, bool fixedTree = false) => new()
+ {
+ Devices = [new FocasDeviceOptions(Host)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/R", Host, "R100", FocasDataType.Byte))],
+ Probe = new FocasProbeOptions { Enabled = probe, Interval = TimeSpan.FromMilliseconds(20) },
+ FixedTree = new FocasFixedTreeOptions { Enabled = fixedTree, PollInterval = TimeSpan.FromMilliseconds(10) },
+ };
+
+ /// A re-initialise publishes a NEW table; a reader holding the old one never sees it emptied.
+ [Fact]
+ public async Task Reinitialize_swaps_in_a_new_table_and_never_mutates_the_one_a_reader_holds()
+ {
+ var drv = new FocasDriver(Options(), "focas-swap", new FakeFocasClientFactory());
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ var before = drv.DeviceTable;
+ before.Count.ShouldBe(1);
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ drv.DeviceTable.ShouldNotBeSameAs(before);
+ drv.DeviceTable.Count.ShouldBe(1);
+ before.Count.ShouldBe(1, "the table a concurrent reader enumerates must not be cleared under it");
+ await drv.ShutdownAsync(CancellationToken.None);
+ before.Count.ShouldBe(1);
+ drv.DeviceTable.Count.ShouldBe(0);
+ }
+
+ /// Hammering every table reader during repeated re-initialises throws nothing — and reads still succeed.
+ [Fact]
+ public async Task Readers_survive_repeated_reinitialise()
+ {
+ var factory = new FakeFocasClientFactory();
+ var drv = new FocasDriver(Options(fixedTree: true), "focas-hammer", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ using var stop = new CancellationTokenSource();
+ var good = 0;
+ var errors = new List();
+ var reader = Task.Run(async () =>
+ {
+ while (!stop.IsCancellationRequested)
+ {
+ try
+ {
+ _ = drv.GetHostStatuses();
+ var snaps = await drv.ReadAsync(["raw/R", $"{Host}/Identity/SeriesNumber"], CancellationToken.None);
+ if (snaps[0].StatusCode == FocasStatusMapper.Good) Interlocked.Increment(ref good);
+ }
+ catch (Exception ex) { lock (errors) errors.Add(ex); }
+ }
+ });
+
+ for (var i = 0; i < 100; i++)
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await Task.Delay(100);
+ stop.Cancel();
+ await reader;
+ await drv.ShutdownAsync(CancellationToken.None);
+
+ errors.ShouldBeEmpty();
+ good.ShouldBeGreaterThan(0, "positive control: the reader really did read through the table");
+ }
+
+ /// A client whose connect parks until released and ignores cancellation.
+ private sealed class ParkedConnectClient(TaskCompletionSource entered, Task release) : FakeFocasClient
+ {
+ public override async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
+ {
+ entered.TrySetResult();
+ await release.ConfigureAwait(false);
+ await base.ConnectAsync(address, timeout, CancellationToken.None).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// A data-path read parked in connect when shutdown runs must not leave a live client on the released
+ /// device: the device is retired, so the connect that completes afterwards disposes what it opened.
+ ///
+ [Fact]
+ public async Task Connect_completing_after_shutdown_does_not_leave_a_live_client()
+ {
+ var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var factory = new FakeFocasClientFactory { Customise = () => new ParkedConnectClient(entered, release.Task) };
+ var drv = new FocasDriver(Options(), "focas-retire", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var read = drv.ReadAsync(["raw/R"], CancellationToken.None);
+ await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
+
+ var shutdown = drv.ShutdownAsync(CancellationToken.None);
+ await Task.Delay(50);
+ release.SetResult();
+ await shutdown.WaitAsync(TimeSpan.FromSeconds(10));
+ var snaps = await read.WaitAsync(TimeSpan.FromSeconds(5));
+
+ factory.Clients.Count.ShouldBe(1);
+ factory.Clients[0].IsConnected.ShouldBeFalse("a connect finishing on a retired device must dispose its client");
+ factory.Clients[0].DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
+ snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
+ }
+
+ ///
+ /// An initialise abandoned mid-way (the runtime timed it out and moved on to a shutdown) must not
+ /// resurrect the driver when it finally completes: no device table, no loops, no connection.
+ ///
+ [Fact]
+ public async Task Abandoned_initialise_finishing_after_shutdown_starts_nothing()
+ {
+ var factory = new FakeFocasClientFactory();
+ var options = Options(probe: true);
+ using var gate = new ManualResetEventSlim(false);
+ var inRebind = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var drv = new FocasDriver(options, "focas-abandoned", factory, rebind: _ =>
+ {
+ inRebind.TrySetResult();
+ gate.Wait(TimeSpan.FromSeconds(10));
+ return new FocasDriverBinding(options, factory);
+ });
+
+ var init = Task.Run(() => drv.InitializeAsync("{\"devices\":[]}", CancellationToken.None));
+ await inRebind.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await drv.ShutdownAsync(CancellationToken.None);
+ gate.Set();
+ await Should.ThrowAsync(() => init.WaitAsync(TimeSpan.FromSeconds(10)));
+
+ await Task.Delay(200); // several probe intervals — a started loop would have connected by now
+ drv.DeviceCount.ShouldBe(0);
+ factory.Clients.ShouldBeEmpty();
+ drv.GetHealth().State.ShouldNotBe(DriverState.Healthy);
+
+ // Positive control: the same driver, initialised without being superseded, does start its probe.
+ await drv.InitializeAsync("{\"devices\":[]}", CancellationToken.None);
+ await WaitUntil(() => factory.Clients.Count > 0);
+ drv.DeviceCount.ShouldBe(1);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ await drv.DisposeAsync();
+ }
+
+ /// A loop wedged in a read that ignores cancellation for a while.
+ private sealed class SlowProbeClient : FakeFocasClient
+ {
+ public override async Task ProbeAsync(CancellationToken ct)
+ {
+ await Task.Delay(400, CancellationToken.None).ConfigureAwait(false);
+ return true;
+ }
+ }
+
+ ///
+ /// Lane A disposes while its own shutdown may still be running. The dispose must not return before the
+ /// teardown it overlaps has released the client — it waits for it rather than finding an empty table.
+ ///
+ [Fact]
+ public async Task Dispose_concurrent_with_shutdown_waits_for_the_teardown()
+ {
+ var factory = new FakeFocasClientFactory { Customise = () => new SlowProbeClient() };
+ var drv = new FocasDriver(Options(probe: true), "focas-dispose", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await WaitUntil(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
+
+ var shutdown = drv.ShutdownAsync(CancellationToken.None);
+ var dispose = drv.DisposeAsync().AsTask();
+ await dispose.WaitAsync(TimeSpan.FromSeconds(10));
+
+ factory.Clients.ShouldAllBe(c => !c.IsConnected);
+ factory.Clients.ShouldAllBe(c => c.DisposeCount >= 1);
+ await shutdown.WaitAsync(TimeSpan.FromSeconds(10));
+ drv.DeviceCount.ShouldBe(0);
+ }
+
+ private static async Task WaitUntil(Func condition)
+ {
+ var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
+ while (!condition())
+ {
+ if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
+ await Task.Delay(10);
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeHealthTests.cs
new file mode 100644
index 00000000..ff5aef11
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasFixedTreeHealthTests.cs
@@ -0,0 +1,163 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// #740 / R07:H15 — the fixed-tree loop swallowed every failure at Debug and never touched driver health, so a
+/// driver serving only FIXED: tags (Identity / Axes / Program / Status — the typical FOCAS shape) read
+/// Healthy on /hosts with every node Bad, and the #527 comms-loss watchdog could never fire. The loop
+/// now credits each answered axis pass (Healthy + LastSuccessfulRead) and, once a device has been silent past
+/// the fixed-tree staleness budget, degrades the driver with LastSuccessfulRead frozen.
+///
+[Trait("Category", "Unit")]
+public sealed class FocasFixedTreeHealthTests
+{
+ private const string Host = "focas://10.0.0.5:8193";
+ private static readonly TimeSpan Stale = TimeSpan.FromMilliseconds(300);
+ private static readonly TimeSpan BlipBudget = TimeSpan.FromMilliseconds(1500);
+
+ /// A CNC that can be switched off (transport failure) or made to refuse the dynamic read.
+ private sealed class CncSwitch
+ {
+ public volatile bool Down;
+ public volatile bool Refuse;
+ /// Transport failures served so far — what the tests wait on instead of wall-clock delays.
+ public int Failures;
+ /// Refusals served so far.
+ public int Refusals;
+ }
+
+ private sealed class SwitchableCnc(CncSwitch sw) : FakeFocasClient
+ {
+ private bool Down => sw.Down;
+ private bool Refuse => sw.Refuse;
+
+ public override Task GetSysInfoAsync(CancellationToken ct)
+ {
+ if (!Down) return base.GetSysInfoAsync(ct);
+ Interlocked.Increment(ref sw.Failures);
+ throw new IOException("socket closed");
+ }
+
+ public override Task ReadDynamicAsync(int axisIndex, CancellationToken ct)
+ {
+ if (Down)
+ {
+ Interlocked.Increment(ref sw.Failures);
+ throw new IOException("socket closed");
+ }
+ if (Refuse)
+ {
+ Interlocked.Increment(ref sw.Refusals);
+ throw new FocasWireException("cnc_rddynamic2 refused", rc: 6);
+ }
+ return base.ReadDynamicAsync(axisIndex, ct);
+ }
+ }
+
+ private static FocasDriver FixedOnlyDriver(CncSwitch sw, TimeSpan? budget = null) => new(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(Host)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/X", Host, "FIXED:Axes/X/AbsolutePosition", FocasDataType.Float64))],
+ Probe = new FocasProbeOptions { Enabled = false },
+ FixedTree = new FocasFixedTreeOptions
+ {
+ PollInterval = TimeSpan.FromMilliseconds(20),
+ ProgramPollInterval = TimeSpan.Zero,
+ TimerPollInterval = TimeSpan.Zero,
+ },
+ }, "focas-fixed-health", new FakeFocasClientFactory { Customise = () => new SwitchableCnc(sw) })
+ {
+ FixedTreeStaleBudgetOverride = budget ?? Stale,
+ };
+
+ ///
+ /// Positive control first, then the outage: an answering CNC keeps the driver Healthy with a moving
+ /// LastSuccessfulRead; a CNC that stops answering degrades it once the budget passes, with
+ /// LastSuccessfulRead frozen (the watchdog's conjunction); and its return restores Healthy.
+ ///
+ [Fact]
+ public async Task Fixed_only_driver_degrades_when_its_cnc_disappears_and_recovers_when_it_returns()
+ {
+ var sw = new CncSwitch();
+ // A wide budget (review round 2 m4): two 20 ms ticks always land well inside it, so the "blip does not
+ // degrade" absence check below runs unconditionally instead of being skipped on a slow agent.
+ await using var drv = FixedOnlyDriver(sw, BlipBudget);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await WaitUntil(() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null);
+
+ var first = drv.GetHealth().LastSuccessfulRead!.Value;
+ // An answered axis pass is a successful read: wait for it to move rather than sleeping.
+ await WaitUntil(() => drv.GetHealth().LastSuccessfulRead > first);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+
+ sw.Down = true;
+ var lastAnswer = drv.GetDeviceState(Host)!.LastFixedPollUtc!.Value;
+ // Two failed ticks have demonstrably happened (positive evidence for the absence check below) …
+ await WaitUntil(() => Volatile.Read(ref sw.Failures) >= 2);
+ // … inside the budget (asserted, not assumed), and the driver is still Healthy.
+ (DateTime.UtcNow - lastAnswer).ShouldBeLessThan(BlipBudget, "the two failed ticks must land inside the budget");
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a blip inside the staleness budget does not degrade");
+ await WaitUntil(() => drv.GetHealth().State == DriverState.Degraded);
+ var frozen = drv.GetHealth().LastSuccessfulRead;
+ var failuresAtDegrade = Volatile.Read(ref sw.Failures);
+ await WaitUntil(() => Volatile.Read(ref sw.Failures) >= failuresAtDegrade + 5);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ drv.GetHealth().LastSuccessfulRead.ShouldBe(frozen, "LastSuccessfulRead must stay frozen while the CNC is silent");
+
+ sw.Down = false;
+ await WaitUntil(() => drv.GetHealth().State == DriverState.Healthy);
+ drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(frozen!.Value);
+ }
+
+ /// A CNC that is down from the start (bootstrap never succeeds) degrades once the budget passes.
+ [Fact]
+ public async Task Bootstrap_that_never_succeeds_degrades_the_driver()
+ {
+ var sw = new CncSwitch { Down = true };
+ await using var drv = FixedOnlyDriver(sw);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ // The bootstrap retries every 2 s, so the first failure is what has to trip it.
+ await WaitUntil(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(6));
+ sw.Down = false;
+ }
+
+ ///
+ /// A CNC that ANSWERS with a refusal is not a comms loss: the transport is fine, so the driver is not
+ /// degraded (else the watchdog would reconnect-loop a production control).
+ ///
+ [Fact]
+ public async Task Cnc_refusal_of_the_dynamic_read_does_not_degrade()
+ {
+ var sw = new CncSwitch();
+ await using var drv = FixedOnlyDriver(sw);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await WaitUntil(() => drv.GetDeviceState(Host)?.LastFixedPollUtc is not null);
+
+ sw.Refuse = true;
+ var started = DateTime.UtcNow;
+ await WaitUntil(() => Volatile.Read(ref sw.Refusals) >= 1);
+ var atFirstRefusal = drv.GetHealth().LastSuccessfulRead!.Value;
+ // Refused ticks really happened, for longer than the staleness budget …
+ await WaitUntil(() => Volatile.Read(ref sw.Refusals) >= 5 && DateTime.UtcNow - started > Stale * 2);
+ // … and the driver stayed Healthy, with each refusal crediting the answer (review minor 2).
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(atFirstRefusal,
+ "a refusal is an answer from the CNC — LastSuccessfulRead advances");
+ sw.Refuse = false;
+ }
+
+ private static async Task WaitUntil(Func condition, TimeSpan? timeout = null)
+ {
+ var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(5));
+ while (!condition())
+ {
+ if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not reached");
+ await Task.Delay(10);
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs
index 97042231..4c287b4f 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasHandleRecycleTests.cs
@@ -62,6 +62,73 @@ public sealed class FocasHandleRecycleTests
await drv.ShutdownAsync(CancellationToken.None);
}
+ /// A client whose reads take a while — long enough for a recycle tick to land mid-read.
+ private sealed class SlowReadClient : FakeFocasClient
+ {
+ public override async Task<(object? value, uint status)> ReadAsync(
+ FocasAddress address, FocasDataType type, CancellationToken ct)
+ {
+ await Task.Delay(200, ct).ConfigureAwait(false);
+ if (!IsConnected) throw new ObjectDisposedException(nameof(SlowReadClient), "disposed mid-read");
+ return ((sbyte)5, FocasStatusMapper.Good);
+ }
+ }
+
+ ///
+ /// #740 / R07:L6 — the recycle loop disposed the device client with no gate, so a recycle tick landing
+ /// mid-read killed that read (BadCommunicationError + Degraded, every interval). The recycle now takes the
+ /// device's connect gate and lets the in-flight read finish before disposing the client it detached.
+ ///
+ [Fact]
+ public async Task Recycle_tick_landing_mid_read_lets_the_read_finish()
+ {
+ var factory = new FakeFocasClientFactory { Customise = () => new SlowReadClient() };
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
+ Probe = new FocasProbeOptions { Enabled = false },
+ HandleRecycle = new FocasHandleRecycleOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50) },
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ // Each 200 ms read spans at least three recycle ticks.
+ for (var i = 0; i < 3; i++)
+ {
+ var snap = (await drv.ReadAsync(["R"], CancellationToken.None))[0];
+ snap.StatusCode.ShouldBe(FocasStatusMapper.Good, $"read {i} was killed by a recycle tick");
+ snap.Value.ShouldBe((sbyte)5);
+ }
+ drv.GetHealth().State.ShouldBe(Core.Abstractions.DriverState.Healthy);
+
+ // Positive control: the recycle really ran — the first client was disposed and a fresh one opened.
+ await WaitFor(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3));
+ factory.Clients[0].DisposeCount.ShouldBeGreaterThan(0);
+ factory.Clients.Count.ShouldBeGreaterThan(1);
+ await drv.ShutdownAsync(CancellationToken.None);
+ factory.Clients.ShouldAllBe(c => !c.IsConnected, "no client opened around a recycle may be orphaned");
+ }
+
+ /// DisposeWhenIdleAsync drains the in-flight op, then disposes; a later op is refused, not run on a dead socket.
+ [Fact]
+ public async Task DisposeWhenIdle_drains_the_in_flight_op_then_refuses_new_ones()
+ {
+ var inner = new SlowReadClient();
+ await inner.ConnectAsync(new FocasHostAddress("10.0.0.5", 8193), TimeSpan.FromSeconds(1), CancellationToken.None);
+ var sync = new SynchronizedFocasClient(inner, TimeSpan.FromSeconds(5));
+ var addr = FocasAddress.TryParse("R100")!;
+
+ var read = sync.ReadAsync(addr, FocasDataType.Byte, CancellationToken.None);
+ await Task.Delay(20);
+ sync.PendingOperations.ShouldBe(1);
+ var dispose = sync.DisposeWhenIdleAsync(TimeSpan.FromSeconds(5));
+
+ (await read).status.ShouldBe(FocasStatusMapper.Good);
+ await dispose;
+ inner.DisposeCount.ShouldBe(1);
+ await Should.ThrowAsync(() => sync.ReadAsync(addr, FocasDataType.Byte, CancellationToken.None));
+ }
+
private static async Task WaitFor(Func pred, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs
index f738c275..3e89a3ff 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasPollErrorHealthTests.cs
@@ -57,4 +57,32 @@ public sealed class FocasPollErrorHealthTests
drv.GetHealth().State.ShouldBe(DriverState.Faulted);
}
+
+ ///
+ /// #740 review round 2 m3 — a poll failure carries the generation its batch STARTED under, so a batch that
+ /// began before a re-initialise cannot degrade the new generation. Positive control: a failure stamped with
+ /// the live generation (or unstamped) does degrade.
+ ///
+ [Fact]
+ public async Task PollReaderFailure_from_a_previous_generation_does_not_degrade_the_new_one()
+ {
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
+ Probe = new FocasProbeOptions { Enabled = false },
+ }, "focas-1", new FakeFocasClientFactory());
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ var oldGeneration = drv.LifecycleGeneration;
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var stale = new InvalidOperationException("old batch boom");
+ stale.Data[FocasDriver.BatchGenerationKey] = oldGeneration;
+ drv.HandlePollError(stale);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+
+ var live = new InvalidOperationException("live batch boom");
+ live.Data[FocasDriver.BatchGenerationKey] = drv.LifecycleGeneration;
+ drv.HandlePollError(live);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ }
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadHealthContractTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadHealthContractTests.cs
new file mode 100644
index 00000000..923ec354
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasReadHealthContractTests.cs
@@ -0,0 +1,212 @@
+using Microsoft.Extensions.Logging;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// #740 driver-lane health checklist (drivers.md, #527/#611): a deterministic CNC refusal — the control
+/// answered with a non-OK return code, or with a value the tag's type cannot hold (vacant macro, fractional
+/// macro on an integer tag) — is per-tag Bad with ONE Warning per tag and NO health write, and it still
+/// proves the device answered (LastSuccessfulRead advances). Only a comms-class status degrades the driver.
+/// Before this, every non-Good status latched Degraded every poll, so one vacant macro made a reachable CNC
+/// look sick forever.
+///
+[Trait("Category", "Unit")]
+public sealed class FocasReadHealthContractTests
+{
+ private const string Host = "focas://10.0.0.5:8193";
+
+ private sealed class WarningCounter : ILogger
+ {
+ private int _warnings;
+ public int Warnings => Volatile.Read(ref _warnings);
+ 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)
+ {
+ if (logLevel == LogLevel.Warning && formatter(state, exception).Contains("refused", StringComparison.Ordinal))
+ Interlocked.Increment(ref _warnings);
+ }
+ }
+
+ private static (FocasDriver drv, FakeFocasClient client, WarningCounter log) NewDriver(uint r100Status)
+ {
+ var client = new FakeFocasClient { Values = { ["R101"] = (sbyte)1 } };
+ client.ReadStatuses["R100"] = r100Status;
+ var log = new WarningCounter();
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(Host)],
+ RawTags =
+ [
+ FocasRawTags.Entry(new FocasTagDefinition("raw/R100", Host, "R100", FocasDataType.Byte)),
+ FocasRawTags.Entry(new FocasTagDefinition("raw/R101", Host, "R101", FocasDataType.Byte)),
+ ],
+ Probe = new FocasProbeOptions { Enabled = false },
+ PmcCoalesce = new FocasPmcCoalesceOptions { Enabled = false },
+ }, "focas-contract", new FakeFocasClientFactory { Customise = () => client }, log);
+ return (drv, client, log);
+ }
+
+ /// A refusal leaves health alone, warns once, and still credits the answer.
+ [Theory]
+ [InlineData(FocasStatusMapper.BadOutOfRange)] // EW_NUMBER
+ [InlineData(FocasStatusMapper.BadNotSupported)] // EW_FUNC / EW_NOOPT
+ [InlineData(FocasStatusMapper.BadNoData)] // vacant macro
+ [InlineData(FocasStatusMapper.BadTypeMismatch)] // fractional macro on an integer tag
+ [InlineData(FocasStatusMapper.BadDeviceFailure)] // EW_BUSY
+ public async Task Cnc_refusal_is_per_tag_bad_with_one_warning_and_no_health_write(uint refusal)
+ {
+ var (drv, _, log) = NewDriver(refusal);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ var atInit = drv.GetHealth().LastSuccessfulRead;
+
+ for (var i = 0; i < 3; i++)
+ {
+ await Task.Delay(5);
+ var snap = (await drv.ReadAsync(["raw/R100"], CancellationToken.None))[0];
+ snap.StatusCode.ShouldBe(refusal);
+ }
+
+ var health = drv.GetHealth();
+ health.State.ShouldBe(DriverState.Healthy, "a CNC refusal is not a comms fault");
+ health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(atInit!.Value, "the CNC answered — that is evidence of life");
+ log.Warnings.ShouldBe(1, "one Warning per refused tag, not one per poll");
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ /// A refused tag that recovers and is refused again warns again (the dedup clears on Good).
+ [Fact]
+ public async Task Refusal_warning_rearms_after_the_tag_reads_good()
+ {
+ var (drv, client, log) = NewDriver(FocasStatusMapper.BadOutOfRange);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await drv.ReadAsync(["raw/R100"], CancellationToken.None);
+ client.ReadStatuses.Remove("R100");
+ client.Values["R100"] = (sbyte)3;
+ (await drv.ReadAsync(["raw/R100"], CancellationToken.None))[0].StatusCode.ShouldBe(FocasStatusMapper.Good);
+ client.ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
+ await drv.ReadAsync(["raw/R100"], CancellationToken.None);
+ log.Warnings.ShouldBe(2);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ /// Positive control: a comms-class status still degrades, preserving LastSuccessfulRead.
+ [Fact]
+ public async Task Comms_status_still_degrades_with_last_successful_read_preserved()
+ {
+ var (drv, _, _) = NewDriver(FocasStatusMapper.BadCommunicationError);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await drv.ReadAsync(["raw/R101"], CancellationToken.None);
+ var lastGood = drv.GetHealth().LastSuccessfulRead;
+
+ await drv.ReadAsync(["raw/R100"], CancellationToken.None);
+
+ var health = drv.GetHealth();
+ health.State.ShouldBe(DriverState.Degraded);
+ health.LastSuccessfulRead.ShouldBe(lastGood);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ ///
+ /// Review minor 2 — a device that recovers from a comms fault and then only REFUSES (every tag it serves is
+ /// refused) is answering, so it is restored to Healthy instead of staying Degraded forever.
+ ///
+ [Fact]
+ public async Task Refusal_after_a_comms_fault_restores_healthy()
+ {
+ var (drv, client, _) = NewDriver(FocasStatusMapper.BadCommunicationError);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await drv.ReadAsync(["raw/R100"], CancellationToken.None);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded, "positive control: the comms fault degraded it");
+
+ client.ReadStatuses["R100"] = FocasStatusMapper.BadOutOfRange;
+ await drv.ReadAsync(["raw/R100"], CancellationToken.None);
+
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ /// A read whose CNC answer is held until released, then fails at the transport.
+ private sealed class HeldReadClient(TaskCompletionSource entered, Task release) : FakeFocasClient
+ {
+ public volatile bool Hold = true;
+
+ public override async Task<(object? value, uint status)> ReadAsync(
+ FocasAddress address, FocasDataType type, CancellationToken ct)
+ {
+ if (!Hold) return await base.ReadAsync(address, type, ct).ConfigureAwait(false);
+ entered.TrySetResult();
+ await release.ConfigureAwait(false);
+ throw new IOException("socket reset");
+ }
+ }
+
+ ///
+ /// Review minor 8 — a read that started against the PREVIOUS table and fails after a re-initialise must not
+ /// write its Degraded onto the new generation's health. Positive control: the same failure under the live
+ /// generation does degrade.
+ ///
+ [Fact]
+ public async Task A_read_from_the_previous_generation_cannot_degrade_the_new_one()
+ {
+ var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var clients = new List();
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(Host)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/R100", Host, "R100", FocasDataType.Byte))],
+ Probe = new FocasProbeOptions { Enabled = false },
+ Timeout = TimeSpan.FromSeconds(10),
+ }, "focas-gen", new FakeFocasClientFactory
+ {
+ Customise = () => { var c = new HeldReadClient(entered, release.Task); lock (clients) clients.Add(c); return c; },
+ });
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var stale = drv.ReadAsync(["raw/R100"], CancellationToken.None);
+ await entered.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ await drv.InitializeAsync("{}", CancellationToken.None); // a new generation, Healthy
+ release.SetResult();
+ (await stale.WaitAsync(TimeSpan.FromSeconds(5)))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
+
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy, "the old generation's failure must not land on the new one");
+
+ // Positive control: a failure under the live generation degrades.
+ var live = drv.ReadAsync(["raw/R100"], CancellationToken.None);
+ (await live.WaitAsync(TimeSpan.FromSeconds(5)))[0].StatusCode.ShouldBe(FocasStatusMapper.BadCommunicationError);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ /// A write the driver refuses (NotSupportedException) is not a comms fault either.
+ [Fact]
+ public async Task Write_refusal_does_not_degrade()
+ {
+ var client = new ThrowingWriteClient();
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(Host)],
+ RawTags = [FocasRawTags.Entry(new FocasTagDefinition("raw/W", Host, "R100", FocasDataType.Byte, Writable: true))],
+ Probe = new FocasProbeOptions { Enabled = false },
+ }, "focas-write-refusal", new FakeFocasClientFactory { Customise = () => client });
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var result = await drv.WriteAsync([new WriteRequest("raw/W", (sbyte)1)], CancellationToken.None);
+
+ result[0].StatusCode.ShouldBe(FocasStatusMapper.BadNotSupported);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ private sealed class ThrowingWriteClient : FakeFocasClient
+ {
+ public override Task WriteAsync(FocasAddress address, FocasDataType type, object? value, CancellationToken ct) =>
+ throw new NotSupportedException("bit writes are not supported on this area");
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStringDataTypeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStringDataTypeTests.cs
new file mode 100644
index 00000000..600a205a
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasStringDataTypeTests.cs
@@ -0,0 +1,104 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// #740 / R07:U8 — was authorable on any address, but no area read
+/// produces a string: PMC fell through to a byte read, PARAM to an Int32, MACRO to a double, and a
+/// String-typed node received a boxed number at Good. String is now refused on PMC / PARAM / MACRO by the
+/// one shared check, everywhere a tag is authored or loaded; the fixed tree (Identity / Program name, …),
+/// whose values really are strings, keeps it.
+///
+[Trait("Category", "Unit")]
+public sealed class FocasStringDataTypeTests
+{
+ private const string Host = "focas://10.0.0.5:8193";
+
+ private static string Config(string address, string dataType) =>
+ $$"""{"address":"{{address}}","dataType":"{{dataType}}"}""";
+
+ /// String on an area read is refused by the mapper.
+ [Theory]
+ [InlineData("R100")]
+ [InlineData("X0.0")]
+ [InlineData("PARAM:6711")]
+ [InlineData("MACRO:500")]
+ public void Mapper_refuses_string_on_an_area_read(string address)
+ {
+ FocasTagDefinitionFactory.FromTagConfig(Config(address, "String"), "raw/T", out _).ShouldBeFalse();
+ FocasTagDefinitionFactory.DescribeDataTypeMismatch(FocasDataType.String, address).ShouldNotBeNull();
+ }
+
+ /// Positive control: numeric types on the same addresses, and String on the fixed tree, still map.
+ [Theory]
+ [InlineData("R100", "Byte")]
+ [InlineData("PARAM:6711", "Int32")]
+ [InlineData("MACRO:500", "Float64")]
+ [InlineData("FIXED:Identity/SeriesNumber", "String")]
+ [InlineData("FIXED:Program/Name", "String")]
+ public void Mapper_accepts_numeric_area_reads_and_string_fixed_tree_paths(string address, string dataType)
+ {
+ FocasTagDefinitionFactory.FromTagConfig(Config(address, dataType), "raw/T", out var def).ShouldBeTrue();
+ def.Address.ShouldBe(address);
+ }
+
+ /// An ALARM: tag has no value, so its data type is irrelevant and String is not refused.
+ [Fact]
+ public void Mapper_accepts_string_on_an_alarm_tag()
+ {
+ FocasTagDefinitionFactory.FromTagConfig(
+ """{"address":"ALARM:*","dataType":"String","alarm":{"severity":500}}""", "raw/A", out _).ShouldBeTrue();
+ }
+
+ /// An unparseable address is left to the address check — it still draws THAT diagnosis.
+ [Fact]
+ public void Unparseable_address_keeps_its_own_diagnosis()
+ {
+ FocasTagDefinitionFactory.DescribeDataTypeMismatch(FocasDataType.String, "not-an-address").ShouldBeNull();
+ FocasTagDefinitionFactory.FromTagConfig(Config("not-an-address", "String"), "raw/T", out _).ShouldBeTrue();
+ }
+
+ /// The deploy-time inspector names the reason rather than falling to the generic backstop.
+ [Fact]
+ public void Inspect_names_the_string_refusal()
+ {
+ var warnings = FocasTagDefinitionFactory.Inspect(Config("MACRO:500", "String"));
+ warnings.ShouldContain(w => w.Contains("String", StringComparison.Ordinal) && w.Contains("MACRO:500", StringComparison.Ordinal));
+ FocasTagDefinitionFactory.Inspect(Config("MACRO:500", "Float64")).ShouldBeEmpty();
+ }
+
+ ///
+ /// At init a String area tag is skipped and reported (never served as a Good number); the numeric tag
+ /// beside it reads normally (positive control).
+ ///
+ [Fact]
+ public async Task Driver_skips_and_reports_a_string_area_tag()
+ {
+ var factory = new FakeFocasClientFactory
+ {
+ Customise = () => new FakeFocasClient { Values = { ["R100"] = (sbyte)7 } },
+ };
+ var drv = new FocasDriver(new FocasDriverOptions
+ {
+ Devices = [new FocasDeviceOptions(Host)],
+ RawTags =
+ [
+ new RawTagEntry("raw/Str", Config("R100", "String"), false, Host),
+ new RawTagEntry("raw/Num", Config("R100", "Byte"), false, Host),
+ ],
+ Probe = new FocasProbeOptions { Enabled = false },
+ }, "focas-u8", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var snaps = await drv.ReadAsync(["raw/Str", "raw/Num"], CancellationToken.None);
+ snaps[0].StatusCode.ShouldBe(FocasStatusMapper.BadNodeIdUnknown);
+ snaps[0].Value.ShouldBeNull();
+ snaps[1].StatusCode.ShouldBe(FocasStatusMapper.Good);
+ snaps[1].Value.ShouldBe((sbyte)7);
+ drv.GetSkippedTags()!.Sample.ShouldContain(s => s.RawPath == "raw/Str");
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWideNumberTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWideNumberTests.cs
new file mode 100644
index 00000000..7bf63a40
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests/FocasWideNumberTests.cs
@@ -0,0 +1,84 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
+using ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Wire;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
+
+///
+/// #740 / R07:H14 — the capability matrix admits macro numbers up to 99999 (30i/31i/32i) and parameters up
+/// to 29999 (Unknown: anything), and the FOCAS/2 request block carries 32-bit arguments, but the adapter
+/// cast every number to short: MACRO:99999 asked the CNC for #-31073 and published
+/// that variable Good under the authored tag. Numbers now travel as int end to end.
+///
+[Trait("Category", "Unit")]
+public sealed class FocasWideNumberTests
+{
+ /// The macro request carries the full number in both 32-bit argument slots.
+ [Theory]
+ [InlineData(500)]
+ [InlineData(32767)]
+ [InlineData(32768)]
+ [InlineData(99999)]
+ public void Macro_request_carries_the_unnarrowed_number(int number)
+ {
+ var block = FocasWireClient.MacroRequest(number, pathId: 1);
+ block.Command.ShouldBe((ushort)0x0015);
+ block.Arg1.ShouldBe(number);
+ block.Arg2.ShouldBe(number);
+ }
+
+ ///
+ /// Positive control on the wire encoding: the request for #500 is byte-identical to the one the real
+ /// 31i-B answered in the ref-macro-500-0x0015 capture, so widening changed nothing below 32768.
+ ///
+ [Fact]
+ public void Macro_request_for_500_matches_the_live_capture_bytes()
+ {
+ var body = FocasWireProtocol.BuildRequestBody([FocasWireClient.MacroRequest(500, pathId: 1)]);
+ Convert.ToHexString(body).ShouldBe("0001001C0001000100150000 01F4000001F4000000000000000000000000".Replace(" ", ""));
+ }
+
+ /// A global parameter puts its number in both arguments; an axis parameter puts the axis second.
+ [Theory]
+ [InlineData(6711, (short)0, 6711)]
+ [InlineData(40000, (short)0, 40000)]
+ [InlineData(40000, (short)2, 2)]
+ public void Parameter_request_carries_the_unnarrowed_number(int number, short axis, int expectedArg2)
+ {
+ var block = FocasWireClient.ParameterRequest(number, axis, pathId: 1);
+ block.Command.ShouldBe((ushort)0x000e);
+ block.Arg1.ShouldBe(number);
+ block.Arg2.ShouldBe(expectedArg2);
+ }
+
+ /// The decoded macro reports the number that was asked for, not a wrapped one.
+ [Fact]
+ public void Parsed_macro_keeps_a_number_above_short_range()
+ {
+ var payload = new byte[] { 0x05, 0xF5, 0xE1, 0x00, 0x00, 0x0A, 0x00, 0x08 };
+ var m = FocasWireClient.ParseMacro(99999, payload);
+ m.Number.ShouldBe(99999);
+ m.Scaled.ShouldBe(1.0);
+ }
+
+ ///
+ /// pmc_rdpmcrng addresses are 16-bit on the wire; a PMC number whose slot runs past 65535 is refused
+ /// rather than wrapped onto a different address (Unknown series admits any number).
+ ///
+ [Theory]
+ [InlineData(100, 1, true)]
+ [InlineData(65535, 1, true)]
+ [InlineData(65534, 2, true)]
+ [InlineData(65535, 2, false)]
+ [InlineData(70000, 1, false)]
+ public void Pmc_range_outside_16_bits_is_refused_not_wrapped(int number, int width, bool ok)
+ {
+ WireFocasClient.TryPmcRange(number, width, out var start, out var end).ShouldBe(ok);
+ if (ok)
+ {
+ ((int)start).ShouldBe(number);
+ ((int)end).ShouldBe(number + width - 1);
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATRemediationGateTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATRemediationGateTests.cs
new file mode 100644
index 00000000..c592495b
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests/TwinCATRemediationGateTests.cs
@@ -0,0 +1,146 @@
+using System.Collections.Concurrent;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.IntegrationTests;
+
+///
+/// Wire-level cases for the #739 High/Medium findings, against the live TwinCAT 3 XAR fixture
+/// (skipped without it, like ). Every case that writes restores what it
+/// changed: GVL_Primitives.vWord and vString are seeds the primitives theory reads.
+///
+[Collection("TwinCATXar")]
+[Trait("Category", "Integration")]
+[Trait("Simulator", "TwinCAT-XAR")]
+public sealed class TwinCATRemediationGateTests(TwinCATXarFixture sim)
+{
+ private string Host => $"ads://{sim.TargetNetId}:{sim.AmsPort}";
+
+ private TwinCATDriver NewDriver(string id, bool native, params TwinCATTagDefinition[] tags) => new(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host, "XAR-VM")],
+ RawTags = TwinCATRawTags.Entries(tags),
+ UseNativeNotifications = native,
+ Timeout = TimeSpan.FromSeconds(5),
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ }, id);
+
+ /// H11: a typo'd symbol in a native subscribe is isolated; the real counter keeps delivering.
+ [TwinCATFact]
+ public async Task Native_subscribe_isolates_an_unknown_symbol()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+ await using var drv = NewDriver("tc3-gate-h11", native: true,
+ new TwinCATTagDefinition("Counter", Host, "GVL_Fixture.nCounter", TwinCATDataType.DInt),
+ new TwinCATTagDefinition("Typo", Host, "GVL_Fixture.nCounterTypo", TwinCATDataType.DInt));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var events = new ConcurrentQueue();
+ drv.OnDataChange += (_, e) => events.Enqueue(e);
+
+ var handle = await drv.SubscribeAsync(["Counter", "Typo"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
+ var deadline = DateTime.UtcNow + TimeSpan.FromSeconds(5);
+ while (events.Count(e => e.FullReference == "Counter") < 2 && DateTime.UtcNow < deadline)
+ await Task.Delay(50, TestContext.Current.CancellationToken);
+ await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
+
+ events.Count(e => e.FullReference == "Counter" && e.Snapshot.StatusCode == 0u).ShouldBeGreaterThanOrEqualTo(2);
+ events.First(e => e.FullReference == "Typo").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy, "a refused symbol is not a comms fault");
+ }
+
+ /// H12: native pushes alone advance LastSuccessfulRead.
+ [TwinCATFact]
+ public async Task Native_pushes_advance_LastSuccessfulRead()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+ await using var drv = NewDriver("tc3-gate-h12", native: true,
+ new TwinCATTagDefinition("Counter", Host, "GVL_Fixture.nCounter", TwinCATDataType.DInt));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
+
+ var handle = await drv.SubscribeAsync(["Counter"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
+ await Task.Delay(1500, TestContext.Current.CancellationToken);
+ await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
+
+ drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp.AddMilliseconds(500));
+ }
+
+ ///
+ /// H18: a bit write on the 16-bit vWord (0xBEEF) flips exactly that bit at the WORD's own width
+ /// (the old 32-bit UDInt RMW could not even address a WORD), and a bit beyond it is refused.
+ ///
+ [TwinCATFact]
+ public async Task Bit_write_on_a_WORD_flips_one_bit_and_refuses_bit_16()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+ await using var drv = NewDriver("tc3-gate-h18", native: false,
+ new TwinCATTagDefinition("Word", Host, "GVL_Primitives.vWord", TwinCATDataType.UInt, Writable: false),
+ new TwinCATTagDefinition("Bit4", Host, "GVL_Primitives.vWord.4", TwinCATDataType.Bool),
+ new TwinCATTagDefinition("Bit16", Host, "GVL_Primitives.vWord.16", TwinCATDataType.Bool));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var before = Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value);
+ var bit4WasSet = (before & 0x10) != 0;
+ try
+ {
+ (await drv.WriteAsync([new WriteRequest("Bit4", !bit4WasSet)], TestContext.Current.CancellationToken))[0]
+ .StatusCode.ShouldBe(0u);
+ var after = Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value);
+ after.ShouldBe((ushort)(before ^ 0x10), "only bit 4 may change");
+
+ (await drv.WriteAsync([new WriteRequest("Bit16", true)], TestContext.Current.CancellationToken))[0]
+ .StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
+ }
+ finally
+ {
+ await drv.WriteAsync([new WriteRequest("Bit4", bit4WasSet)], CancellationToken.None);
+ }
+ Convert.ToUInt16((await drv.ReadAsync(["Word"], TestContext.Current.CancellationToken))[0].Value).ShouldBe(before);
+ }
+
+ /// U9: a whole-array write round-trips through the codec (CLR double[] converts to REAL elements).
+ [TwinCATFact]
+ public async Task Whole_array_write_round_trips()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+ await using var drv = NewDriver("tc3-gate-u9-array", native: false,
+ new TwinCATTagDefinition("Arr", Host, "GVL_Arrays.aReal1D", TwinCATDataType.Real, ArrayLength: 32));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var values = Enumerable.Range(0, 32).Select(i => i * 1.5 + 0.25).ToArray();
+
+ (await drv.WriteAsync([new WriteRequest("Arr", values)], TestContext.Current.CancellationToken))[0]
+ .StatusCode.ShouldBe(0u);
+ var read = (await drv.ReadAsync(["Arr"], TestContext.Current.CancellationToken))[0];
+ read.StatusCode.ShouldBe(0u);
+ ((float[])read.Value!).ShouldBe(values.Select(v => (float)v).ToArray());
+
+ (await drv.WriteAsync([new WriteRequest("Arr", values[..31])], TestContext.Current.CancellationToken))[0]
+ .StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
+ }
+
+ /// U9: a STRING write is NUL-padded at the PLC width (no stale tail) and an over-long one is refused.
+ [TwinCATFact]
+ public async Task String_write_round_trips_and_refuses_overflow()
+ {
+ if (sim.SkipReason is not null) Assert.Skip(sim.SkipReason);
+ await using var drv = NewDriver("tc3-gate-u9-string", native: false,
+ new TwinCATTagDefinition("S", Host, "GVL_Primitives.vString", TwinCATDataType.String));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var original = (string)(await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value!;
+ try
+ {
+ (await drv.WriteAsync([new WriteRequest("S", "Grüße 40°")], TestContext.Current.CancellationToken))[0]
+ .StatusCode.ShouldBe(0u);
+ (await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value.ShouldBe("Grüße 40°");
+
+ (await drv.WriteAsync([new WriteRequest("S", new string('x', 81))], TestContext.Current.CancellationToken))[0]
+ .StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange, "vString is STRING(80)");
+ }
+ finally
+ {
+ await drv.WriteAsync([new WriteRequest("S", original)], CancellationToken.None);
+ }
+ (await drv.ReadAsync(["S"], TestContext.Current.CancellationToken))[0].Value.ShouldBe(original);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs
index 2ad33e42..8559f9af 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/FakeTwinCATClient.cs
@@ -1,3 +1,4 @@
+using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
@@ -56,10 +57,22 @@ internal class FakeTwinCATClient : ITwinCATClient
{
ConnectCount++;
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
+ if (BlockConnect is { } block)
+ {
+ // Models the real client's blocking SDK Connect: ignores the token until released.
+ return block.Task.ContinueWith(_ => { IsConnected = true; }, TaskScheduler.Default);
+ }
IsConnected = true;
return Task.CompletedTask;
}
+ /// When set, does not complete (and ignores cancellation) until this
+ /// is completed — a probe wedged in the synchronous ADS Connect.
+ public TaskCompletionSource? BlockConnect { get; set; }
+
+ /// Gets or sets the simulated notification-queue drop count.
+ public long DroppedNotificationCount { get; set; }
+
/// Simulates reading a value from a symbol.
/// The path to the symbol to read.
/// The data type of the symbol.
@@ -70,16 +83,31 @@ internal class FakeTwinCATClient : ITwinCATClient
public virtual Task<(object? value, uint status)> ReadValueAsync(
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, CancellationToken ct)
{
+ if (BlockRead is { } block)
+ {
+ // A read wedged in the (uncancellable) wire call; completes with the normal answer once released.
+ return block.Task.ContinueWith(_ =>
+ {
+ BlockRead = null;
+ return ReadValueAsync(symbolPath, type, bitIndex, arrayCount, CancellationToken.None).Result;
+ }, TaskScheduler.Default);
+ }
if (ThrowOnRead) throw Exception ?? new InvalidOperationException();
- SingleReadCount++;
+ Interlocked.Increment(ref _singleReadCount);
LastReadArrayCount = arrayCount;
var status = ReadStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
var value = Values.TryGetValue(symbolPath, out var v) ? v : null;
return Task.FromResult((value, status));
}
+ /// When set, does not complete (and ignores cancellation) until this is
+ /// completed — a poll read wedged in the wire call.
+ public TaskCompletionSource? BlockRead { get; set; }
+
+ private int _singleReadCount;
+
/// Gets the number of individual calls served.
- public int SingleReadCount { get; private set; }
+ public int SingleReadCount { get => Volatile.Read(ref _singleReadCount); private set => Volatile.Write(ref _singleReadCount, value); }
/// Gets the size of each batch the driver issued, in order (#645).
public List SumReadBatches { get; } = new();
/// Gets or sets an exception throws instead of answering — a
@@ -130,21 +158,102 @@ internal class FakeTwinCATClient : ITwinCATClient
string symbolPath, TwinCATDataType type, int? bitIndex, object? value, CancellationToken ct)
{
if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
+ if (type is TwinCATDataType.String or TwinCATDataType.WString
+ && SymbolShapes.TryGetValue(symbolPath, out var shape))
+ {
+ // The real client's encode plan (resolve → CheckRead → codec at the PLC width).
+ var (planStatus, bytes) = AdsTwinCATClient.PlanStringWrite(type, shape, value, utf8: false);
+ if (planStatus != TwinCATStatusMapper.Good) return Task.FromResult(planStatus);
+ RawWrites[symbolPath] = bytes;
+ }
WriteLog.Add((symbolPath, type, bitIndex, value));
Values[symbolPath] = value;
var status = WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good;
return Task.FromResult(status);
}
+ /// Gets the log of every call.
+ public List<(string symbol, TwinCATDataType type, IReadOnlyList elements)> ArrayWriteLog { get; } = new();
+
+ /// Simulates an array write: records it, stores the elements and answers from .
+ /// The array symbol path.
+ /// The declared element type.
+ /// The converted elements.
+ /// The cancellation token.
+ /// The write status.
+ public virtual Task WriteArrayAsync(string symbolPath, TwinCATDataType type, IReadOnlyList elements,
+ CancellationToken ct)
+ {
+ if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
+ if (ArrayShapes.TryGetValue(symbolPath, out var array) && SymbolShapes.TryGetValue(symbolPath, out var shape))
+ {
+ // The real client's encode plan (CheckArray layout → TwinCATArrayCodec.Encode).
+ var (planStatus, bytes) = AdsTwinCATClient.PlanArrayWrite(type, elements, shape, array, utf8: false);
+ if (planStatus != TwinCATStatusMapper.Good) return Task.FromResult(planStatus);
+ RawWrites[symbolPath] = bytes;
+ }
+ ArrayWriteLog.Add((symbolPath, type, elements));
+ Values[symbolPath] = elements;
+ return Task.FromResult(WriteStatuses.TryGetValue(symbolPath, out var s) ? s : TwinCATStatusMapper.Good);
+ }
+
+ /// Resolved PLC symbol shapes; a string / array write to a symbol listed here runs the real client's
+ /// encode plan and lands its bytes in (#739 fix round 1).
+ public Dictionary SymbolShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
+ /// Resolved PLC array shapes for entries that are arrays.
+ public Dictionary ArrayShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
+ /// The raw bytes the encode plan produced, by symbol.
+ public ConcurrentDictionary RawWrites { get; } = new(StringComparer.OrdinalIgnoreCase);
+
+ /// Raw bytes of bit-write parent words, by parent path (#739 H18). The byte count is the parent's width.
+ public Dictionary ParentWords { get; } = new(StringComparer.OrdinalIgnoreCase);
+ /// Optional parent shapes; absent = an unsigned integer word as wide as its entry.
+ public Dictionary ParentShapes { get; } = new(StringComparer.OrdinalIgnoreCase);
+ /// Gets the log of every call.
+ public List<(string parent, int bit, bool value)> BitWriteLog { get; } = new();
+ /// When set, yields between its read and its write so a missing
+ /// per-parent lock in the driver loses updates.
+ public bool YieldInBitWrite { get; set; }
+
+ /// Simulates the parent-width bit RMW with the driver's own .
+ /// The parent word's symbol path.
+ /// The bit index.
+ /// The bit value.
+ /// The cancellation token.
+ /// The write status.
+ public virtual async Task WriteBitAsync(string parentPath, int bit, bool value, CancellationToken ct)
+ {
+ if (ThrowOnWrite) throw Exception ?? new InvalidOperationException();
+ lock (BitWriteLog) BitWriteLog.Add((parentPath, bit, value));
+ if (ReadStatuses.TryGetValue(parentPath, out var readStatus) && readStatus != TwinCATStatusMapper.Good)
+ return readStatus;
+ byte[] current;
+ lock (ParentWords) current = ParentWords.TryGetValue(parentPath, out var w) ? [.. w] : [];
+ var shape = ParentShapes.TryGetValue(parentPath, out var sh)
+ ? sh
+ : new TwinCATPlcSymbolShape(TwinCATPlcKind.Scalar, current.Length == 0 ? 4 : current.Length, false, "UDINT");
+ if (YieldInBitWrite) await Task.Yield();
+ var status = TwinCATBitAccess.ApplyBitWrite(shape, current, bit, value, out var updated);
+ if (status != TwinCATStatusMapper.Good) return status;
+ lock (ParentWords) ParentWords[parentPath] = updated;
+ return WriteStatuses.TryGetValue(parentPath, out var s) ? s : TwinCATStatusMapper.Good;
+ }
+
/// Simulates probing the connection status.
/// The cancellation token.
/// A task that returns the probe result.
public virtual Task ProbeAsync(CancellationToken ct)
{
+ Interlocked.Increment(ref _probeCount);
if (ThrowOnProbe) return Task.FromResult(false);
return Task.FromResult(ProbeResult);
}
+ private int _probeCount;
+
+ /// Gets how many times ran — a measured window for absence assertions.
+ public int ProbeCount => Volatile.Read(ref _probeCount);
+
/// Releases unmanaged resources.
public virtual void Dispose()
{
@@ -160,9 +269,19 @@ internal class FakeTwinCATClient : ITwinCATClient
public bool ThrowOnAddNotification { get; set; }
/// Per-symbol exceptions throws (e.g. a
/// for one refused tag).
- public Dictionary AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase);
+ public ConcurrentDictionary AddNotificationErrors { get; } = new(StringComparer.OrdinalIgnoreCase);
+
+ private readonly ConcurrentDictionary _addAttempts = new(StringComparer.OrdinalIgnoreCase);
+
+ /// How many times was called for .
+ /// The symbol path.
+ /// The attempt count.
+ public int AttemptsFor(string symbolPath) => _addAttempts.TryGetValue(symbolPath, out var n) ? n : 0;
/// Records the most recently-supplied maxDelayMs for Driver.TwinCAT-014 tests.
public int LastMaxDelayMs { get; private set; }
+ /// When set, awaits this gate BEFORE applying
+ /// — wedges an attempt in flight whose outcome is a failure.
+ public TaskCompletionSource? PreAttemptGate { get; set; }
/// When set, awaits this gate before creating the handle —
/// lets a test wedge a replay/register mid-flight (STAB-17 orphan race).
public TaskCompletionSource? AddNotificationGate { get; set; }
@@ -181,6 +300,8 @@ internal class FakeTwinCATClient : ITwinCATClient
string symbolPath, TwinCATDataType type, int? bitIndex, int? arrayCount, TimeSpan cycleTime,
int maxDelayMs, Action onChange, CancellationToken cancellationToken)
{
+ _addAttempts.AddOrUpdate(symbolPath, 1, (_, n) => n + 1);
+ if (PreAttemptGate is { } pre) await pre.Task.ConfigureAwait(false);
if (ThrowOnAddNotification)
throw Exception ?? new InvalidOperationException("fake AddNotification failure");
if (AddNotificationErrors.TryGetValue(symbolPath, out var perSymbol))
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayStringWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayStringWriteTests.cs
new file mode 100644
index 00000000..b42c3f0a
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATArrayStringWriteTests.cs
@@ -0,0 +1,199 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+///
+/// #739 U9: array writes and STRING writes go through the same codecs the read side decodes with.
+/// Arrays were advertised writable but every write threw InvalidCastException (reported BadTypeMismatch);
+/// strings bypassed the codec (host-ANSI encoding, no length check against STRING(n)).
+///
+[Trait("Category", "Unit")]
+public sealed class TwinCATArrayStringWriteTests
+{
+ private const string Host = "ads://5.23.91.23.1.1:851";
+
+ // ---- string codec: encode ----
+
+ /// A STRING(n) encodes NUL-terminated and NUL-padded to the PLC width, in Windows-1252.
+ [Fact]
+ public void EncodeString_is_NUL_padded_Windows1252_at_the_PLC_width()
+ {
+ TwinCATStringCodec.EncodeString("AB", 4, utf8: false).ShouldBe(new byte[] { 0x41, 0x42, 0, 0 });
+ TwinCATStringCodec.EncodeString("ä€", 3, utf8: false).ShouldBe(new byte[] { 0xE4, 0x80, 0 });
+ TwinCATStringCodec.EncodeString("ä", 3, utf8: true).ShouldBe(new byte[] { 0xC3, 0xA4, 0 });
+ TwinCATStringCodec.EncodeString("", 2, utf8: false).ShouldBe(new byte[] { 0, 0 });
+ }
+
+ /// The encode is the decode's inverse (a STRING(n) of exactly n characters fits).
+ [Fact]
+ public void EncodeString_round_trips_through_DecodeString()
+ {
+ foreach (var utf8 in new[] { false, true })
+ {
+ var bytes = TwinCATStringCodec.EncodeString("Grüße 40°", 81, utf8);
+ TwinCATStringCodec.DecodeString(bytes, utf8).ShouldBe("Grüße 40°");
+ }
+ TwinCATStringCodec.EncodeString("ABC", 4, utf8: false).Length.ShouldBe(4);
+ }
+
+ /// A value longer than STRING(n), or a character the target encoding cannot hold, is out of range.
+ [Fact]
+ public void EncodeString_refuses_a_value_that_does_not_fit()
+ {
+ Should.Throw(() => TwinCATStringCodec.EncodeString("ABC", 3, utf8: false));
+ Should.Throw(() => TwinCATStringCodec.EncodeString("中", 10, utf8: false));
+ Should.Throw(() => TwinCATStringCodec.EncodeString("ää", 4, utf8: true)); // 4 bytes + NUL
+ }
+
+ /// A WSTRING(n) encodes UTF-16LE with a 2-byte terminator and round-trips.
+ [Fact]
+ public void EncodeWString_is_UTF16LE_with_a_terminator()
+ {
+ TwinCATStringCodec.EncodeWString("Hi", 6).ShouldBe(new byte[] { 0x48, 0, 0x69, 0, 0, 0 });
+ TwinCATStringCodec.DecodeWString(TwinCATStringCodec.EncodeWString("中文", 22)).ShouldBe("中文");
+ Should.Throw(() => TwinCATStringCodec.EncodeWString("Hi", 4));
+ }
+
+ // ---- array codec: elements + encode ----
+
+ ///
+ /// A CLR list converts element-wise to the declared type (ScadaBridge writes an int[] to an
+ /// Int16 node); the element type is converted, not exact-matched.
+ ///
+ [Fact]
+ public void ToWriteElements_converts_each_element_to_the_declared_type()
+ {
+ var elements = TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 1, -2, 300 }, 3);
+ elements.ShouldBe(new object[] { (short)1, (short)-2, (short)300 });
+ TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Real, new List { 1, 2.5 }, 2)
+ .ShouldBe(new object[] { 1f, 2.5f });
+ TwinCATArrayCodec.ToWriteElements(TwinCATDataType.String, new[] { "a", "b" }, 2)
+ .ShouldBe(new object[] { "a", "b" });
+ }
+
+ /// A length mismatch and an out-of-range element are out of range; a scalar is a type mismatch.
+ [Fact]
+ public void ToWriteElements_refuses_a_wrong_shape()
+ {
+ Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 1, 2 }, 3));
+ Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, new[] { 70_000 }, 1));
+ Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, 5, 1));
+ Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.String, "abc", 3));
+ Should.Throw(() => TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Int, null, 1));
+ }
+
+ /// Encode lays elements out little-endian at the PLC stride — the exact inverse of Decode.
+ [Fact]
+ public void Encode_is_the_inverse_of_Decode()
+ {
+ var intLayout = new TwinCATArrayLayout(2, 2);
+ var intBytes = TwinCATArrayCodec.Encode(TwinCATDataType.Int, [(short)1, (short)-2], intLayout, utf8: false);
+ intBytes.ShouldBe(new byte[] { 0x01, 0x00, 0xFE, 0xFF });
+
+ RoundTrip(TwinCATDataType.Bool, [true, false, true], new TwinCATArrayLayout(3, 1));
+ RoundTrip(TwinCATDataType.DInt, [int.MinValue, 7], new TwinCATArrayLayout(2, 4));
+ RoundTrip(TwinCATDataType.ULInt, [ulong.MaxValue], new TwinCATArrayLayout(1, 8));
+ RoundTrip(TwinCATDataType.LReal, [1.5, -2.25], new TwinCATArrayLayout(2, 8));
+ RoundTrip(TwinCATDataType.String, ["ab", "Grüße"], new TwinCATArrayLayout(2, 11));
+ RoundTrip(TwinCATDataType.WString, ["中", "x"], new TwinCATArrayLayout(2, 8));
+
+ // TIME elements arrive as the encoded uint ms (ConvertForWrite) and decode back to double ms.
+ var time = TwinCATArrayCodec.Encode(TwinCATDataType.Time,
+ TwinCATArrayCodec.ToWriteElements(TwinCATDataType.Time, new[] { 1500.0, 20.0 }, 2), new TwinCATArrayLayout(2, 4), false);
+ TwinCATArrayCodec.Decode(TwinCATDataType.Time, time, new TwinCATArrayLayout(2, 4), false)
+ .ShouldBe(new[] { 1500.0, 20.0 });
+ }
+
+ /// A string element longer than the element's STRING(n) is out of range.
+ [Fact]
+ public void Encode_refuses_a_string_element_wider_than_its_stride()
+ {
+ Should.Throw(() =>
+ TwinCATArrayCodec.Encode(TwinCATDataType.String, ["toolong"], new TwinCATArrayLayout(1, 4), utf8: false));
+ Should.Throw(() =>
+ TwinCATArrayCodec.Encode(TwinCATDataType.Int, [(short)1], new TwinCATArrayLayout(2, 2), utf8: false));
+ }
+
+ private static void RoundTrip(TwinCATDataType type, object[] elements, TwinCATArrayLayout layout)
+ {
+ var bytes = TwinCATArrayCodec.Encode(type, elements, layout, utf8: false);
+ bytes.Length.ShouldBe(layout.ByteLength);
+ var decoded = TwinCATArrayCodec.Decode(type, bytes, layout, utf8: false);
+ decoded.Cast().ShouldBe(elements);
+ }
+
+ // ---- driver ----
+
+ private static (TwinCATDriver drv, FakeTwinCATClient fake) NewDriver(params TwinCATTagDefinition[] tags)
+ {
+ var fake = new FakeTwinCATClient();
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host)],
+ RawTags = TwinCATRawTags.Entries(tags),
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ }, "drv-1", new FakeTwinCATClientFactory { Customise = () => fake });
+ return (drv, fake);
+ }
+
+ /// An array tag's write sends ArrayLength converted elements through WriteArrayAsync.
+ [Fact]
+ public async Task An_array_write_sends_converted_elements()
+ {
+ var (drv, fake) = NewDriver(new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 3));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ var result = await drv.WriteAsync([new WriteRequest("Buf", new[] { 1, 2, 3 })], TestContext.Current.CancellationToken);
+
+ result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
+ var write = fake.ArrayWriteLog.ShouldHaveSingleItem();
+ write.symbol.ShouldBe("MAIN.Buf");
+ write.elements.ShouldBe(new object[] { (short)1, (short)2, (short)3 });
+ fake.WriteLog.ShouldBeEmpty(); // never the scalar path (that threw InvalidCastException)
+ }
+
+ ///
+ /// A length mismatch is BadOutOfRange and a scalar value BadTypeMismatch — nothing reaches the wire
+ /// and health is untouched (a refusal is not a comms fault). Positive control: the good write above.
+ ///
+ [Fact]
+ public async Task A_wrong_shape_array_write_is_refused_before_the_wire()
+ {
+ var (drv, fake) = NewDriver(new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 3));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ var results = await drv.WriteAsync(
+ [
+ new WriteRequest("Buf", new[] { 1, 2 }),
+ new WriteRequest("Buf", 5),
+ new WriteRequest("Buf", new[] { 1, 2, 70_000 }),
+ ], TestContext.Current.CancellationToken);
+
+ results.Select(r => r.StatusCode).ShouldBe(
+ [
+ TwinCATStatusMapper.BadOutOfRange,
+ TwinCATStatusMapper.BadTypeMismatch,
+ TwinCATStatusMapper.BadOutOfRange,
+ ]);
+ fake.ArrayWriteLog.ShouldBeEmpty();
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ /// An over-long string surfaced by the client as OverflowException is BadOutOfRange, not a comms fault.
+ [Fact]
+ public async Task An_over_long_string_write_is_BadOutOfRange()
+ {
+ var (drv, fake) = NewDriver(new TwinCATTagDefinition("Name", Host, "MAIN.Name", TwinCATDataType.String));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ fake.ThrowOnWrite = true;
+ fake.Exception = new OverflowException("6 bytes do not fit STRING(4)");
+
+ var result = await drv.WriteAsync([new WriteRequest("Name", "toolong")], TestContext.Current.CancellationToken);
+
+ result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBatchSubscribeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBatchSubscribeTests.cs
new file mode 100644
index 00000000..29148186
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBatchSubscribeTests.cs
@@ -0,0 +1,89 @@
+using System.Collections.Concurrent;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+///
+/// #739 / R02:P1 — TwinCAT is an . DriverInstanceActor subscribes ONLY the
+/// batch event of a batch-capable driver, so every data change — poll cycle AND native push, including the
+/// Bad snapshots the native path publishes itself — must reach OnDataChangeBatch; the per-tag event
+/// keeps firing for legacy consumers (the CLI).
+///
+[Trait("Category", "Unit")]
+public sealed class TwinCATBatchSubscribeTests
+{
+ private const string Host = "ads://5.23.91.23.1.1:851";
+
+ private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, bool native) =>
+ new(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host)],
+ RawTags = TwinCATRawTags.Entries(
+ [
+ new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
+ new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt),
+ ]),
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ UseNativeNotifications = native,
+ }, "drv-1", factory);
+
+ /// The driver advertises the batch capability.
+ [Fact]
+ public void Driver_is_batch_subscribable() =>
+ NewDriver(new FakeTwinCATClientFactory(), native: false).ShouldBeAssignableTo();
+
+ /// A poll cycle that changes two tags raises ONE batch carrying both, and the per-tag event twice.
+ [Fact]
+ public async Task A_poll_cycle_raises_one_batch_with_every_changed_tag()
+ {
+ var factory = new FakeTwinCATClientFactory
+ {
+ Customise = () => new FakeTwinCATClient { Values = { ["MAIN.A"] = 1, ["MAIN.B"] = 2 } },
+ };
+ var drv = NewDriver(factory, native: false);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var batches = new ConcurrentQueue();
+ var single = new ConcurrentQueue();
+ drv.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
+ drv.OnDataChange += (_, e) => single.Enqueue(e);
+
+ var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
+ await WaitForAsync(() => !batches.IsEmpty, TimeSpan.FromSeconds(3));
+ await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
+
+ var first = batches.First();
+ first.SubscriptionHandle.ShouldBe(handle);
+ first.Items.Select(i => i.FullReference).ShouldBe(["A", "B"], ignoreOrder: true);
+ single.Count(e => e.FullReference is "A" or "B").ShouldBeGreaterThanOrEqualTo(2);
+ }
+
+ /// A native push and the native path's own Bad snapshot both reach the batch event.
+ [Fact]
+ public async Task Native_pushes_and_native_bad_snapshots_reach_the_batch_event()
+ {
+ var fake = new FakeTwinCATClient();
+ var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, native: true);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var batches = new ConcurrentQueue();
+ drv.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
+
+ _ = await drv.SubscribeAsync(["A", "Ghost"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
+ fake.FireNotification("MAIN.A", 42);
+
+ var items = batches.SelectMany(b => b.Items).ToList();
+ items.Single(i => i.FullReference == "Ghost").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
+ var a = items.Single(i => i.FullReference == "A");
+ a.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
+ a.Snapshot.Value.ShouldBe(42);
+ }
+
+ private static async Task WaitForAsync(Func condition, TimeSpan timeout)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (!condition() && DateTime.UtcNow < deadline)
+ await Task.Delay(20);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitAccessTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitAccessTests.cs
index 3dc22507..a5b339c8 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitAccessTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATBitAccessTests.cs
@@ -55,4 +55,50 @@ public sealed class TwinCATBitAccessTests
TwinCATBitAccess.CheckParent(0, TwinCATPlcSymbolShape.Classify("ST_X", DataTypeCategory.Struct, 8)).ShouldNotBeNull();
TwinCATBitAccess.CheckParent(31, TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4)).ShouldBeNull();
}
+
+ // ---- #739 H18: the bit WRITE is a read-modify-write at the parent's own width ----
+
+ /// A bit write flips exactly one bit of the parent's raw bytes at the parent's width.
+ [Fact]
+ public void ApplyBitWrite_flips_one_bit_at_the_parent_width()
+ {
+ var word = TwinCATPlcSymbolShape.Classify("WORD", DataTypeCategory.Primitive, 2);
+ TwinCATBitAccess.ApplyBitWrite(word, [0x01, 0x00], 9, true, out var set).ShouldBe(TwinCATStatusMapper.Good);
+ set.ShouldBe(new byte[] { 0x01, 0x02 });
+ TwinCATBitAccess.ApplyBitWrite(word, [0xFF, 0xFF], 3, false, out var cleared).ShouldBe(TwinCATStatusMapper.Good);
+ cleared.ShouldBe(new byte[] { 0xF7, 0xFF });
+ }
+
+ ///
+ /// A signed parent with its sign bit set is plain bytes — the old UDInt round-trip read it back as
+ /// a value ≥ 2³¹ and a checked conversion turned ANY bit write into BadOutOfRange.
+ ///
+ [Fact]
+ public void ApplyBitWrite_on_a_negative_signed_parent_preserves_the_sign_bit()
+ {
+ var intWord = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, 2);
+ TwinCATBitAccess.ApplyBitWrite(intWord, [0x00, 0x80], 0, true, out var updated).ShouldBe(TwinCATStatusMapper.Good);
+ updated.ShouldBe(new byte[] { 0x01, 0x80 });
+ }
+
+ /// A bit beyond the parent is BadOutOfRange; a non-integer parent BadTypeMismatch; nothing is produced.
+ [Fact]
+ public void ApplyBitWrite_refuses_a_bit_the_parent_does_not_have()
+ {
+ var word = TwinCATPlcSymbolShape.Classify("UINT", DataTypeCategory.Primitive, 2);
+ TwinCATBitAccess.ApplyBitWrite(word, [0, 0], 16, true, out var none).ShouldBe(TwinCATStatusMapper.BadOutOfRange);
+ none.ShouldBeEmpty();
+ var real = TwinCATPlcSymbolShape.Classify("REAL", DataTypeCategory.Primitive, 4);
+ TwinCATBitAccess.ApplyBitWrite(real, [0, 0, 0, 0], 1, true, out _).ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
+ }
+
+ /// A parent read shorter than the parent's width is never written back (it would zero bytes).
+ [Fact]
+ public void ApplyBitWrite_refuses_a_short_parent_buffer()
+ {
+ var dword = TwinCATPlcSymbolShape.Classify("DWORD", DataTypeCategory.Primitive, 4);
+ TwinCATBitAccess.ApplyBitWrite(dword, [0xFF, 0xFF], 1, true, out var none)
+ .ShouldBe(TwinCATStatusMapper.BadCommunicationError);
+ none.ShouldBeEmpty();
+ }
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATCapturingLogger.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATCapturingLogger.cs
new file mode 100644
index 00000000..9df5d1b8
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATCapturingLogger.cs
@@ -0,0 +1,26 @@
+using Microsoft.Extensions.Logging;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+/// Collects the driver's Warning-level messages (thread-safe) for once-per-X assertions.
+internal sealed class TwinCATCapturingLogger : ILogger
+{
+ private readonly List _warnings = [];
+
+ /// Gets a snapshot of the formatted Warning messages logged so far.
+ public IReadOnlyList Warnings { get { lock (_warnings) return [.. _warnings]; } }
+
+ ///
+ 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)
+ {
+ if (logLevel == LogLevel.Warning) lock (_warnings) _warnings.Add(formatter(state, exception));
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATHealthContractTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATHealthContractTests.cs
new file mode 100644
index 00000000..19beaeff
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATHealthContractTests.cs
@@ -0,0 +1,163 @@
+using System.Collections.Concurrent;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+///
+/// The driver health contract (#739 H12): data proving the device answered — a native notification or
+/// a successful probe — advances LastSuccessfulRead; sustained probe/connect failure degrades with
+/// it preserved; a refusal (a write the client cannot express, a replay the PLC refuses) is no comms fault.
+///
+[Trait("Category", "Unit")]
+public sealed class TwinCATHealthContractTests
+{
+ private const string Host = "ads://5.23.91.23.1.1:851";
+
+ private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, TwinCATProbeOptions probe, bool native = true) =>
+ new(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host)],
+ RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)]),
+ Probe = probe,
+ UseNativeNotifications = native,
+ }, "drv-1", factory);
+
+ private static TwinCATProbeOptions FastProbe => new()
+ {
+ Enabled = true,
+ Interval = TimeSpan.FromMilliseconds(30),
+ Timeout = TimeSpan.FromMilliseconds(100),
+ };
+
+ /// A native push advances LastSuccessfulRead and restores Healthy after a comms-fault write.
+ [Fact]
+ public async Task A_native_notification_advances_LastSuccessfulRead_and_restores_Healthy()
+ {
+ var fake = new FakeTwinCATClient();
+ var factory = new FakeTwinCATClientFactory { Customise = () => fake };
+ var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false });
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ _ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
+ var initStamp = drv.GetHealth().LastSuccessfulRead;
+ initStamp.ShouldNotBeNull();
+
+ // A write that fails on the wire degrades (comms fault) with the stamp preserved.
+ fake.ThrowOnWrite = true;
+ _ = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ drv.GetHealth().LastSuccessfulRead.ShouldBe(initStamp);
+
+ // Condition, not a fixed delay: the clock only has to move past the stamp (the Degraded state means
+ // the notification is not inside the Healthy throttle window).
+ await WaitForAsync(() => DateTime.UtcNow > initStamp!.Value, TimeSpan.FromSeconds(2));
+ fake.FireNotification("MAIN.Speed", 7);
+
+ var after = drv.GetHealth();
+ after.State.ShouldBe(DriverState.Healthy);
+ after.LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp!.Value);
+ }
+
+ /// A successful probe advances LastSuccessfulRead in a driver that serves only notifications.
+ [Fact]
+ public async Task A_successful_probe_advances_LastSuccessfulRead()
+ {
+ var factory = new FakeTwinCATClientFactory();
+ var drv = NewDriver(factory, FastProbe);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
+
+ await WaitForAsync(() => drv.GetHealth().LastSuccessfulRead > initStamp.AddMilliseconds(250), TimeSpan.FromSeconds(3));
+
+ drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp.AddMilliseconds(250));
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ await drv.ShutdownAsync(TestContext.Current.CancellationToken);
+ }
+
+ ///
+ /// An unreachable AMS router (every probe connect throws) degrades the driver after
+ /// consecutive failures, preserving the stamp —
+ /// before, the probe caught it and health stayed Healthy forever.
+ ///
+ [Fact]
+ public async Task Consecutive_probe_failures_degrade_preserving_LastSuccessfulRead()
+ {
+ var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { ThrowOnConnect = true } };
+ var drv = NewDriver(factory, FastProbe);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var initStamp = drv.GetHealth().LastSuccessfulRead;
+
+ await WaitForAsync(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(3));
+
+ var health = drv.GetHealth();
+ health.State.ShouldBe(DriverState.Degraded);
+ health.LastSuccessfulRead.ShouldBe(initStamp);
+ factory.Clients.Count.ShouldBeGreaterThanOrEqualTo(TwinCATDriver.ProbeFailuresBeforeDegraded);
+ await drv.ShutdownAsync(TestContext.Current.CancellationToken);
+ }
+
+ ///
+ /// A write the client refuses with is BadNotSupported with no
+ /// health write (#611: a refusal is not a comms fault). Positive control: a wire failure does degrade.
+ ///
+ [Fact]
+ public async Task A_NotSupported_write_leaves_health_alone()
+ {
+ var fake = new FakeTwinCATClient { ThrowOnWrite = true, Exception = new NotSupportedException("no") };
+ var factory = new FakeTwinCATClientFactory { Customise = () => fake };
+ var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false }, native: false);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ var result = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
+ result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNotSupported);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+
+ fake.Exception = new InvalidOperationException("wire");
+ result = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
+ result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ }
+
+ ///
+ /// A reconnect replay the PLC REFUSES (an isolated type-mismatch tag re-attempted on every reconnect)
+ /// republishes the refusal's status and does not degrade. Positive control: a replay that fails on the
+ /// wire degrades (covered for the same flow in TwinCATReconnectReplayTests).
+ ///
+ [Fact]
+ public async Task A_refused_replay_republishes_the_refusal_status_without_degrading()
+ {
+ var build = 0;
+ var factory = new FakeTwinCATClientFactory
+ {
+ Customise = () =>
+ {
+ var c = new FakeTwinCATClient();
+ if (++build >= 2)
+ c.AddNotificationErrors["MAIN.Speed"] = new TwinCATTypeMismatchException("MAIN.Speed", "now a STRING");
+ return c;
+ },
+ };
+ var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false });
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var events = new ConcurrentQueue();
+ drv.OnDataChange += (_, e) => events.Enqueue(e);
+ _ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
+
+ factory.Clients[0].SimulateWireDrop();
+ // A write reconnects (and replays) without itself touching health on success.
+ _ = await drv.WriteAsync([new WriteRequest("Speed", 1)], TestContext.Current.CancellationToken);
+
+ factory.Clients.Count.ShouldBe(2);
+ events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ private static async Task WaitForAsync(Func condition, TimeSpan timeout)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (!condition() && DateTime.UtcNow < deadline)
+ await Task.Delay(20);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATLifecycleTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATLifecycleTests.cs
new file mode 100644
index 00000000..915316f8
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATLifecycleTests.cs
@@ -0,0 +1,298 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+///
+/// #739 L8 + the Lane A runtime contract (final stop = ShutdownAsync then DisposeAsync,
+/// bounded 5 s + 250 ms): a probe wedged in the blocking ADS connect cannot hang teardown; dispose may run
+/// while a shutdown is still running; an abandoned initialise overlapping the next shutdown never installs
+/// its generation; a value queued before unsubscribe is not delivered after it; re-init swaps its tables
+/// and retracts the previous generation's notifications; queue drops are reported.
+///
+[Trait("Category", "Unit")]
+public sealed class TwinCATLifecycleTests
+{
+ private const string Host = "ads://5.23.91.23.1.1:851";
+
+ private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, bool probe = true, bool native = true,
+ TwinCATCapturingLogger? log = null, params TwinCATTagDefinition[] tags) =>
+ new(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host)],
+ RawTags = TwinCATRawTags.Entries(tags.Length > 0
+ ? tags
+ : [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)]),
+ Probe = probe
+ ? new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30), Timeout = TimeSpan.FromMilliseconds(100) }
+ : new TwinCATProbeOptions { Enabled = false },
+ UseNativeNotifications = native,
+ }, "drv-1", factory, log);
+
+ private static async Task WaitForAsync(Func condition, TimeSpan timeout)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (!condition() && DateTime.UtcNow < deadline)
+ await Task.Delay(10);
+ }
+
+ ///
+ /// A probe wedged in the (uncancellable) connect no longer hangs ShutdownAsync — the join is bounded —
+ /// and the client the wedged connect eventually produces is disposed, not installed on a dead device.
+ ///
+ [Fact]
+ public async Task Shutdown_is_bounded_when_a_probe_is_wedged_in_connect()
+ {
+ var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
+ var drv = NewDriver(factory);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
+ factory.Clients.Count.ShouldBe(1); // the probe is inside the wedged connect
+
+ var sw = Stopwatch.StartNew();
+ await drv.ShutdownAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ShutdownBudget + TimeSpan.FromMilliseconds(500));
+
+ wedge.SetResult();
+ await WaitForAsync(() => factory.Clients[0].DisposeCount == 1, TimeSpan.FromSeconds(2));
+ factory.Clients[0].DisposeCount.ShouldBe(1);
+ drv.DeviceCount.ShouldBe(0);
+ }
+
+ ///
+ /// DisposeAsync while a ShutdownAsync is still running (joining a wedged probe) returns promptly and
+ /// both complete without throwing; the device is released exactly once.
+ ///
+ [Fact]
+ public async Task Dispose_concurrent_with_a_running_shutdown_completes_promptly()
+ {
+ var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
+ var drv = NewDriver(factory);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
+
+ var shutdown = drv.ShutdownAsync(CancellationToken.None);
+ var sw = Stopwatch.StartNew();
+ await drv.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ // Ordering is the real assertion: dispose finished while the first shutdown was still joining the
+ // wedged probe. The elapsed bound is generous (the runtime allows 250 ms; a loaded CI box gets 1 s).
+ shutdown.IsCompleted.ShouldBeFalse("the positive control: the first shutdown really was still joining the probe");
+ sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1));
+
+ wedge.SetResult();
+ await shutdown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ drv.Dispose(); // and a late sync Dispose is a no-op
+ drv.GetHealth().State.ShouldBe(DriverState.Unknown);
+ }
+
+ ///
+ /// An InitializeAsync overtaken by a ShutdownAsync before it commits must not install its devices or
+ /// start probe loops when it resumes. Positive control: a fresh initialise afterwards installs normally.
+ ///
+ [Fact]
+ public async Task An_initialise_overtaken_by_shutdown_before_commit_installs_nothing()
+ {
+ var factory = new FakeTwinCATClientFactory();
+ var drv = NewDriver(factory);
+ var paused = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var resume = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ drv.BeforeCommitForTest = async () => { paused.TrySetResult(); await resume.Task; };
+
+ var abandoned = drv.InitializeAsync("{}", CancellationToken.None);
+ await paused.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ await drv.ShutdownAsync(TestContext.Current.CancellationToken);
+ resume.SetResult();
+
+ await Should.ThrowAsync(() => abandoned.WaitAsync(TimeSpan.FromSeconds(5)));
+ drv.DeviceCount.ShouldBe(0);
+ factory.Clients.ShouldBeEmpty();
+ drv.GetHealth().State.ShouldBe(DriverState.Unknown);
+
+ // Positive control + measured window: a fresh generation's probe creates exactly one client and
+ // ticks several times; an overtaken-generation probe loop (same interval, started earlier) would have
+ // created a second client within that window.
+ drv.BeforeCommitForTest = null;
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ drv.DeviceCount.ShouldBe(1);
+ await WaitForAsync(() => factory.Clients.Count >= 1 && factory.Clients[0].ProbeCount >= 4, TimeSpan.FromSeconds(3));
+ factory.Clients[0].ProbeCount.ShouldBeGreaterThanOrEqualTo(4);
+ factory.Clients.Count.ShouldBe(1, "no probe loop of the overtaken generation may be running");
+ await drv.ShutdownAsync(TestContext.Current.CancellationToken);
+ }
+
+ ///
+ /// The Lane A shape: an InitializeAsync the runtime abandoned while it is still releasing the previous
+ /// generation (joining a probe wedged in connect) overlaps the next ShutdownAsync. Shutdown stays
+ /// bounded and, once everything settles, no device, probe loop or live client survives.
+ ///
+ [Fact]
+ public async Task An_abandoned_initialise_overlapping_shutdown_leaves_nothing_running()
+ {
+ var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var first = true;
+ var factory = new FakeTwinCATClientFactory
+ {
+ Customise = () =>
+ {
+ var c = new FakeTwinCATClient { BlockConnect = first ? wedge : null };
+ first = false;
+ return c;
+ },
+ };
+ var drv = NewDriver(factory);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
+
+ var abandoned = drv.InitializeAsync("{}", CancellationToken.None); // joins the wedged generation-1 probe
+ await WaitForAsync(() => drv.DeviceCount == 1 && factory.Clients.Count >= 2, TimeSpan.FromSeconds(3)); // gen 2 committed + probing
+ var sw = Stopwatch.StartNew();
+ await drv.ShutdownAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ShutdownBudget + TimeSpan.FromMilliseconds(500));
+
+ wedge.SetResult();
+ try { await abandoned.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); }
+ catch (OperationCanceledException) when (!TestContext.Current.CancellationToken.IsCancellationRequested) { }
+
+ drv.DeviceCount.ShouldBe(0);
+ await WaitForAsync(() => factory.Clients.All(c => c.DisposeCount > 0 || !c.IsConnected), TimeSpan.FromSeconds(2));
+ var settled = factory.Clients.Count;
+
+ // Measured window: a sibling driver on the same probe interval ticks 4 times; any surviving probe
+ // loop of the torn-down driver would have built a new client in that time.
+ var witnessFactory = new FakeTwinCATClientFactory();
+ var witness = NewDriver(witnessFactory);
+ await witness.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ await WaitForAsync(() => witnessFactory.Clients.Count >= 1 && witnessFactory.Clients[0].ProbeCount >= 4, TimeSpan.FromSeconds(3));
+ witnessFactory.Clients[0].ProbeCount.ShouldBeGreaterThanOrEqualTo(4);
+ await witness.ShutdownAsync(TestContext.Current.CancellationToken);
+ factory.Clients.Count.ShouldBe(settled, "no probe loop may still be creating clients");
+ factory.Clients.ShouldAllBe(c => c.DisposeCount > 0 || !c.IsConnected);
+ }
+
+ ///
+ /// A value the client queued before the unsubscribe is dropped if dispatched after it.
+ /// Positive control: the same callback delivers while subscribed.
+ ///
+ [Fact]
+ public async Task A_notification_dispatched_after_unsubscribe_is_dropped()
+ {
+ var factory = new FakeTwinCATClientFactory();
+ var drv = NewDriver(factory, probe: false);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var events = new ConcurrentQueue();
+ drv.OnDataChange += (_, e) => events.Enqueue(e);
+ var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
+ var callback = factory.Clients[0].Notifications.Single().OnChange; // what the client's queue holds
+
+ callback("MAIN.Speed", 1);
+ events.Count.ShouldBe(1);
+
+ await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
+ callback("MAIN.Speed", 2);
+ events.Count.ShouldBe(1);
+ }
+
+ ///
+ /// A direct re-initialise with a changed config swaps the tag table (the removed tag is unknown, the
+ /// added one resolves) and retracts the previous generation's live notifications.
+ ///
+ [Fact]
+ public async Task Reinitialise_swaps_tables_and_retracts_the_previous_generations_notifications()
+ {
+ var factory = new FakeTwinCATClientFactory();
+ var drv = NewDriver(factory, probe: false);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ _ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
+ factory.Clients[0].Notifications.Count.ShouldBe(1);
+
+ var next = System.Text.Json.JsonSerializer.Serialize(new
+ {
+ devices = new[] { new { hostAddress = Host } },
+ rawTags = new[]
+ {
+ new
+ {
+ rawPath = "Torque",
+ tagConfig = TwinCATTagDefinitionFactory.ToTagConfig(
+ new TwinCATTagDefinition("Torque", Host, "MAIN.Torque", TwinCATDataType.DInt)),
+ writeIdempotent = false,
+ deviceName = Host,
+ },
+ },
+ probe = new { enabled = false },
+ });
+ await drv.InitializeAsync(next, TestContext.Current.CancellationToken);
+
+ factory.Clients[0].Notifications.ShouldBeEmpty();
+ factory.Clients[0].DisposeCount.ShouldBe(1);
+ (await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken)).Single().StatusCode
+ .ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
+ (await drv.ReadAsync(["Torque"], TestContext.Current.CancellationToken)).Single().StatusCode
+ .ShouldBe(TwinCATStatusMapper.Good);
+ }
+
+ /// Reads racing a stream of re-initialises never throw (no table is ever mid-Clear).
+ [Fact]
+ public async Task Reads_racing_reinitialise_never_throw()
+ {
+ var factory = new FakeTwinCATClientFactory();
+ var drv = NewDriver(factory, probe: false);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ // Run for at least 300 ms of overlap AND until the reader has seen a Good read mid-churn (positive
+ // control), bounded at 10 s — a condition, not a fixed window, so a loaded machine cannot starve it.
+ var good = 0;
+ var reads = 0;
+ var inits = 0;
+ var minUntil = DateTime.UtcNow.AddMilliseconds(300);
+ var deadline = DateTime.UtcNow.AddSeconds(10);
+ bool Done() => (DateTime.UtcNow > minUntil && Volatile.Read(ref good) > 0 && Volatile.Read(ref inits) > 10)
+ || DateTime.UtcNow > deadline;
+ var reader = Task.Run(async () =>
+ {
+ while (!Done())
+ {
+ var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
+ Interlocked.Increment(ref reads);
+ if (r[0].StatusCode == TwinCATStatusMapper.Good) Interlocked.Increment(ref good);
+ }
+ }, TestContext.Current.CancellationToken);
+ while (!Done())
+ {
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ Interlocked.Increment(ref inits);
+ await Task.Yield(); // let the reader in between generations
+ }
+ await reader; // throws if any read threw
+ good.ShouldBeGreaterThan(0);
+ inits.ShouldBeGreaterThan(10);
+ }
+
+ /// Notification-queue drops are reported once per probe tick that sees new drops.
+ [Fact]
+ public async Task Notification_queue_drops_are_reported()
+ {
+ var fake = new FakeTwinCATClient();
+ var log = new TwinCATCapturingLogger();
+ var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, log: log);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ await WaitForAsync(() => fake.ProbeCount >= 3, TimeSpan.FromSeconds(3));
+ log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(0); // 3 ticks, no drops, no warning
+
+ fake.DroppedNotificationCount = 5;
+ await WaitForAsync(() => log.Warnings.Any(w => w.Contains("dropped 5")), TimeSpan.FromSeconds(2));
+ var ticks = fake.ProbeCount;
+ await WaitForAsync(() => fake.ProbeCount >= ticks + 3, TimeSpan.FromSeconds(3));
+ log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(1); // 3 more ticks, no new drops, no repeat
+
+ fake.DroppedNotificationCount = 8;
+ await WaitForAsync(() => log.Warnings.Any(w => w.Contains("dropped 3")), TimeSpan.FromSeconds(2));
+ log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(2);
+ await drv.ShutdownAsync(TestContext.Current.CancellationToken);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeIsolationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeIsolationTests.cs
new file mode 100644
index 00000000..144c7570
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeIsolationTests.cs
@@ -0,0 +1,225 @@
+using System.Collections.Concurrent;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+///
+/// Native-subscribe isolation (#739): an unresolvable reference publishes BadNodeIdUnknown
+/// (U12) instead of keeping whatever status the server seeded, and one failing registration does not
+/// take down the rest of the subscription (H11).
+///
+[Trait("Category", "Unit")]
+public sealed class TwinCATNativeIsolationTests
+{
+ private const string Host = "ads://5.23.91.23.1.1:851";
+
+ private static TwinCATDriver NewNativeDriver(FakeTwinCATClientFactory factory, params TwinCATTagDefinition[] tags) =>
+ new(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host)],
+ RawTags = TwinCATRawTags.Entries(tags),
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ UseNativeNotifications = true,
+ }, "drv-1", factory);
+
+ ///
+ /// U12: an unknown reference and a tag routed to an unconfigured device each publish one
+ /// BadNodeIdUnknown snapshot; the resolvable tag still registers and delivers Good (positive control).
+ ///
+ [Fact]
+ public async Task Native_subscribe_publishes_BadNodeIdUnknown_for_unresolvable_references()
+ {
+ var factory = new FakeTwinCATClientFactory();
+ var drv = NewNativeDriver(factory,
+ new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt),
+ new TwinCATTagDefinition("Orphan", "ads://9.9.9.9.1.1:851", "MAIN.Orphan", TwinCATDataType.DInt));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ var events = new ConcurrentQueue();
+ drv.OnDataChange += (_, e) => events.Enqueue(e);
+
+ _ = await drv.SubscribeAsync(["Ghost", "Speed", "Orphan"], TimeSpan.FromMilliseconds(100),
+ TestContext.Current.CancellationToken);
+
+ var ghost = events.Single(e => e.FullReference == "Ghost");
+ ghost.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
+ ghost.Snapshot.Value.ShouldBeNull();
+ events.Single(e => e.FullReference == "Orphan").Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
+
+ // Positive control: the resolvable tag registered and its push arrives Good.
+ factory.Clients.ShouldHaveSingleItem().Notifications.ShouldHaveSingleItem().SymbolPath.ShouldBe("MAIN.Speed");
+ factory.Clients[0].FireNotification("MAIN.Speed", 42);
+ var good = events.Single(e => e.FullReference == "Speed");
+ good.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
+ good.Snapshot.Value.ShouldBe(42);
+ }
+
+ ///
+ /// H11: one tag whose AddDeviceNotification round-trip fails (not a refusal) goes Bad on its own and
+ /// the subscribe call SUCCEEDS — before, the exception disposed every registration and rethrew, so one
+ /// bad tag served zero data for the whole driver. The good tags keep delivering (positive control).
+ ///
+ [Fact]
+ public async Task One_failing_registration_does_not_tear_down_the_subscription()
+ {
+ var fake = new FakeTwinCATClient();
+ fake.AddNotificationErrors["MAIN.B"] = new InvalidOperationException("AddDeviceNotificationEx blew up");
+ var factory = new FakeTwinCATClientFactory { Customise = () => fake };
+ var drv = NewNativeDriver(factory,
+ new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
+ new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt),
+ new TwinCATTagDefinition("C", Host, "MAIN.C", TwinCATDataType.DInt));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var events = new ConcurrentQueue();
+ drv.OnDataChange += (_, e) => events.Enqueue(e);
+
+ var handle = await drv.SubscribeAsync(["A", "B", "C"], TimeSpan.FromMilliseconds(100),
+ TestContext.Current.CancellationToken);
+
+ fake.Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A", "MAIN.C"], ignoreOrder: true);
+ events.ShouldHaveSingleItem().FullReference.ShouldBe("B");
+ events.Single().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
+
+ fake.FireNotification("MAIN.C", 3);
+ events.Last().FullReference.ShouldBe("C");
+ events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
+
+ // The failed tag is kept as a dead intent and unsubscribe still removes everything.
+ drv.GetDeviceState(Host)!.NativeRegistrations.Count.ShouldBe(3);
+ await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
+ drv.GetDeviceState(Host)!.NativeRegistrations.ShouldBeEmpty();
+ fake.Notifications.ShouldBeEmpty();
+ }
+
+ ///
+ /// H11: a refusal (symbol not found — the device answered) isolates the tag with the refusal's own
+ /// status and leaves driver health alone; the good tag delivers.
+ ///
+ [Fact]
+ public async Task A_refused_symbol_is_isolated_without_a_health_write()
+ {
+ var fake = new FakeTwinCATClient();
+ fake.AddNotificationErrors["MAIN.Typo"] = new TwinCATNotificationRefusedException(
+ "MAIN.Typo", "resolving the symbol failed", TwinCATStatusMapper.BadNodeIdUnknown);
+ var factory = new FakeTwinCATClientFactory { Customise = () => fake };
+ var drv = NewNativeDriver(factory,
+ new TwinCATTagDefinition("Typo", Host, "MAIN.Typo", TwinCATDataType.DInt),
+ new TwinCATTagDefinition("Good", Host, "MAIN.Good", TwinCATDataType.DInt));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var events = new ConcurrentQueue();
+ drv.OnDataChange += (_, e) => events.Enqueue(e);
+
+ _ = await drv.SubscribeAsync(["Typo", "Good"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
+
+ events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ fake.FireNotification("MAIN.Good", 1);
+ events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
+ }
+
+ ///
+ /// H11: in a two-device driver an unreachable device only blacks out ITS tags (Bad + dead intents,
+ /// health Degraded); the reachable device's tags register and deliver.
+ ///
+ [Fact]
+ public async Task An_unreachable_device_blacks_out_only_its_own_tags()
+ {
+ const string hostB = "ads://5.23.91.24.1.1:851";
+ var build = 0;
+ var factory = new FakeTwinCATClientFactory
+ {
+ // Device A's client is created first and works; device B's connect throws.
+ Customise = () => new FakeTwinCATClient { ThrowOnConnect = ++build == 2 },
+ };
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host), new TwinCATDeviceOptions(hostB)],
+ RawTags = TwinCATRawTags.Entries(
+ [
+ new TwinCATTagDefinition("A1", Host, "MAIN.A1", TwinCATDataType.DInt),
+ new TwinCATTagDefinition("B1", hostB, "MAIN.B1", TwinCATDataType.DInt),
+ new TwinCATTagDefinition("B2", hostB, "MAIN.B2", TwinCATDataType.DInt),
+ new TwinCATTagDefinition("A2", Host, "MAIN.A2", TwinCATDataType.DInt),
+ ]),
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ UseNativeNotifications = true,
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var events = new ConcurrentQueue();
+ drv.OnDataChange += (_, e) => events.Enqueue(e);
+
+ _ = await drv.SubscribeAsync(["A1", "B1", "B2", "A2"], TimeSpan.FromMilliseconds(100),
+ TestContext.Current.CancellationToken);
+
+ factory.Clients.Count.ShouldBe(2); // B2 did not attempt a second connect
+ factory.Clients[0].Notifications.Select(n => n.SymbolPath).ShouldBe(["MAIN.A1", "MAIN.A2"], ignoreOrder: true);
+ events.Select(e => e.FullReference).ShouldBe(["B1", "B2"], ignoreOrder: true);
+ events.ShouldAllBe(e => e.Snapshot.StatusCode == TwinCATStatusMapper.BadCommunicationError);
+ drv.GetDeviceState(hostB)!.NativeRegistrations.Count.ShouldBe(2);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+
+ factory.Clients[0].FireNotification("MAIN.A2", 22);
+ events.Last().FullReference.ShouldBe("A2");
+ events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
+ }
+
+ ///
+ /// H11: the dead intent left by a failed registration is re-registered by the probe-tick retry, and
+ /// its first push then arrives Good — the subscription heals without a re-subscribe.
+ ///
+ [Fact]
+ public async Task A_failed_registration_is_recovered_by_the_probe_tick_retry()
+ {
+ var fake = new FakeTwinCATClient();
+ fake.AddNotificationErrors["MAIN.B"] = new InvalidOperationException("transient");
+ var factory = new FakeTwinCATClientFactory { Customise = () => fake };
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host)],
+ RawTags = TwinCATRawTags.Entries([new TwinCATTagDefinition("B", Host, "MAIN.B", TwinCATDataType.DInt)]),
+ Probe = new TwinCATProbeOptions
+ {
+ Enabled = true,
+ Interval = TimeSpan.FromMilliseconds(40),
+ Timeout = TimeSpan.FromMilliseconds(100),
+ },
+ UseNativeNotifications = true,
+ }, "drv-1", factory);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var events = new ConcurrentQueue();
+ drv.OnDataChange += (_, e) => events.Enqueue(e);
+
+ _ = await drv.SubscribeAsync(["B"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
+ fake.AddNotificationErrors.Clear();
+
+ await WaitForAsync(() => fake.Notifications.Count == 1, TimeSpan.FromSeconds(3));
+ fake.Notifications.Count.ShouldBe(1);
+ fake.FireNotification("MAIN.B", 5);
+ events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.Good);
+ await drv.ShutdownAsync(TestContext.Current.CancellationToken);
+ }
+
+ /// A comms-class registration status stays an exception; any other status is a per-tag refusal.
+ [Fact]
+ public void Registration_failures_are_classified_by_status()
+ {
+ AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadNodeIdUnknown)
+ .ShouldBeOfType().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
+ AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadOutOfService)
+ .ShouldBeOfType();
+ AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadCommunicationError)
+ .ShouldBeOfType();
+ AdsTwinCATClient.RegistrationFailure("MAIN.X", "step", TwinCATStatusMapper.BadTimeout)
+ .ShouldBeOfType();
+ }
+
+ private static async Task WaitForAsync(Func condition, TimeSpan timeout)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (!condition() && DateTime.UtcNow < deadline)
+ await Task.Delay(20);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs
index 7b40cbc0..3fdceb13 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATNativeNotificationTests.cs
@@ -98,13 +98,14 @@ public sealed class TwinCATNativeNotificationTests
events.Count.ShouldBe(afterUnsub);
}
- /// Verifies that native subscribe failure mid-registration cleans up partial state.
+ ///
+ /// A registration failing mid-subscribe isolates that one tag (#739 H11): the first registration
+ /// stays live, the second is kept as a dead intent and goes Bad, and the call does not throw.
+ ///
[Fact]
- public async Task Native_subscribe_failure_mid_registration_cleans_up_partial_state()
+ public async Task Native_subscribe_failure_mid_registration_isolates_the_failed_tag()
{
// Fail-on-second-call fake — first AddNotificationAsync succeeds, second throws.
- // Subscribe's catch block must tear the first one down before rethrowing so no zombie
- // notification lingers.
var fake = new FailAfterNAddsFake(new AbTagParamsIrrelevant(), succeedBefore: 1);
var factory = new FakeTwinCATClientFactory { Customise = () => fake };
var drv = new TwinCATDriver(new TwinCATDriverOptions
@@ -120,12 +121,19 @@ public sealed class TwinCATNativeNotificationTests
}, "drv-1", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
- await Should.ThrowAsync(() =>
- drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None));
+ var events = new ConcurrentQueue();
+ drv.OnDataChange += (_, e) => events.Enqueue(e);
+
+ var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
- // First registration succeeded then got torn down by the catch; second threw.
fake.AddCallCount.ShouldBe(2);
- fake.Notifications.Count.ShouldBe(0); // partial handle cleaned up
+ fake.Notifications.ShouldHaveSingleItem().SymbolPath.ShouldBe("MAIN.A");
+ var bad = events.ShouldHaveSingleItem();
+ bad.FullReference.ShouldBe("B");
+ bad.Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
+
+ await drv.UnsubscribeAsync(handle, CancellationToken.None);
+ fake.Notifications.ShouldBeEmpty();
}
///
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWarningTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWarningTests.cs
new file mode 100644
index 00000000..eeb78937
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWarningTests.cs
@@ -0,0 +1,104 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+///
+/// #739 P10: a bad tag warns ONCE (not once per poll — 50 typo'd symbols on a 500 ms poll used to be 100
+/// Warnings/s forever), re-arms when it reads Good, and a device refusal leaves driver health alone while a
+/// comms-class status degrades.
+///
+[Trait("Category", "Unit")]
+public sealed class TwinCATReadWarningTests
+{
+ private const string Host = "ads://5.23.91.23.1.1:851";
+
+ private static (TwinCATDriver drv, FakeTwinCATClient fake, TwinCATCapturingLogger log) NewDriver()
+ {
+ var fake = new FakeTwinCATClient();
+ var log = new TwinCATCapturingLogger();
+ var drv = new TwinCATDriver(new TwinCATDriverOptions
+ {
+ Devices = [new TwinCATDeviceOptions(Host)],
+ RawTags = TwinCATRawTags.Entries(
+ [
+ new TwinCATTagDefinition("Typo", Host, "MAIN.Typo", TwinCATDataType.DInt),
+ new TwinCATTagDefinition("Other", Host, "MAIN.Other", TwinCATDataType.DInt),
+ ]),
+ Probe = new TwinCATProbeOptions { Enabled = false },
+ }, "drv-1", new FakeTwinCATClientFactory { Customise = () => fake }, log);
+ return (drv, fake, log);
+ }
+
+ private static int WarningsFor(TwinCATCapturingLogger log, string reference) =>
+ log.Warnings.Count(w => w.Contains($"'{reference}'", StringComparison.Ordinal));
+
+ /// Repeated bad reads of one tag log one Warning; a second bad tag gets its own (positive control).
+ [Fact]
+ public async Task A_bad_tag_warns_once_across_polls_and_rearms_after_Good()
+ {
+ var (drv, fake, log) = NewDriver();
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ fake.ReadStatuses["MAIN.Typo"] = TwinCATStatusMapper.BadNodeIdUnknown;
+
+ for (var i = 0; i < 5; i++)
+ (await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken))
+ .Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
+ WarningsFor(log, "Typo").ShouldBe(1);
+
+ fake.ReadStatuses["MAIN.Other"] = TwinCATStatusMapper.BadNodeIdUnknown;
+ _ = await drv.ReadAsync(["Other"], TestContext.Current.CancellationToken);
+ WarningsFor(log, "Other").ShouldBe(1);
+
+ // Fixed, then broken again: a new episode warns again.
+ fake.ReadStatuses.Remove("MAIN.Typo");
+ _ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
+ fake.ReadStatuses["MAIN.Typo"] = TwinCATStatusMapper.BadNodeIdUnknown;
+ _ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
+ _ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
+ WarningsFor(log, "Typo").ShouldBe(2);
+ }
+
+ /// A re-initialise re-arms the warning.
+ [Fact]
+ public async Task A_reinitialise_rearms_the_warning()
+ {
+ var (drv, fake, log) = NewDriver();
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ fake.ReadStatuses["MAIN.Typo"] = TwinCATStatusMapper.BadNodeIdUnknown;
+ _ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
+
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ _ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
+
+ WarningsFor(log, "Typo").ShouldBe(2);
+ }
+
+ ///
+ /// A status proving the device answered (symbol not found) keeps the driver Healthy and advances the
+ /// stamp; a comms-class status (timeout) degrades with the stamp preserved.
+ ///
+ [Fact]
+ public async Task A_refusal_status_does_not_degrade_but_a_comms_status_does()
+ {
+ var (drv, fake, _) = NewDriver();
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
+ fake.ReadStatuses["MAIN.Typo"] = TwinCATStatusMapper.BadNodeIdUnknown;
+ var deadline = DateTime.UtcNow.AddSeconds(2);
+ while (DateTime.UtcNow <= initStamp && DateTime.UtcNow < deadline) await Task.Delay(5, TestContext.Current.CancellationToken);
+
+ _ = await drv.ReadAsync(["Typo"], TestContext.Current.CancellationToken);
+ var afterRefusal = drv.GetHealth();
+ afterRefusal.State.ShouldBe(DriverState.Healthy);
+ afterRefusal.LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp);
+
+ fake.ReadStatuses["MAIN.Other"] = TwinCATStatusMapper.BadTimeout;
+ _ = await drv.ReadAsync(["Other"], TestContext.Current.CancellationToken);
+ var afterTimeout = drv.GetHealth();
+ afterTimeout.State.ShouldBe(DriverState.Degraded);
+ afterTimeout.LastSuccessfulRead.ShouldBe(afterRefusal.LastSuccessfulRead);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs
index c81e7b88..d7dbcc0d 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReadWriteTests.cs
@@ -296,23 +296,23 @@ public sealed class TwinCATReadWriteTests
factory.Clients[0].DisposeCount.ShouldBe(1);
}
- // ---- BOOL-within-word RMW writes ----
+ // ---- BOOL-within-word RMW writes (#739 H18: at the parent's own width, on raw bytes) ----
- /// A BOOL-within-word set reads the parent word, ORs the bit, writes it back as UDInt.
+ /// A BOOL-within-word set read-modify-writes the parent word's raw bytes through WriteBitAsync.
[Fact]
- public async Task Bit_set_RMWs_parent_word_as_UDInt()
+ public async Task Bit_set_RMWs_the_parent_word_bytes()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
- factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0b0001u } };
+ factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["MAIN.Flags"] = [0b0001, 0, 0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.Good);
- factory.Clients[0].Values["MAIN.Flags"].ShouldBe(0b1001u);
- factory.Clients[0].WriteLog.ShouldContain(e =>
- e.symbol == "MAIN.Flags" && e.type == TwinCATDataType.UDInt && e.bit == null);
+ factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0b1001, 0, 0, 0 });
+ factory.Clients[0].BitWriteLog.ShouldBe([("MAIN.Flags", 3, true)]);
+ factory.Clients[0].WriteLog.ShouldBeEmpty(); // no 32-bit UDInt round-trip any more
}
/// A BOOL-within-word clear preserves the other bits in the parent word.
@@ -321,12 +321,12 @@ public sealed class TwinCATReadWriteTests
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
- factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0xFFFFu } };
+ factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["MAIN.Flags"] = [0xFF, 0xFF] } };
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Flag", false)], CancellationToken.None);
- factory.Clients[0].Values["MAIN.Flags"].ShouldBe(0xFFF7u);
+ factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xF7, 0xFF });
}
/// RMW works on a DWORD parent (bit 20 set above the 16-bit boundary).
@@ -335,12 +335,31 @@ public sealed class TwinCATReadWriteTests
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Hi", "ads://5.23.91.23.1.1:851", "GVL.Status.20", TwinCATDataType.Bool));
- factory.Customise = () => new FakeTwinCATClient { Values = { ["GVL.Status"] = 0u } };
+ factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["GVL.Status"] = [0, 0, 0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None);
await drv.WriteAsync([new WriteRequest("Hi", true)], CancellationToken.None);
- factory.Clients[0].Values["GVL.Status"].ShouldBe(1u << 20);
+ BitConverter.ToUInt32(factory.Clients[0].ParentWords["GVL.Status"]).ShouldBe(1u << 20);
+ }
+
+ ///
+ /// A bit beyond a 16-bit parent is refused BadOutOfRange and nothing is written — before, the hardcoded
+ /// 32-bit RMW wrote into the neighbouring bytes. No health write: a refusal is not a comms fault.
+ ///
+ [Fact]
+ public async Task Bit_beyond_a_16_bit_parent_is_refused_without_writing()
+ {
+ var (drv, factory) = NewDriver(
+ new TwinCATTagDefinition("Hi", "ads://5.23.91.23.1.1:851", "MAIN.Word.20", TwinCATDataType.Bool));
+ factory.Customise = () => new FakeTwinCATClient { ParentWords = { ["MAIN.Word"] = [0, 0] } };
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var results = await drv.WriteAsync([new WriteRequest("Hi", true)], CancellationToken.None);
+
+ results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
+ factory.Clients[0].ParentWords["MAIN.Word"].ShouldBe(new byte[] { 0, 0 });
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
}
/// A failed parent read short-circuits the RMW and surfaces the read status.
@@ -351,6 +370,7 @@ public sealed class TwinCATReadWriteTests
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
factory.Customise = () => new FakeTwinCATClient
{
+ ParentWords = { ["MAIN.Flags"] = [0xAA, 0] },
ReadStatuses = { ["MAIN.Flags"] = TwinCATStatusMapper.BadNodeIdUnknown },
};
await drv.InitializeAsync("{}", CancellationToken.None);
@@ -358,25 +378,24 @@ public sealed class TwinCATReadWriteTests
var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
- factory.Clients[0].WriteLog.ShouldBeEmpty();
+ factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xAA, 0 });
}
- /// A Good parent read that yields a null value must NOT zero the word — it surfaces
- /// a Bad status and writes nothing (treating null as 0 would clear every set bit).
+ /// A Good parent read that yields no bytes must NOT zero the word — it surfaces a Bad status
+ /// and writes nothing (treating it as 0 would clear every set bit).
[Fact]
- public async Task Bit_write_with_null_parent_value_does_not_zero_the_word()
+ public async Task Bit_write_with_no_parent_bytes_does_not_zero_the_word()
{
var (drv, factory) = NewDriver(
new TwinCATTagDefinition("Flag", "ads://5.23.91.23.1.1:851", "MAIN.Flags.3", TwinCATDataType.Bool));
- // Do NOT seed Values["MAIN.Flags"] — the fake returns (null, Good) for the parent read.
+ // Do NOT seed ParentWords["MAIN.Flags"] — the fake answers the parent read Good with no bytes.
factory.Customise = () => new FakeTwinCATClient();
await drv.InitializeAsync("{}", CancellationToken.None);
var results = await drv.WriteAsync([new WriteRequest("Flag", true)], CancellationToken.None);
results.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
- // No parent write attempted — the word is left untouched on the device.
- factory.Clients[0].WriteLog.ShouldBeEmpty();
+ factory.Clients[0].ParentWords.ShouldNotContainKey("MAIN.Flags");
}
/// Concurrent bit writes to the same word compose correctly (per-parent lock).
@@ -387,12 +406,12 @@ public sealed class TwinCATReadWriteTests
.Select(b => new TwinCATTagDefinition($"Bit{b}", "ads://5.23.91.23.1.1:851", $"MAIN.Flags.{b}", TwinCATDataType.Bool))
.ToArray();
var (drv, factory) = NewDriver(tags);
- factory.Customise = () => new FakeTwinCATClient { Values = { ["MAIN.Flags"] = 0u } };
+ factory.Customise = () => new FakeTwinCATClient { YieldInBitWrite = true, ParentWords = { ["MAIN.Flags"] = [0, 0] } };
await drv.InitializeAsync("{}", CancellationToken.None);
await Task.WhenAll(Enumerable.Range(0, 8).Select(b =>
drv.WriteAsync([new WriteRequest($"Bit{b}", true)], CancellationToken.None)));
- Convert.ToUInt32(factory.Clients[0].Values["MAIN.Flags"]).ShouldBe(0xFFu);
+ factory.Clients[0].ParentWords["MAIN.Flags"].ShouldBe(new byte[] { 0xFF, 0 });
}
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReviewFixRound1Tests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReviewFixRound1Tests.cs
new file mode 100644
index 00000000..a25d5292
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReviewFixRound1Tests.cs
@@ -0,0 +1,302 @@
+using System.Collections.Concurrent;
+using System.Diagnostics;
+using Shouldly;
+using TwinCAT.TypeSystem;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+///
+/// #739 review fix round 1: the PLC-not-in-RUN health ruling, refused-registration retry backoff,
+/// per-device worst-of health, RMW gates surviving teardown, a cancellable shutdown, the real
+/// STRING / array encode path through the client planning helpers, and REAL range checks.
+///
+[Trait("Category", "Unit")]
+public sealed class TwinCATReviewFixRound1Tests
+{
+ private const string Host = "ads://5.23.91.23.1.1:851";
+ private const string HostB = "ads://5.23.91.24.1.1:851";
+
+ private static TwinCATProbeOptions FastProbe => new()
+ {
+ Enabled = true,
+ Interval = TimeSpan.FromMilliseconds(30),
+ Timeout = TimeSpan.FromMilliseconds(100),
+ };
+
+ private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, TwinCATProbeOptions probe, bool native,
+ params TwinCATTagDefinition[] tags) =>
+ new(new TwinCATDriverOptions
+ {
+ Devices = [.. tags.Select(t => t.DeviceHostAddress).Append(Host).Distinct().Select(h => new TwinCATDeviceOptions(h))],
+ RawTags = TwinCATRawTags.Entries(tags),
+ Probe = probe,
+ UseNativeNotifications = native,
+ }, "drv-1", factory);
+
+ private static async Task WaitForAsync(Func condition, TimeSpan timeout)
+ {
+ var deadline = DateTime.UtcNow + timeout;
+ while (!condition() && DateTime.UtcNow < deadline)
+ await Task.Delay(10);
+ }
+
+ // ---- IMPORTANT: a PLC not in RUN leaves the driver Degraded ----
+
+ ///
+ /// A probe that answers with ADS state Stop writes Degraded "PLC in Stop" and still advances
+ /// LastSuccessfulRead (the device answered); back in Run the driver is Healthy (positive control).
+ ///
+ [Fact]
+ public async Task A_PLC_not_in_Run_is_Degraded_and_Run_restores_Healthy()
+ {
+ var fake = new FakeTwinCATClient { AdsStateText = "Stop" };
+ var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: false,
+ new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var initStamp = drv.GetHealth().LastSuccessfulRead!.Value;
+
+ await WaitForAsync(() => drv.GetHealth().State == DriverState.Degraded, TimeSpan.FromSeconds(3));
+ var stopped = drv.GetHealth();
+ stopped.State.ShouldBe(DriverState.Degraded);
+ stopped.LastError.ShouldNotBeNull().ShouldContain("PLC in Stop");
+ await WaitForAsync(() => drv.GetHealth().LastSuccessfulRead > initStamp, TimeSpan.FromSeconds(3));
+ drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(initStamp);
+
+ // A Good read while the PLC is stopped must not paper over it.
+ _ = await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+
+ fake.AdsStateText = "Run";
+ await WaitForAsync(() => drv.GetHealth().State == DriverState.Healthy, TimeSpan.FromSeconds(3));
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ await drv.ShutdownAsync(TestContext.Current.CancellationToken);
+ }
+
+ // ---- MINOR 1: refused dead registrations back off; a class change republishes ----
+
+ ///
+ /// A REFUSED dead registration is not re-attempted every probe tick; a comms-failed one is (positive
+ /// control: it recovers). A symbol-version-changed signal re-arms the refused one at once.
+ ///
+ [Fact]
+ public async Task Refused_registrations_back_off_while_comms_failures_retry_each_tick()
+ {
+ var fake = new FakeTwinCATClient();
+ fake.AddNotificationErrors["MAIN.T"] = new TwinCATTypeMismatchException("MAIN.T", "declared DInt, PLC STRING");
+ fake.AddNotificationErrors["MAIN.C"] = new InvalidOperationException("transient");
+ var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: true,
+ new TwinCATTagDefinition("T", Host, "MAIN.T", TwinCATDataType.DInt),
+ new TwinCATTagDefinition("C", Host, "MAIN.C", TwinCATDataType.DInt));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ _ = await drv.SubscribeAsync(["T", "C"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
+
+ await WaitForAsync(() => fake.AttemptsFor("MAIN.C") >= 3, TimeSpan.FromSeconds(3));
+ fake.AttemptsFor("MAIN.C").ShouldBeGreaterThanOrEqualTo(3); // comms class: every tick
+ fake.AttemptsFor("MAIN.T").ShouldBe(1); // refusal: backed off
+
+ fake.AddNotificationErrors.TryRemove("MAIN.C", out _);
+ await WaitForAsync(() => fake.Notifications.Any(n => n.SymbolPath == "MAIN.C"), TimeSpan.FromSeconds(3));
+ fake.Notifications.ShouldContain(n => n.SymbolPath == "MAIN.C");
+
+ fake.FireSymbolVersionChanged();
+ await WaitForAsync(() => fake.AttemptsFor("MAIN.T") >= 2, TimeSpan.FromSeconds(3));
+ fake.AttemptsFor("MAIN.T").ShouldBe(2);
+ await drv.ShutdownAsync(TestContext.Current.CancellationToken);
+ }
+
+ /// When a retry fails with a different failure class the tag's status is republished, once.
+ [Fact]
+ public async Task A_retry_whose_failure_class_changes_republishes_the_status()
+ {
+ var fake = new FakeTwinCATClient();
+ fake.AddNotificationErrors["MAIN.X"] = new InvalidOperationException("router hiccup");
+ var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, FastProbe, native: true,
+ new TwinCATTagDefinition("X", Host, "MAIN.X", TwinCATDataType.DInt));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var events = new ConcurrentQueue();
+ drv.OnDataChange += (_, e) => events.Enqueue(e);
+ _ = await drv.SubscribeAsync(["X"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
+ events.ShouldHaveSingleItem().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadCommunicationError);
+ await WaitForAsync(() => fake.AttemptsFor("MAIN.X") >= 3, TimeSpan.FromSeconds(3));
+ events.Count.ShouldBe(1, "a repeat of the same failure class publishes nothing");
+
+ fake.AddNotificationErrors["MAIN.X"] = new TwinCATNotificationRefusedException(
+ "MAIN.X", "symbol gone", TwinCATStatusMapper.BadNodeIdUnknown);
+ await WaitForAsync(() => events.Count >= 2, TimeSpan.FromSeconds(3));
+ events.Last().Snapshot.StatusCode.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
+ await drv.ShutdownAsync(TestContext.Current.CancellationToken);
+ }
+
+ // ---- MINOR 2: per-device worst-of health ----
+
+ ///
+ /// A comms fault on device B is not cleared by device A answering; B answering clears it
+ /// (positive control).
+ ///
+ [Fact]
+ public async Task One_devices_answer_does_not_clear_another_devices_comms_fault()
+ {
+ var clients = new ConcurrentDictionary();
+ var build = 0;
+ var factory = new FakeTwinCATClientFactory
+ {
+ Customise = () =>
+ {
+ var c = new FakeTwinCATClient();
+ clients[Interlocked.Increment(ref build)] = c;
+ return c;
+ },
+ };
+ var drv = NewDriver(factory, new TwinCATProbeOptions { Enabled = false }, native: false,
+ new TwinCATTagDefinition("A", Host, "MAIN.A", TwinCATDataType.DInt),
+ new TwinCATTagDefinition("B", HostB, "MAIN.B", TwinCATDataType.DInt));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ _ = await drv.ReadAsync(["A"], TestContext.Current.CancellationToken); // client 1 = device A
+ _ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken); // client 2 = device B
+ clients[2].ReadStatuses["MAIN.B"] = TwinCATStatusMapper.BadTimeout;
+
+ _ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+
+ _ = await drv.ReadAsync(["A"], TestContext.Current.CancellationToken);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded, "device A answering says nothing about device B");
+
+ clients[2].ReadStatuses.Remove("MAIN.B");
+ _ = await drv.ReadAsync(["B"], TestContext.Current.CancellationToken);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ // ---- MINOR 3: RMW gates are kept across generations ----
+
+ /// The per-parent RMW gate survives a teardown (a write still in flight keeps the same gate).
+ [Fact]
+ public async Task Bit_RMW_gates_survive_a_shutdown()
+ {
+ var fake = new FakeTwinCATClient { ParentWords = { ["MAIN.Flags"] = [0, 0] } };
+ var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
+ new TwinCATProbeOptions { Enabled = false }, native: false,
+ new TwinCATTagDefinition("Bit", Host, "MAIN.Flags.1", TwinCATDataType.Bool));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ _ = await drv.WriteAsync([new WriteRequest("Bit", true)], TestContext.Current.CancellationToken);
+ var gate = drv.BitRmwGateForTest(Host, "MAIN.Flags").ShouldNotBeNull();
+
+ await drv.ShutdownAsync(TestContext.Current.CancellationToken);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ _ = await drv.WriteAsync([new WriteRequest("Bit", false)], TestContext.Current.CancellationToken);
+
+ drv.BitRmwGateForTest(Host, "MAIN.Flags").ShouldBeSameAs(gate);
+ }
+
+ // ---- MINOR 4: ShutdownAsync honours its token ----
+
+ ///
+ /// A cancelled ShutdownAsync stops waiting on a probe wedged in connect and throws
+ /// OperationCanceledException; the teardown itself still happened (tables emptied).
+ ///
+ [Fact]
+ public async Task Shutdown_honours_its_cancellation_token()
+ {
+ var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
+ var drv = NewDriver(factory, FastProbe, native: false);
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
+
+ using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(100));
+ var sw = Stopwatch.StartNew();
+ await Should.ThrowAsync(() => drv.ShutdownAsync(cts.Token));
+ sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ProbeJoinTimeout, "the wait ended on the token, not the join bound");
+ drv.DeviceCount.ShouldBe(0);
+ drv.GetHealth().State.ShouldBe(DriverState.Unknown);
+
+ // The background teardown finishes once the wedge releases, and disposes the client the wedged
+ // connect produced (no dangling teardown left behind the test).
+ var teardown = drv.LastTeardownForTest.ShouldNotBeNull();
+ wedge.SetResult();
+ await teardown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ await WaitForAsync(() => factory.Clients[0].DisposeCount > 0, TimeSpan.FromSeconds(3));
+ factory.Clients[0].DisposeCount.ShouldBeGreaterThan(0);
+ }
+
+ // ---- MINOR 7: the real STRING / array encode path through the client planning helpers ----
+
+ ///
+ /// A STRING write through the driver lands as NUL-padded Windows-1252 bytes at the PLC symbol's width;
+ /// an over-long value is BadOutOfRange and a WSTRING symbol under a String tag BadTypeMismatch.
+ ///
+ [Fact]
+ public async Task String_writes_go_through_the_client_encode_plan()
+ {
+ var fake = new FakeTwinCATClient();
+ fake.SymbolShapes["MAIN.Name"] = TwinCATPlcSymbolShape.Classify("STRING(4)", DataTypeCategory.String, 5);
+ fake.SymbolShapes["MAIN.Wide"] = TwinCATPlcSymbolShape.Classify("WSTRING(4)", DataTypeCategory.String, 10);
+ var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
+ new TwinCATProbeOptions { Enabled = false }, native: false,
+ new TwinCATTagDefinition("Name", Host, "MAIN.Name", TwinCATDataType.String),
+ new TwinCATTagDefinition("Wide", Host, "MAIN.Wide", TwinCATDataType.String));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ var results = await drv.WriteAsync(
+ [
+ new WriteRequest("Name", "äb"),
+ new WriteRequest("Name", "toolong"),
+ new WriteRequest("Wide", "x"),
+ ], TestContext.Current.CancellationToken);
+
+ results.Select(r => r.StatusCode).ShouldBe(
+ [TwinCATStatusMapper.Good, TwinCATStatusMapper.BadOutOfRange, TwinCATStatusMapper.BadTypeMismatch]);
+ fake.RawWrites["MAIN.Name"].ShouldBe(new byte[] { 0xE4, (byte)'b', 0, 0, 0 });
+ fake.RawWrites.ShouldNotContainKey("MAIN.Wide");
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ /// An array write through the driver lands as the codec's bytes at the PLC element stride.
+ [Fact]
+ public async Task Array_writes_go_through_the_client_encode_plan()
+ {
+ var fake = new FakeTwinCATClient();
+ var element = TwinCATPlcSymbolShape.Classify("INT", DataTypeCategory.Primitive, 2);
+ fake.SymbolShapes["MAIN.Buf"] = TwinCATPlcSymbolShape.Classify("ARRAY [0..3] OF INT", DataTypeCategory.Array, 8);
+ fake.ArrayShapes["MAIN.Buf"] = new TwinCATPlcArrayShape([4], 8, element);
+ var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
+ new TwinCATProbeOptions { Enabled = false }, native: false,
+ new TwinCATTagDefinition("Buf", Host, "MAIN.Buf", TwinCATDataType.Int, ArrayLength: 2),
+ new TwinCATTagDefinition("BufAsReal", Host, "MAIN.Buf", TwinCATDataType.Real, ArrayLength: 2));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+
+ var results = await drv.WriteAsync(
+ [
+ new WriteRequest("Buf", new[] { 1, -2 }),
+ new WriteRequest("BufAsReal", new[] { 1.0, 2.0 }),
+ ], TestContext.Current.CancellationToken);
+
+ results[0].StatusCode.ShouldBe(TwinCATStatusMapper.Good);
+ fake.RawWrites["MAIN.Buf"].ShouldBe(new byte[] { 0x01, 0x00, 0xFE, 0xFF }); // leading sub-range only
+ results[1].StatusCode.ShouldBe(TwinCATStatusMapper.BadTypeMismatch);
+ }
+
+ // ---- MINOR 8: REAL / LREAL range ----
+
+ /// A REAL (scalar or array element) beyond float range is BadOutOfRange, never written as Infinity.
+ [Fact]
+ public async Task Out_of_range_REAL_values_are_BadOutOfRange()
+ {
+ Should.Throw(() => AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, 1e300));
+ Should.Throw(() => AdsTwinCATClient.ConvertForWrite(TwinCATDataType.LReal, "1e400"));
+ AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, 3.5).ShouldBe(3.5f);
+ AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, float.PositiveInfinity).ShouldBe(float.PositiveInfinity);
+ float.IsNaN((float)AdsTwinCATClient.ConvertForWrite(TwinCATDataType.Real, double.NaN)).ShouldBeTrue();
+
+ var fake = new FakeTwinCATClient();
+ var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake },
+ new TwinCATProbeOptions { Enabled = false }, native: false,
+ new TwinCATTagDefinition("Arr", Host, "MAIN.Arr", TwinCATDataType.Real, ArrayLength: 2));
+ await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
+ var result = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1.0, 1e300 })], TestContext.Current.CancellationToken);
+ result.Single().StatusCode.ShouldBe(TwinCATStatusMapper.BadOutOfRange);
+ fake.ArrayWriteLog.ShouldBeEmpty();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReviewFixRound2Tests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReviewFixRound2Tests.cs
new file mode 100644
index 00000000..0acc860f
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests/TwinCATReviewFixRound2Tests.cs
@@ -0,0 +1,262 @@
+using System.Collections.Concurrent;
+using Shouldly;
+using TwinCAT.TypeSystem;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
+
+///